diff --git a/example/lib/common.dart b/example/lib/common.dart index d3d83e7..6671af9 100644 --- a/example/lib/common.dart +++ b/example/lib/common.dart @@ -26,4 +26,26 @@ class LoggerInterceptor extends InterceptorContract { } return response; } + + @override + void interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) { + log('----- Error -----'); + if (request != null) { + log('Request: ${request.toString()}'); + } + if (response != null) { + log('Response: ${response.toString()}'); + } + if (error != null) { + log('Error: ${error.toString()}'); + } + if (stackTrace != null) { + log('StackTrace: $stackTrace'); + } + } } diff --git a/lib/extensions/base_request.dart b/lib/extensions/base_request.dart index 4c77ea2..aaf3156 100644 --- a/lib/extensions/base_request.dart +++ b/lib/extensions/base_request.dart @@ -33,42 +33,39 @@ extension BaseRequestCopyWith on BaseRequest { List? files, // StreamedRequest only properties. Stream>? stream, - }) { - if (this is Request) { - return RequestCopyWith(this as Request).copyWith( - method: method, - url: url, - headers: headers, - body: body, - encoding: encoding, - followRedirects: followRedirects, - maxRedirects: maxRedirects, - persistentConnection: persistentConnection, - ); - } else if (this is StreamedRequest) { - return StreamedRequestCopyWith(this as StreamedRequest).copyWith( - method: method, - url: url, - headers: headers, - stream: stream, - followRedirects: followRedirects, - maxRedirects: maxRedirects, - persistentConnection: persistentConnection, - ); - } else if (this is MultipartRequest) { - return MultipartRequestCopyWith(this as MultipartRequest).copyWith( - method: method, - url: url, - headers: headers, - fields: fields, - files: files, - followRedirects: followRedirects, - maxRedirects: maxRedirects, - persistentConnection: persistentConnection, - ); - } - - throw UnsupportedError( - 'Cannot copy unsupported type of request $runtimeType'); - } + }) => + switch (this) { + Request req => req.copyWith( + method: method, + url: url, + headers: headers, + body: body, + encoding: encoding, + followRedirects: followRedirects, + maxRedirects: maxRedirects, + persistentConnection: persistentConnection, + ), + StreamedRequest req => req.copyWith( + method: method, + url: url, + headers: headers, + stream: stream, + followRedirects: followRedirects, + maxRedirects: maxRedirects, + persistentConnection: persistentConnection, + ), + MultipartRequest req => req.copyWith( + method: method, + url: url, + headers: headers, + fields: fields, + files: files, + followRedirects: followRedirects, + maxRedirects: maxRedirects, + persistentConnection: persistentConnection, + ), + _ => throw UnsupportedError( + 'Cannot copy unsupported type of request $runtimeType', + ), + }; } diff --git a/lib/extensions/base_response_io.dart b/lib/extensions/base_response_io.dart index ec46047..8f7c0c1 100644 --- a/lib/extensions/base_response_io.dart +++ b/lib/extensions/base_response_io.dart @@ -32,43 +32,40 @@ extension BaseResponseCopyWith on BaseResponse { int? contentLength, // `IOStreamedResponse` only properties. HttpClientResponse? inner, - }) { - if (this is Response) { - return ResponseCopyWith(this as Response).copyWith( - statusCode: statusCode, - body: body, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase, - ); - } else if (this is StreamedResponse) { - return StreamedResponseCopyWith(this as StreamedResponse).copyWith( - stream: stream, - statusCode: statusCode, - contentLength: contentLength, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase, - ); - } else if (this is IOStreamedResponse) { - return IOStreamedResponseCopyWith(this as IOStreamedResponse).copyWith( - stream: stream, - statusCode: statusCode, - contentLength: contentLength, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase, - inner: inner, - ); - } - - throw UnsupportedError( - 'Cannot copy unsupported type of response $runtimeType'); - } + }) => + switch (this) { + Response res => res.copyWith( + statusCode: statusCode, + body: body, + request: request, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + ), + IOStreamedResponse res => res.copyWith( + stream: stream, + statusCode: statusCode, + contentLength: contentLength, + request: request, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + inner: inner, + ), + StreamedResponse res => res.copyWith( + stream: stream, + statusCode: statusCode, + contentLength: contentLength, + request: request, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + ), + _ => throw UnsupportedError( + 'Cannot copy unsupported type of response $runtimeType', + ), + }; } diff --git a/lib/extensions/base_response_none.dart b/lib/extensions/base_response_none.dart index a2e72f3..8a23ee5 100644 --- a/lib/extensions/base_response_none.dart +++ b/lib/extensions/base_response_none.dart @@ -24,31 +24,29 @@ extension BaseResponseCopyWith on BaseResponse { // `StreamedResponse` only properties. Stream>? stream, int? contentLength, - }) { - if (this is Response) { - return ResponseCopyWith(this as Response).copyWith( - statusCode: statusCode, - body: body, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase, - ); - } else if (this is StreamedResponse) { - return StreamedResponseCopyWith(this as StreamedResponse).copyWith( - stream: stream, - statusCode: statusCode, - contentLength: contentLength, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase, - ); - } - - throw UnsupportedError( - 'Cannot copy unsupported type of response $runtimeType'); - } + }) => + switch (this) { + Response res => res.copyWith( + statusCode: statusCode, + body: body, + request: request, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + ), + StreamedResponse res => res.copyWith( + stream: stream, + statusCode: statusCode, + contentLength: contentLength, + request: request, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + ), + _ => throw UnsupportedError( + 'Cannot copy unsupported type of response $runtimeType', + ), + }; } diff --git a/lib/extensions/io_streamed_response.dart b/lib/extensions/io_streamed_response.dart index 1300589..424f870 100644 --- a/lib/extensions/io_streamed_response.dart +++ b/lib/extensions/io_streamed_response.dart @@ -14,17 +14,16 @@ extension IOStreamedResponseCopyWith on IOStreamedResponse { bool? persistentConnection, String? reasonPhrase, HttpClientResponse? inner, - }) { - return IOStreamedResponse( - stream ?? this.stream, - statusCode ?? this.statusCode, - contentLength: contentLength ?? this.contentLength, - request: request ?? this.request, - headers: headers ?? this.headers, - isRedirect: isRedirect ?? this.isRedirect, - persistentConnection: persistentConnection ?? this.persistentConnection, - reasonPhrase: reasonPhrase ?? this.reasonPhrase, - inner: inner, - ); - } + }) => + IOStreamedResponse( + stream ?? this.stream, + statusCode ?? this.statusCode, + contentLength: contentLength ?? this.contentLength, + request: request ?? this.request, + headers: headers ?? this.headers, + isRedirect: isRedirect ?? this.isRedirect, + persistentConnection: persistentConnection ?? this.persistentConnection, + reasonPhrase: reasonPhrase ?? this.reasonPhrase, + inner: inner, + ); } diff --git a/lib/extensions/multipart_request.dart b/lib/extensions/multipart_request.dart index acb676f..a73912f 100644 --- a/lib/extensions/multipart_request.dart +++ b/lib/extensions/multipart_request.dart @@ -15,12 +15,12 @@ extension MultipartRequestCopyWith on MultipartRequest { int? maxRedirects, bool? persistentConnection, }) { - var clonedRequest = + final MultipartRequest clonedRequest = MultipartRequest(method?.asString ?? this.method, url ?? this.url) ..headers.addAll(headers ?? this.headers) ..fields.addAll(fields ?? this.fields); - for (var file in this.files) { + for (final MultipartFile file in this.files) { clonedRequest.files.add(MultipartFile( file.field, file.finalize(), diff --git a/lib/extensions/request.dart b/lib/extensions/request.dart index bce7521..59c8fdd 100644 --- a/lib/extensions/request.dart +++ b/lib/extensions/request.dart @@ -18,7 +18,7 @@ extension RequestCopyWith on Request { int? maxRedirects, bool? persistentConnection, }) { - final copied = Request( + final Request copied = Request( method?.asString ?? this.method, url ?? this.url, )..bodyBytes = this.bodyBytes; diff --git a/lib/extensions/streamed_request.dart b/lib/extensions/streamed_request.dart index 411d28e..e0a8e75 100644 --- a/lib/extensions/streamed_request.dart +++ b/lib/extensions/streamed_request.dart @@ -15,20 +15,19 @@ extension StreamedRequestCopyWith on StreamedRequest { bool? persistentConnection, }) { // Create a new StreamedRequest with the same method and URL - var clonedRequest = + final StreamedRequest clonedRequest = StreamedRequest(method?.asString ?? this.method, url ?? this.url) ..headers.addAll(headers ?? this.headers); // Use a broadcast stream to allow multiple listeners - var broadcastStream = + final Stream> broadcastStream = stream?.asBroadcastStream() ?? finalize().asBroadcastStream(); // Pipe the broadcast stream into the cloned request's sink - broadcastStream.listen((data) { - clonedRequest.sink.add(data); - }, onDone: () { - clonedRequest.sink.close(); - }); + broadcastStream.listen( + (List data) => clonedRequest.sink.add(data), + onDone: () => clonedRequest.sink.close(), + ); this.persistentConnection = persistentConnection ?? this.persistentConnection; diff --git a/lib/extensions/uri.dart b/lib/extensions/uri.dart index 86460f6..9e81ae5 100644 --- a/lib/extensions/uri.dart +++ b/lib/extensions/uri.dart @@ -1,31 +1,23 @@ import 'package:http_interceptor/extensions/string.dart'; import 'package:http_interceptor/utils/utils.dart'; -/// Extends `Uri` to allow adding parameters to already created intstances +/// Extends `Uri` to allow adding parameters to already created instances. extension AddParameters on Uri { - /// Returns a new `Uri` instance based on `this` and adds [parameters]. - Uri addParameters(Map? parameters) { - if (parameters == null) return this; - - String paramUrl = origin + path; - - Map newParameters = {}; - - queryParametersAll.forEach((key, values) { - newParameters[key] = values; - }); - - parameters.forEach((key, value) { - newParameters[key] = value; - }); - - String finalUrl = buildUrlString(paramUrl, newParameters); - - // Preserve the fragment if it exists - if (fragment.isNotEmpty) { - finalUrl += '#$fragment'; - } - - return finalUrl.toUri(); - } + /// Returns a new [Uri] instance based on `this` and adds [parameters]. + Uri addParameters([Map? parameters]) => + parameters?.isNotEmpty ?? false + ? (StringBuffer() + ..writeAll([ + buildUrlString( + "$origin$path", + { + ...queryParametersAll, + ...?parameters, + }, + ), + if (fragment.isNotEmpty) '#$fragment', + ])) + .toString() + .toUri() + : this; } diff --git a/lib/http/http_methods.dart b/lib/http/http_methods.dart index c53aa9b..cf922cb 100644 --- a/lib/http/http_methods.dart +++ b/lib/http/http_methods.dart @@ -7,47 +7,27 @@ enum HttpMethod { PUT, PATCH, DELETE, -} + OPTIONS; -/// Extends [HttpMethod] to be initialized from a [String] value. -extension StringToMethod on HttpMethod { - /// Parses an string into a Method Enum value. - static HttpMethod fromString(String method) { - switch (method) { - case "HEAD": - return HttpMethod.HEAD; - case "GET": - return HttpMethod.GET; - case "POST": - return HttpMethod.POST; - case "PUT": - return HttpMethod.PUT; - case "PATCH": - return HttpMethod.PATCH; - case "DELETE": - return HttpMethod.DELETE; - } - throw ArgumentError.value(method, "method", "Must be a valid HTTP Method."); - } -} + /// Converts a string to an [HttpMethod]. + static HttpMethod fromString(String method) => switch (method) { + "HEAD" => HttpMethod.HEAD, + "GET" => HttpMethod.GET, + "POST" => HttpMethod.POST, + "PUT" => HttpMethod.PUT, + "PATCH" => HttpMethod.PATCH, + "DELETE" => HttpMethod.DELETE, + "OPTIONS" => HttpMethod.OPTIONS, + _ => throw ArgumentError.value( + method, + "method", + "Must be a valid HTTP Method.", + ), + }; + + /// Converts the [HttpMethod] to a string. + String get asString => name; -/// Extends [HttpMethod] to provide a [String] representation. -extension MethodToString on HttpMethod { - // Parses a Method Enum value into a string. - String get asString { - switch (this) { - case HttpMethod.HEAD: - return "HEAD"; - case HttpMethod.GET: - return "GET"; - case HttpMethod.POST: - return "POST"; - case HttpMethod.PUT: - return "PUT"; - case HttpMethod.PATCH: - return "PATCH"; - case HttpMethod.DELETE: - return "DELETE"; - } - } + @override + String toString() => name; } diff --git a/lib/http/intercepted_client.dart b/lib/http/intercepted_client.dart index 1627c8a..323623c 100644 --- a/lib/http/intercepted_client.dart +++ b/lib/http/intercepted_client.dart @@ -55,7 +55,7 @@ class InterceptedClient extends BaseClient { final RetryPolicy? retryPolicy; int _retryCount = 0; - late Client _inner; + late final Client _inner; InterceptedClient._internal({ required this.interceptors, @@ -194,11 +194,10 @@ class InterceptedClient extends BaseClient { Uri url, { Map? headers, Map? params, - }) { - return get(url, headers: headers, params: params).then((response) { - _checkResponseSuccess(url, response); - return response.body; - }); + }) async { + final Response response = await get(url, headers: headers, params: params); + _checkResponseSuccess(url, response); + return response.body; } @override @@ -206,18 +205,18 @@ class InterceptedClient extends BaseClient { Uri url, { Map? headers, Map? params, - }) { - return get(url, headers: headers, params: params).then((response) { - _checkResponseSuccess(url, response); - return response.bodyBytes; - }); + }) async { + final Response response = await get(url, headers: headers, params: params); + _checkResponseSuccess(url, response); + return response.bodyBytes; } @override Future send(BaseRequest request) async { - final response = await _attemptRequest(request, isStream: true); + final BaseResponse response = + await _attemptRequest(request, isStream: true); - final interceptedResponse = await _interceptResponse(response); + final BaseResponse interceptedResponse = await _interceptResponse(response); if (interceptedResponse is StreamedResponse) { return interceptedResponse; @@ -236,9 +235,10 @@ class InterceptedClient extends BaseClient { Object? body, Encoding? encoding, }) async { - url = url.addParameters(params); - - Request request = Request(method.asString, url); + final Request request = Request( + method.asString, + url.addParameters(params), + ); if (headers != null) request.headers.addAll(headers); if (encoding != null) request.encoding = encoding; if (body != null) { @@ -253,53 +253,58 @@ class InterceptedClient extends BaseClient { } } - var response = await _attemptRequest(request); + final BaseResponse response = await _attemptRequest(request); // Intercept response - response = await _interceptResponse(response); - - return response; + return await _interceptResponse(response); } void _checkResponseSuccess(Uri url, Response response) { if (response.statusCode < 400) return; - var message = "Request to $url failed with status ${response.statusCode}"; - if (response.reasonPhrase != null) { - message = "$message: ${response.reasonPhrase}"; - } + final StringBuffer message = StringBuffer() + ..writeAll([ + "Request to $url failed with status ${response.statusCode}", + if (response.reasonPhrase != null) ": ${response.reasonPhrase}", + ]); + throw ClientException("$message.", url); } /// Attempts to perform the request and intercept the data /// of the response - Future _attemptRequest(BaseRequest request, - {bool isStream = false}) async { + Future _attemptRequest( + BaseRequest request, { + bool isStream = false, + }) async { _retryCount = 0; // Reset retry count for each new request return _attemptRequestWithRetries(request, isStream: isStream); } /// Internal method that handles the actual request with retry logic - Future _attemptRequestWithRetries(BaseRequest request, - {bool isStream = false}) async { + Future _attemptRequestWithRetries( + BaseRequest request, { + bool isStream = false, + }) async { BaseResponse response; + try { // Intercept request - final interceptedRequest = await _interceptRequest(request); + final BaseRequest interceptedRequest = await _interceptRequest(request); + + late final StreamedResponse stream; - StreamedResponse stream; if (requestTimeout == null) { stream = await _inner.send(interceptedRequest); } else { // Use a completer to properly handle timeout and cancellation - final completer = Completer(); + final Completer completer = Completer(); final Future requestFuture = _inner.send(interceptedRequest); // Set up timeout with proper cleanup - Timer? timeoutTimer; bool isCompleted = false; - timeoutTimer = Timer(requestTimeout!, () { + final Timer timeoutTimer = Timer(requestTimeout!, () { if (!isCompleted) { isCompleted = true; if (onRequestTimeout != null) { @@ -338,7 +343,7 @@ class InterceptedClient extends BaseClient { // Handle the actual request completion requestFuture.then((streamResponse) { - timeoutTimer?.cancel(); + timeoutTimer.cancel(); if (!isCompleted) { isCompleted = true; if (!completer.isCompleted) { @@ -346,7 +351,7 @@ class InterceptedClient extends BaseClient { } } }).catchError((error) { - timeoutTimer?.cancel(); + timeoutTimer.cancel(); if (!isCompleted) { isCompleted = true; if (!completer.isCompleted) { @@ -368,7 +373,7 @@ class InterceptedClient extends BaseClient { .delayRetryAttemptOnResponse(retryAttempt: _retryCount)); return _attemptRequestWithRetries(request, isStream: isStream); } - } on Exception catch (error) { + } on Exception catch (error, stackTrace) { if (retryPolicy != null && retryPolicy!.maxRetryAttempts > _retryCount && await retryPolicy!.shouldAttemptRetryOnException(error, request)) { @@ -377,6 +382,12 @@ class InterceptedClient extends BaseClient { .delayRetryAttemptOnException(retryAttempt: _retryCount)); return _attemptRequestWithRetries(request, isStream: isStream); } else { + await _interceptError( + request: request, + error: error, + stackTrace: stackTrace, + ); + rethrow; } } @@ -416,6 +427,26 @@ class InterceptedClient extends BaseClient { return interceptedResponse; } + /// This internal function intercepts the error. + Future _interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) async { + for (InterceptorContract interceptor in interceptors) { + if (await interceptor.shouldInterceptError( + request: request, response: response)) { + await interceptor.interceptError( + request: request, + response: response, + error: error, + stackTrace: stackTrace, + ); + } + } + } + @override void close() { _inner.close(); diff --git a/lib/http/intercepted_http.dart b/lib/http/intercepted_http.dart index 8fd82d3..7e05462 100644 --- a/lib/http/intercepted_http.dart +++ b/lib/http/intercepted_http.dart @@ -98,134 +98,143 @@ class InterceptedHttp { /// Performs a HEAD request with a new [Client] instance and closes it after /// it has been used. Future head( - url, { + Uri url, { Map? headers, - }) async { - return _withClient((client) => client.head( + }) => + _withClient( + (InterceptedClient client) => client.head( url, headers: headers, - )); - } + ), + ); /// Performs a GET request with a new [Client] instance and closes it after /// it has been used. Future get( - url, { + Uri url, { Map? headers, Map? params, - }) async { - return _withClient((client) => client.get( + }) => + _withClient( + (InterceptedClient client) => client.get( url, headers: headers, params: params, - )); - } + ), + ); /// Performs a POST request with a new [Client] instance and closes it after /// it has been used. Future post( - url, { + Uri url, { Map? headers, Map? params, Object? body, Encoding? encoding, - }) async { - return _withClient((client) => client.post( + }) => + _withClient( + (InterceptedClient client) => client.post( url, headers: headers, params: params, body: body, encoding: encoding, - )); - } + ), + ); /// Performs a PUT request with a new [Client] instance and closes it after /// it has been used. Future put( - url, { + Uri url, { Map? headers, Map? params, Object? body, Encoding? encoding, - }) async { - return _withClient((client) => client.put( + }) => + _withClient( + (InterceptedClient client) => client.put( url, headers: headers, params: params, body: body, encoding: encoding, - )); - } + ), + ); /// Performs a PATCH request with a new [Client] instance and closes it after /// it has been used. Future patch( - url, { + Uri url, { Map? headers, Map? params, Object? body, Encoding? encoding, - }) async { - return _withClient((client) => client.patch( + }) => + _withClient( + (InterceptedClient client) => client.patch( url, headers: headers, params: params, body: body, encoding: encoding, - )); - } + ), + ); /// Performs a DELETE request with a new [Client] instance and closes it after /// it has been used. Future delete( - url, { + Uri url, { Map? headers, Map? params, Object? body, Encoding? encoding, - }) async { - return _withClient((client) => client.delete( + }) => + _withClient( + (InterceptedClient client) => client.delete( url, headers: headers, params: params, body: body, encoding: encoding, - )); - } + ), + ); /// Executes `client.read` with a new [Client] instance and closes it after /// it has been used. Future read( - url, { + Uri url, { Map? headers, Map? params, - }) { - return _withClient((client) => client.read( + }) => + _withClient( + (InterceptedClient client) => client.read( url, headers: headers, params: params, - )); - } + ), + ); /// Executes `client.readBytes` with a new [Client] instance and closes it /// after it has been used. Future readBytes( - url, { + Uri url, { Map? headers, Map? params, }) => - _withClient((client) => client.readBytes( - url, - headers: headers, - params: params, - )); + _withClient( + (InterceptedClient client) => client.readBytes( + url, + headers: headers, + params: params, + ), + ); /// Internal convenience utility to create a new [Client] instance for each /// request. It closes the client after using it for the request. Future _withClient( Future Function(InterceptedClient client) fn, ) async { - final client = InterceptedClient.build( + final InterceptedClient client = InterceptedClient.build( interceptors: interceptors, requestTimeout: requestTimeout, onRequestTimeout: onRequestTimeout, diff --git a/lib/http_interceptor.dart b/lib/http_interceptor.dart index 4191faa..e8189b1 100644 --- a/lib/http_interceptor.dart +++ b/lib/http_interceptor.dart @@ -1,4 +1,4 @@ -library http_interceptor; +library; export 'package:http/http.dart'; diff --git a/lib/models/interceptor_contract.dart b/lib/models/interceptor_contract.dart index 8929635..2a6c9d9 100644 --- a/lib/models/interceptor_contract.dart +++ b/lib/models/interceptor_contract.dart @@ -28,21 +28,33 @@ import 'package:http/http.dart'; ///} ///``` abstract class InterceptorContract { - FutureOr shouldInterceptRequest({ - required BaseRequest request, - }) => + /// Checks if the request should be intercepted. + FutureOr shouldInterceptRequest({required BaseRequest request}) => true; + + /// Intercepts the request. + FutureOr interceptRequest({required BaseRequest request}); + + /// Checks if the response should be intercepted. + FutureOr shouldInterceptResponse({required BaseResponse response}) => true; - FutureOr interceptRequest({ - required BaseRequest request, - }); + /// Intercepts the response. + FutureOr interceptResponse({required BaseResponse response}); - FutureOr shouldInterceptResponse({ - required BaseResponse response, + /// Checks if the error should be intercepted. + FutureOr shouldInterceptError({ + BaseRequest? request, + BaseResponse? response, }) => true; - FutureOr interceptResponse({ - required BaseResponse response, - }); + /// Intercepts the error response. + FutureOr interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) { + // Default implementation does nothing + } } diff --git a/lib/models/retry_policy.dart b/lib/models/retry_policy.dart index bab5515..c838493 100644 --- a/lib/models/retry_policy.dart +++ b/lib/models/retry_policy.dart @@ -43,7 +43,9 @@ abstract class RetryPolicy { /// /// Returns `true` if the request should be retried, `false` otherwise. FutureOr shouldAttemptRetryOnException( - Exception reason, BaseRequest request) => + Exception reason, + BaseRequest request, + ) => false; /// Defines whether the request should be retried after the request has diff --git a/test/extensions/request_test.dart b/test/extensions/request_test.dart index 1e8744b..09fab4b 100644 --- a/test/extensions/request_test.dart +++ b/test/extensions/request_test.dart @@ -43,7 +43,7 @@ main() { // Arrange // Act - Request copied = request.copyWith(); + final Request copied = request.copyWith(); // Assert expect(copied.hashCode, isNot(equals(request.hashCode))); @@ -58,10 +58,10 @@ main() { }); test('Request is copied with different URI', () { // Arrange - Uri newUrl = Uri.https("www.google.com", "/foobar"); + final Uri newUrl = Uri.https("www.google.com", "/foobar"); // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( url: newUrl, ); @@ -85,7 +85,7 @@ main() { const newMethod = HttpMethod.POST; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( method: newMethod, ); @@ -110,7 +110,7 @@ main() { newHeaders['Authorization'] = 'Bearer token'; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( headers: newHeaders, ); @@ -135,7 +135,7 @@ main() { newHeaders['Authorization'] = 'Bearer token'; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( headers: newHeaders, ); @@ -160,7 +160,7 @@ main() { newHeaders['content-type'] = 'text/plain; charset=utf-8'; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( headers: newHeaders, ); @@ -186,7 +186,7 @@ main() { newBody['hello'] = 'world'; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( body: jsonEncode(newBody), ); @@ -216,7 +216,7 @@ main() { // Act final utfBytes = utf8.encode(jsonEncode(newBody)); final gzipBytes = gzip.encode(utfBytes); - Request copied = request.copyWith( + final Request copied = request.copyWith( body: base64.encode(gzipBytes), ); @@ -243,12 +243,12 @@ main() { final changedHeaders = {'content-type': 'text/plain; charset=iso-8859-1'} ..addAll(request.headers); - Request updatedHeadersRequest = request.copyWith( + final Request updatedHeadersRequest = request.copyWith( headers: changedHeaders, ); // Act - Request copied = updatedHeadersRequest.copyWith( + final Request copied = updatedHeadersRequest.copyWith( encoding: newEncoding, ); @@ -273,7 +273,7 @@ main() { const newFollowRedirects = false; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( followRedirects: newFollowRedirects, ); @@ -297,7 +297,7 @@ main() { const newMaxRedirects = 2; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( maxRedirects: newMaxRedirects, ); @@ -322,7 +322,7 @@ main() { const newPersistentConnection = false; // Act - Request copied = request.copyWith( + final Request copied = request.copyWith( persistentConnection: newPersistentConnection, ); diff --git a/test/http/http_methods_test.dart b/test/http/http_methods_test.dart index 61f1e61..dca3650 100644 --- a/test/http/http_methods_test.dart +++ b/test/http/http_methods_test.dart @@ -5,77 +5,89 @@ main() { group("Can parse from string", () { test("with HEAD method", () { // Arrange - HttpMethod method; - String methodString = "HEAD"; + late final HttpMethod method; + final String methodString = "HEAD"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.HEAD)); }); test("with GET method", () { // Arrange - HttpMethod method; - String methodString = "GET"; + late final HttpMethod method; + final String methodString = "GET"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.GET)); }); test("with POST method", () { // Arrange - HttpMethod method; - String methodString = "POST"; + late final HttpMethod method; + final String methodString = "POST"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.POST)); }); test("with PUT method", () { // Arrange - HttpMethod method; - String methodString = "PUT"; + late final HttpMethod method; + final String methodString = "PUT"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.PUT)); }); test("with PATCH method", () { // Arrange - HttpMethod method; - String methodString = "PATCH"; + late final HttpMethod method; + final String methodString = "PATCH"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.PATCH)); }); test("with DELETE method", () { // Arrange - HttpMethod method; - String methodString = "DELETE"; + late final HttpMethod method; + final String methodString = "DELETE"; // Act - method = StringToMethod.fromString(methodString); + method = HttpMethod.fromString(methodString); // Assert expect(method, equals(HttpMethod.DELETE)); }); + + test("with OPTIONS method", () { + // Arrange + late final HttpMethod method; + final String methodString = "OPTIONS"; + + // Act + method = HttpMethod.fromString(methodString); + + // Assert + expect(method, equals(HttpMethod.OPTIONS)); + }); }); group("Can parse to string", () { test("to 'HEAD' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.HEAD; + final String methodString; + final HttpMethod method = HttpMethod.HEAD; // Act methodString = method.asString; @@ -85,8 +97,8 @@ main() { }); test("to 'GET' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.GET; + final String methodString; + final HttpMethod method = HttpMethod.GET; // Act methodString = method.asString; @@ -96,8 +108,8 @@ main() { }); test("to 'POST' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.POST; + final String methodString; + final HttpMethod method = HttpMethod.POST; // Act methodString = method.asString; @@ -107,8 +119,8 @@ main() { }); test("to 'PUT' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.PUT; + final String methodString; + final HttpMethod method = HttpMethod.PUT; // Act methodString = method.asString; @@ -118,8 +130,8 @@ main() { }); test("to 'PATCH' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.PATCH; + final String methodString; + final HttpMethod method = HttpMethod.PATCH; // Act methodString = method.asString; @@ -129,8 +141,8 @@ main() { }); test("to 'DELETE' string.", () { // Arrange - String methodString; - HttpMethod method = HttpMethod.DELETE; + final String methodString; + final HttpMethod method = HttpMethod.DELETE; // Act methodString = method.asString; @@ -138,17 +150,89 @@ main() { // Assert expect(methodString, equals("DELETE")); }); + + test("to 'OPTIONS' string.", () { + // Arrange + final String methodString; + final HttpMethod method = HttpMethod.OPTIONS; + + // Act + methodString = method.asString; + + // Assert + expect(methodString, equals("OPTIONS")); + }); }); group("Can control unsupported values", () { test("Throws when string is unsupported", () { // Arrange - String methodString = "UNSUPPORTED"; + final String methodString = "UNSUPPORTED"; // Act // Assert expect( - () => StringToMethod.fromString(methodString), throwsArgumentError); + () => HttpMethod.fromString(methodString), + throwsArgumentError, + ); + }); + }); + + group("toString() method returns correct string representation", () { + test("for HEAD method", () { + // Arrange + final HttpMethod method = HttpMethod.HEAD; + + // Act & Assert + expect(method.toString(), equals("HEAD")); + }); + + test("for GET method", () { + // Arrange + final HttpMethod method = HttpMethod.GET; + + // Act & Assert + expect(method.toString(), equals("GET")); + }); + + test("for POST method", () { + // Arrange + final HttpMethod method = HttpMethod.POST; + + // Act & Assert + expect(method.toString(), equals("POST")); + }); + + test("for PUT method", () { + // Arrange + final HttpMethod method = HttpMethod.PUT; + + // Act & Assert + expect(method.toString(), equals("PUT")); + }); + + test("for PATCH method", () { + // Arrange + final HttpMethod method = HttpMethod.PATCH; + + // Act & Assert + expect(method.toString(), equals("PATCH")); + }); + + test("for DELETE method", () { + // Arrange + final HttpMethod method = HttpMethod.DELETE; + + // Act & Assert + expect(method.toString(), equals("DELETE")); + }); + + test("for OPTIONS method", () { + // Arrange + final HttpMethod method = HttpMethod.OPTIONS; + + // Act & Assert + expect(method.toString(), equals("OPTIONS")); }); }); } diff --git a/test/http/intercepted_client_error_test.dart b/test/http/intercepted_client_error_test.dart new file mode 100644 index 0000000..b28de76 --- /dev/null +++ b/test/http/intercepted_client_error_test.dart @@ -0,0 +1,213 @@ +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:test/test.dart'; + +void main() { + group('InterceptedClient error interception', () { + late _MockInterceptor mockInterceptor; + late InterceptedClient client; + + setUp(() { + mockInterceptor = _MockInterceptor(); + client = InterceptedClient.build(interceptors: [mockInterceptor]); + }); + + test( + 'interceptors are called when an error occurs', + () async { + final request = Request('GET', Uri.parse('https://example.com')); + final error = Exception('Test error'); + final stackTrace = StackTrace.current; + + mockInterceptor.shouldInterceptErrorResult = true; + + // Call the internal _interceptError method indirectly + // by creating a scenario where it would be called + await _callInterceptError( + client: client, + request: request, + error: error, + stackTrace: stackTrace, + ); + + expect(mockInterceptor.interceptErrorCalled, true); + expect(mockInterceptor.lastRequest, isNotNull); + expect(mockInterceptor.lastError, isNotNull); + expect(mockInterceptor.lastStackTrace, isNotNull); + }, + ); + + test( + 'interceptors are not called when shouldInterceptError returns false', + () async { + final request = Request('GET', Uri.parse('https://example.com')); + final error = Exception('Test error'); + final stackTrace = StackTrace.current; + + mockInterceptor.shouldInterceptErrorResult = false; + + await _callInterceptError( + client: client, + request: request, + error: error, + stackTrace: stackTrace, + ); + + expect(mockInterceptor.interceptErrorCalled, false); + }, + ); + + test('multiple interceptors are called when an error occurs', () async { + final request = Request('GET', Uri.parse('https://example.com')); + final error = Exception('Test error'); + final stackTrace = StackTrace.current; + + final mockInterceptor2 = _MockInterceptor(); + + client = InterceptedClient.build( + interceptors: [mockInterceptor, mockInterceptor2], + ); + + mockInterceptor.shouldInterceptErrorResult = true; + mockInterceptor2.shouldInterceptErrorResult = true; + + await _callInterceptError( + client: client, + request: request, + error: error, + stackTrace: stackTrace, + ); + + expect(mockInterceptor.interceptErrorCalled, true); + expect(mockInterceptor2.interceptErrorCalled, true); + }); + + test('interceptors receive the correct parameters', () async { + final request = Request('GET', Uri.parse('https://example.com')); + final error = Exception('Test error'); + final stackTrace = StackTrace.current; + + mockInterceptor.shouldInterceptErrorResult = true; + + await _callInterceptError( + client: client, + request: request, + error: error, + stackTrace: stackTrace, + ); + + expect(mockInterceptor.lastRequest, isNotNull); + expect(mockInterceptor.lastError.toString(), contains('Test error')); + expect(mockInterceptor.lastStackTrace, isNotNull); + }); + }); +} + +/// Helper function to indirectly call the _interceptError method +/// by simulating a scenario where it would be called +Future _callInterceptError({ + required InterceptedClient client, + required BaseRequest request, + required Exception error, + required StackTrace stackTrace, +}) async { + // Create a custom interceptor that will call the _interceptError method + // when its interceptRequest method is called + final errorTriggeringInterceptor = _ErrorTriggeringInterceptor( + request: request, + error: error, + stackTrace: stackTrace, + ); + + // Add the interceptor to the client + final clientWithErrorInterceptor = InterceptedClient.build( + interceptors: [errorTriggeringInterceptor, ...client.interceptors], + ); + + // Make a request that will trigger the error + try { + await clientWithErrorInterceptor.send(request); + fail('Expected an exception to be thrown'); + } catch (e) { + // Exception expected + } +} + +/// Custom interceptor that throws a controlled exception during request interception +class _ErrorTriggeringInterceptor implements InterceptorContract { + final BaseRequest request; + final Exception error; + final StackTrace stackTrace; + + const _ErrorTriggeringInterceptor({ + required this.request, + required this.error, + required this.stackTrace, + }); + + @override + BaseRequest interceptRequest({required BaseRequest request}) => throw error; + + @override + BaseResponse interceptResponse({required BaseResponse response}) => response; + + @override + bool shouldInterceptRequest({required BaseRequest request}) => true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => false; + + @override + bool shouldInterceptError({BaseRequest? request, BaseResponse? response}) => + false; + + @override + void interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) { + // Do nothing + } +} + +/// Mock interceptor for testing +class _MockInterceptor implements InterceptorContract { + bool shouldInterceptErrorResult = true; + bool interceptErrorCalled = false; + + BaseRequest? lastRequest; + BaseResponse? lastResponse; + Exception? lastError; + StackTrace? lastStackTrace; + + @override + BaseRequest interceptRequest({required BaseRequest request}) => request; + + @override + BaseResponse interceptResponse({required BaseResponse response}) => response; + + @override + bool shouldInterceptError({BaseRequest? request, BaseResponse? response}) => + shouldInterceptErrorResult; + + @override + void interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) { + interceptErrorCalled = true; + lastRequest = request; + lastResponse = response; + lastError = error; + lastStackTrace = stackTrace; + } + + @override + bool shouldInterceptRequest({required BaseRequest request}) => true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => true; +} diff --git a/test/http/intercepted_client_test.dart b/test/http/intercepted_client_test.dart new file mode 100644 index 0000000..df9929e --- /dev/null +++ b/test/http/intercepted_client_test.dart @@ -0,0 +1,662 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:test/test.dart'; + +void main() { + group('InterceptedClient', () { + late _MockClient mockClient; + late _MockInterceptor mockInterceptor; + late InterceptedClient client; + + setUp(() { + mockClient = _MockClient(); + mockInterceptor = _MockInterceptor(); + client = InterceptedClient.build( + interceptors: [mockInterceptor], + client: mockClient, + ); + }); + + group('build factory method', () { + test('creates instance with provided interceptors', () { + final interceptor1 = _MockInterceptor(); + final interceptor2 = _MockInterceptor(); + + final client = InterceptedClient.build( + interceptors: [interceptor1, interceptor2], + ); + + expect(client.interceptors, contains(interceptor1)); + expect(client.interceptors, contains(interceptor2)); + expect(client.interceptors.length, 2); + }); + + test('creates instance with provided timeout', () { + final timeout = Duration(seconds: 30); + + final client = InterceptedClient.build( + interceptors: [mockInterceptor], + requestTimeout: timeout, + ); + + expect(client.requestTimeout, equals(timeout)); + }); + + test('creates instance with provided retry policy', () { + final retryPolicy = _MockRetryPolicy(); + + final client = InterceptedClient.build( + interceptors: [mockInterceptor], + retryPolicy: retryPolicy, + ); + + expect(client.retryPolicy, equals(retryPolicy)); + }); + + test('creates instance with provided client', () async { + final client = InterceptedClient.build( + interceptors: [mockInterceptor], + client: mockClient, + ); + + // We can't directly check _inner as it's private, + // but we can verify it's used by making a request + await client.get(Uri.parse('https://example.com')); + expect(mockClient.requestCount, 1); + }); + }); + + group('HTTP methods', () { + setUp(() { + mockClient._responseBody = utf8.encode('{"success": true}'); + mockClient._responseStatusCode = 200; + mockClient._responseHeaders = {'content-type': 'application/json'}; + }); + + test('GET method sends correct request', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Authorization': 'Bearer token'}; + final params = {'query': 'test'}; + + await client.get(url, headers: headers, params: params); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'GET'); + expect(request.url.toString(), 'https://example.com?query=test'); + expect(request.headers['Authorization'], 'Bearer token'); + }); + + test('POST method sends correct request with string body', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Content-Type': 'application/json'}; + final body = '{"name": "test"}'; + + await client.post(url, headers: headers, body: body); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'POST'); + expect(request.url.toString(), 'https://example.com'); + expect(request.headers['Content-Type'], contains('application/json')); + + // Verify the body was set correctly in our mock client + expect(mockClient.lastRequestBody, '{"name": "test"}'); + }); + + test('POST method sends correct request with map body', () async { + final url = Uri.parse('https://example.com'); + final body = {'name': 'test'}; + + await client.post(url, body: body); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'POST'); + expect(request.url.toString(), 'https://example.com'); + expect(mockClient.lastRequestFields, {'name': 'test'}); + }); + + test('PUT method sends correct request', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Content-Type': 'application/json'}; + final body = '{"name": "test"}'; + + await client.put(url, headers: headers, body: body); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'PUT'); + expect(request.url.toString(), 'https://example.com'); + expect(request.headers['Content-Type'], contains('application/json')); + expect(mockClient.lastRequestBody, '{"name": "test"}'); + }); + + test('PATCH method sends correct request', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Content-Type': 'application/json'}; + final body = '{"name": "test"}'; + + await client.patch(url, headers: headers, body: body); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'PATCH'); + expect(request.url.toString(), 'https://example.com'); + expect(request.headers['Content-Type'], contains('application/json')); + expect(mockClient.lastRequestBody, '{"name": "test"}'); + }); + + test('DELETE method sends correct request', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Authorization': 'Bearer token'}; + + await client.delete(url, headers: headers); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'DELETE'); + expect(request.url.toString(), 'https://example.com'); + expect(request.headers['Authorization'], 'Bearer token'); + }); + + test('HEAD method sends correct request', () async { + final url = Uri.parse('https://example.com'); + final headers = {'Authorization': 'Bearer token'}; + + await client.head(url, headers: headers); + + expect(mockClient.requests.length, 1); + final request = mockClient.requests.first; + expect(request.method, 'HEAD'); + expect(request.url.toString(), 'https://example.com'); + expect(request.headers['Authorization'], 'Bearer token'); + }); + + test('read method returns response body as string', () async { + final url = Uri.parse('https://example.com'); + + // Set up the response with the expected body + mockClient._responseBody = utf8.encode('response body'); + mockClient._responseStatusCode = 200; + + final result = await client.read(url); + + expect(result, 'response body'); + }); + + test('readBytes method returns response body as bytes', () async { + final url = Uri.parse('https://example.com'); + final bytes = utf8.encode('response body'); + + // Set up the response with the expected body + mockClient._responseBody = bytes; + mockClient._responseStatusCode = 200; + + final result = await client.readBytes(url); + + expect(result, bytes); + }); + + test('read method throws exception for error response', () async { + final url = Uri.parse('https://example.com'); + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('error')), + 404, + reasonPhrase: 'Not Found', + ); + + expect( + () => client.read(url), + throwsA(isA()), + ); + }); + + test('send method returns StreamedResponse', () async { + final request = Request('GET', Uri.parse('https://example.com')); + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('response body')), + 200, + ); + + final response = await client.send(request); + + expect(response, isA()); + expect(response.statusCode, 200); + }); + }); + + group('request interception', () { + setUp(() { + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('{"success": true}')), + 200, + ); + }); + + test('interceptors are called for requests', () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.shouldInterceptRequestResult = true; + + await client.get(url); + + expect(mockInterceptor.interceptRequestCalled, true); + }); + + test( + 'interceptors are not called when shouldInterceptRequest returns false', + () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.shouldInterceptRequestResult = false; + + await client.get(url); + + expect(mockInterceptor.interceptRequestCalled, false); + }, + ); + + test('multiple interceptors are called in order', () async { + final url = Uri.parse('https://example.com'); + final interceptor1 = _OrderTrackingInterceptor(1); + final interceptor2 = _OrderTrackingInterceptor(2); + + client = InterceptedClient.build( + interceptors: [interceptor1, interceptor2], + client: mockClient, + ); + + await client.get(url); + + expect(_OrderTrackingInterceptor.callOrder, [1, 2]); + }); + + test('request modifications are applied', () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.requestModification = (request) { + request.headers['X-Modified'] = 'true'; + return request; + }; + + await client.get(url); + + expect(mockClient.requests.first.headers['X-Modified'], 'true'); + }); + }); + + group('response interception', () { + setUp(() { + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('{"success": true}')), + 200, + ); + }); + + test('interceptors are called for responses', () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.shouldInterceptResponseResult = true; + + await client.get(url); + + expect(mockInterceptor.interceptResponseCalled, true); + }); + + test( + 'interceptors are not called when shouldInterceptResponse returns false', + () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.shouldInterceptResponseResult = false; + + await client.get(url); + + expect(mockInterceptor.interceptResponseCalled, false); + }, + ); + + test('multiple interceptors are called in order for responses', () async { + final url = Uri.parse('https://example.com'); + final interceptor1 = _OrderTrackingInterceptor(1); + final interceptor2 = _OrderTrackingInterceptor(2); + + // Clear both tracking lists + _OrderTrackingInterceptor.callOrder.clear(); + _OrderTrackingInterceptor.responseCallOrder.clear(); + + client = InterceptedClient.build( + interceptors: [interceptor1, interceptor2], + client: mockClient, + ); + + await client.get(url); + + expect(_OrderTrackingInterceptor.responseCallOrder, [1, 2]); + }); + + test('response modifications are applied', () async { + final url = Uri.parse('https://example.com'); + mockInterceptor.responseModification = (response) { + return Response('modified body', 200); + }; + + final response = await client.get(url); + + expect(response.body, 'modified body'); + }); + }); + + group('retry policy', () { + late _MockRetryPolicy retryPolicy; + + setUp(() { + retryPolicy = _MockRetryPolicy(); + client = InterceptedClient.build( + interceptors: [mockInterceptor], + client: mockClient, + retryPolicy: retryPolicy, + ); + }); + + test('retries on response when policy allows', () async { + final url = Uri.parse('https://example.com'); + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('error')), + 500, + ); + + retryPolicy.shouldRetryOnResponse = true; + retryPolicy.maxRetryAttempts = 1; + + await client.get(url); + + expect(mockClient.requestCount, 2); // Original + 1 retry + }); + + test('retries on exception when policy allows', () async { + final url = Uri.parse('https://example.com'); + mockClient.shouldThrow = true; + mockClient.exceptionToThrow = Exception('Network error'); + + retryPolicy.shouldRetryOnException = true; + retryPolicy.maxRetryAttempts = 1; + + await expectLater( + () => client.get(url), + throwsException, + ); + + expect(mockClient.requestCount, 2); // Original + 1 retry + }); + + test('respects max retry attempts', () async { + final url = Uri.parse('https://example.com'); + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('error')), + 500, + ); + + retryPolicy.shouldRetryOnResponse = true; + retryPolicy.maxRetryAttempts = 3; + + await client.get(url); + + expect(mockClient.requestCount, 4); // Original + 3 retries + }); + + test('uses delay from retry policy', () async { + final url = Uri.parse('https://example.com'); + mockClient.response = StreamedResponse( + Stream.value(utf8.encode('error')), + 500, + ); + + retryPolicy.shouldRetryOnResponse = true; + retryPolicy.maxRetryAttempts = 1; + retryPolicy.delay = Duration(milliseconds: 100); + + final stopwatch = Stopwatch()..start(); + await client.get(url); + stopwatch.stop(); + + expect(stopwatch.elapsedMilliseconds, greaterThanOrEqualTo(100)); + }); + }); + + group('timeout handling', () { + setUp(() { + client = InterceptedClient.build( + interceptors: [mockInterceptor], + client: mockClient, + requestTimeout: Duration(milliseconds: 100), + ); + }); + + test('throws exception on timeout when no callback provided', () async { + final url = Uri.parse('https://example.com'); + mockClient.delayResponse = Duration(milliseconds: 200); + + expect( + () => client.get(url), + throwsA(isA()), + ); + }); + + test('uses timeout callback when provided', () async { + final url = Uri.parse('https://example.com'); + mockClient.delayResponse = Duration(milliseconds: 200); + + bool callbackCalled = false; + client = InterceptedClient.build( + interceptors: [mockInterceptor], + client: mockClient, + requestTimeout: Duration(milliseconds: 100), + onRequestTimeout: () { + callbackCalled = true; + return StreamedResponse( + Stream.value(utf8.encode('timeout response')), + 408, + ); + }, + ); + + final response = await client.get(url); + + expect(callbackCalled, true); + expect(response.statusCode, 408); + expect(response.body, 'timeout response'); + }); + }); + + test('close method closes the inner client', () { + client.close(); + + expect(mockClient.closeCalled, true); + }); + }); +} + +class _MockClient extends BaseClient { + final List requests = []; + int requestCount = 0; + int _responseStatusCode = 200; + Map _responseHeaders = {}; + List _responseBody = []; + Duration? delayResponse; + bool shouldThrow = false; + Exception exceptionToThrow = Exception('Test exception'); + bool closeCalled = false; + String? lastRequestBody; + Map? lastRequestFields; + + StreamedResponse get response => StreamedResponse( + Stream.value(_responseBody), + _responseStatusCode, + headers: _responseHeaders, + ); + + set response(StreamedResponse resp) { + _responseStatusCode = resp.statusCode; + _responseHeaders = resp.headers; + // Capture the body bytes + resp.stream.toBytes().then((bytes) { + _responseBody = bytes; + }); + } + + @override + Future send(BaseRequest request) async { + requests.add(request); + requestCount++; + + // Capture the request body if available + if (request is Request) { + lastRequestBody = request.body; + + // For form fields - only access if content type is appropriate + if (request.headers['content-type'] + ?.contains('application/x-www-form-urlencoded') ?? + false) { + try { + lastRequestFields = request.bodyFields; + } catch (e) { + // Ignore errors accessing bodyFields + } + } + } + + if (delayResponse != null) { + await Future.delayed(delayResponse!); + } + + if (shouldThrow) { + throw exceptionToThrow; + } + + return response; + } + + @override + void close() { + closeCalled = true; + super.close(); + } +} + +class _MockInterceptor implements InterceptorContract { + bool shouldInterceptRequestResult = true; + bool shouldInterceptResponseResult = true; + bool interceptRequestCalled = false; + bool interceptResponseCalled = false; + + Function(BaseRequest)? requestModification; + Function(BaseResponse)? responseModification; + + @override + BaseRequest interceptRequest({required BaseRequest request}) { + interceptRequestCalled = true; + + return requestModification?.call(request) ?? request; + } + + @override + BaseResponse interceptResponse({required BaseResponse response}) { + interceptResponseCalled = true; + + return responseModification?.call(response) ?? response; + } + + @override + bool shouldInterceptRequest({required BaseRequest request}) => + shouldInterceptRequestResult; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => + shouldInterceptResponseResult; + + @override + bool shouldInterceptError({BaseRequest? request, BaseResponse? response}) => + true; + + @override + void interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) { + // Do nothing + } +} + +class _OrderTrackingInterceptor implements InterceptorContract { + static List callOrder = []; + static List responseCallOrder = []; + + final int order; + + _OrderTrackingInterceptor(this.order); + + @override + BaseRequest interceptRequest({required BaseRequest request}) { + callOrder.add(order); + return request; + } + + @override + BaseResponse interceptResponse({required BaseResponse response}) { + responseCallOrder.add(order); + return response; + } + + @override + bool shouldInterceptRequest({required BaseRequest request}) => true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => true; + + @override + bool shouldInterceptError({BaseRequest? request, BaseResponse? response}) => + true; + + @override + void interceptError({ + BaseRequest? request, + BaseResponse? response, + Exception? error, + StackTrace? stackTrace, + }) async { + // Do nothing + } +} + +class _MockRetryPolicy extends RetryPolicy { + bool shouldRetryOnResponse = false; + bool shouldRetryOnException = false; + int _maxRetryAttempts = 1; + Duration delay = Duration.zero; + + @override + int get maxRetryAttempts => _maxRetryAttempts; + + // Add setter for maxRetryAttempts + set maxRetryAttempts(int value) { + _maxRetryAttempts = value; + } + + @override + bool shouldAttemptRetryOnResponse(BaseResponse response) => + shouldRetryOnResponse; + + @override + bool shouldAttemptRetryOnException( + Exception exception, + BaseRequest request, + ) => + shouldRetryOnException; + + @override + Duration delayRetryAttemptOnResponse({required int retryAttempt}) => delay; + + @override + Duration delayRetryAttemptOnException({required int retryAttempt}) => delay; +} diff --git a/test/http_interceptor_test.dart b/test/http_interceptor_test.dart deleted file mode 100644 index ab73b3a..0000000 --- a/test/http_interceptor_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {}