diff --git a/CHANGELOG.md b/CHANGELOG.md index dc6182f..3b53bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.0 + +* Added: RetryPolicy. It allows to attempt retries on a request when an exception occurs or when a condition from the response is met. +* Fixed: URI type urls not concatenating parameters. + ## 0.2.0 * Added: Unit testing for a few of the files. diff --git a/README.md b/README.md index 9e55386..edcdacc 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ [![codecov](https://codecov.io/gh/CodingAleCR/http_interceptor/branch/master/graph/badge.svg)](https://codecov.io/gh/CodingAleCR/http_interceptor) [![Star on GitHub](https://img.shields.io/github/stars/codingalecr/http_interceptor.svg?style=flat&logo=github&colorB=deeppink&label=stars)](https://github.com/codingalecr/http_interceptor) -A middleware library that lets you modify requests and responses if desired. Based of on [http_middleware](https://github.com/TEDConsulting/http_middleware) +This is a plugin that lets you intercept the different requests and responses from Dart's http package. You can use to add headers, modify query params, or print a log of the response. -## Getting Started +## Quick Reference -This is a plugin that lets you intercept the different requests and responses from Dart's http package. You can use to add headers, modify query params, or print a log of the response. +- [Installation](#installation) +- [Usage](#usage) + - [Building your own interceptor](#building-your-own-interceptor) + - [Using your interceptor](#using-your-interceptor) + - [Retrying requests](#retrying-requests) +- [Having trouble? Fill an issue](#troubleshooting) -### Installing +## Installation Include the package with the latest version available in your `pubspec.yaml`. @@ -20,15 +25,13 @@ Include the package with the latest version available in your `pubspec.yaml`. http_interceptor: any ``` -### Importing +## Usage ```dart import 'package:http_interceptor/http_interceptor.dart'; ``` -### Using `http_interceptor` - -#### Building your own interceptor +### Building your own interceptor In order to implement `http_interceptor` you need to implement the `InterceptorContract` and create your own interceptor. This abstract class has two methods: `interceptRequest`, which triggers before the http request is called; and `interceptResponse`, which triggers after the request is called, it has a response attached to it which the corresponding to said request. You could use this to do logging, adding headers, error handling, or many other cool stuff. It is important to note that after you proccess the request/response objects you need to return them so that `http` can continue the execute. @@ -72,11 +75,11 @@ class WeatherApiInterceptor implements InterceptorContract { } ``` -#### Using your interceptor +### Using your interceptor Now that you actually have your interceptor implemented, now you need to use it. There are two general ways in which you can use them: by using the `HttpWithInterceptor` to do separate connections for different requests or using a `HttpClientWithInterceptor` for keeping a connection alive while making the different `http` calls. The ideal place to use them is in the service/provider class or the repository class (if you are not using services or providers); if you don't know about the repository pattern you can just google it and you'll know what I'm talking about. ;) -##### Using interceptors with Client +#### Using interceptors with Client Normally, this approach is taken because of its ability to be tested and mocked. @@ -107,7 +110,7 @@ class WeatherRepository { } ``` -##### Using interceptors without Client +#### Using interceptors without Client This is mostly the straight forward approach for a one-and-only call that you might need intercepted. @@ -137,6 +140,27 @@ class WeatherRepository { } ``` -### Issue Reporting +### Retrying requests + + **(NEW 🎉)** Sometimes you need to retry a request due to different circumstances, an expired token is a really good example. Here's how you could potentially implement an expired token retry policy with `http_interceptor`. + +```dart +class ExpiredTokenRetryPolicy extends RetryPolicy { + @override + bool shouldAttemptRetryOnResponse(Response response) { + if (response.statusCode == 401) { + // Perform your token refresh here. + + return true; + } + + return false; + } +} +``` + +You can also set the maximum amount of retry attempts with `maxRetryAttempts` property or override the `shouldAttemptRetryOnException` if you want to retry the request after it failed with an exception. + +## Troubleshooting Open an issue and tell me, I will be happy to help you out as soon as I can. diff --git a/example/lib/main.dart b/example/lib/main.dart index 318094b..b5c512b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -284,6 +284,7 @@ class WeatherApiInterceptor implements InterceptorContract { } catch (e) { print(e); } + print(data.params); return data; } diff --git a/example/pubspec.lock b/example/pubspec.lock index de9a6e6..1ab8307 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -87,7 +87,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0" + version: "0.3.0" http_parser: dependency: transitive description: diff --git a/lib/http_client_with_interceptor.dart b/lib/http_client_with_interceptor.dart index cd9c959..78d06c5 100644 --- a/lib/http_client_with_interceptor.dart +++ b/lib/http_client_with_interceptor.dart @@ -37,14 +37,21 @@ import 'http_methods.dart'; class HttpClientWithInterceptor extends http.BaseClient { List interceptors; Duration requestTimeout; + RetryPolicy retryPolicy; final Client _client = Client(); + int _retryCount = 0; - HttpClientWithInterceptor._internal({this.interceptors, this.requestTimeout}); + HttpClientWithInterceptor._internal({ + this.interceptors, + this.requestTimeout, + this.retryPolicy, + }); factory HttpClientWithInterceptor.build({ @required List interceptors, Duration requestTimeout, + RetryPolicy retryPolicy, }) { assert(interceptors != null); @@ -53,6 +60,7 @@ class HttpClientWithInterceptor extends http.BaseClient { return HttpClientWithInterceptor._internal( interceptors: interceptors, requestTimeout: requestTimeout, + retryPolicy: retryPolicy, ); } @@ -132,7 +140,14 @@ class HttpClientWithInterceptor extends http.BaseClient { dynamic body, Encoding encoding, }) async { - if (url is String) url = Uri.parse(addParametersToUrl(url, params)); + if (url is String) { + url = Uri.parse(addParametersToStringUrl(url, params)); + } else if (url is Uri) { + url = addParametersToUrl(url, params); + } else { + throw HttpInterceptorException( + "Malformed URL parameter. Check that the url used is either a String or a Uri instance."); + } Request request = new Request(methodToString(method), url); if (headers != null) request.headers.addAll(headers); @@ -149,14 +164,7 @@ class HttpClientWithInterceptor extends http.BaseClient { } } - // Intercept request - await _interceptRequest(request); - - var stream = requestTimeout == null - ? await send(request) - : await send(request).timeout(requestTimeout); - - var response = await Response.fromStream(stream); + var response = await _attemptRequest(request); // Intercept response response = await _interceptResponse(response); @@ -174,6 +182,37 @@ class HttpClientWithInterceptor extends http.BaseClient { throw new ClientException("$message.", url); } + Future _attemptRequest(Request request) async { + var response; + try { + // Intercept request + request = await _interceptRequest(request); + + var stream = requestTimeout == null + ? await send(request) + : await send(request).timeout(requestTimeout); + + response = await Response.fromStream(stream); + if (retryPolicy != null + && retryPolicy.maxRetryAttempts > _retryCount + && retryPolicy.shouldAttemptRetryOnResponse(response)) { + _retryCount += 1; + return _attemptRequest(request); + } + } catch (error) { + if (retryPolicy != null + && retryPolicy.maxRetryAttempts > _retryCount + && retryPolicy.shouldAttemptRetryOnException(error)) { + _retryCount += 1; + return _attemptRequest(request); + } else { + throw HttpInterceptorException(error.toString()); + } + } + + return response; + } + /// This internal function intercepts the request. Future _interceptRequest(Request request) async { for (InterceptorContract interceptor in interceptors) { diff --git a/lib/http_with_interceptor.dart b/lib/http_with_interceptor.dart index 19e9b55..06ddb4a 100644 --- a/lib/http_with_interceptor.dart +++ b/lib/http_with_interceptor.dart @@ -28,15 +28,18 @@ import 'package:http_interceptor/interceptor_contract.dart'; class HttpWithInterceptor { List interceptors; Duration requestTimeout; + RetryPolicy retryPolicy; HttpWithInterceptor._internal({ this.interceptors, this.requestTimeout, + this.retryPolicy, }); factory HttpWithInterceptor.build({ @required List interceptors, Duration requestTimeout, + RetryPolicy retryPolicy, }) { assert(interceptors != null); @@ -45,6 +48,7 @@ class HttpWithInterceptor { return new HttpWithInterceptor._internal( interceptors: interceptors, requestTimeout: requestTimeout, + retryPolicy: retryPolicy, ); } @@ -91,6 +95,7 @@ class HttpWithInterceptor { var client = new HttpClientWithInterceptor.build( interceptors: interceptors, requestTimeout: requestTimeout, + retryPolicy: retryPolicy, ); try { return await fn(client); diff --git a/lib/models/http_interceptor_exception.dart b/lib/models/http_interceptor_exception.dart new file mode 100644 index 0000000..91b7ff1 --- /dev/null +++ b/lib/models/http_interceptor_exception.dart @@ -0,0 +1,12 @@ + + +class HttpInterceptorException implements Exception { + final message; + + HttpInterceptorException([this.message]); + + String toString() { + if (message == null) return "Exception"; + return "Exception: $message"; + } +} \ No newline at end of file diff --git a/lib/models/models.dart b/lib/models/models.dart index f03bc76..2ef9324 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,2 +1,4 @@ export 'request_data.dart'; export 'response_data.dart'; +export 'http_interceptor_exception.dart'; +export 'retry_policy.dart'; diff --git a/lib/models/request_data.dart b/lib/models/request_data.dart index 04de9e2..f6cb4ce 100644 --- a/lib/models/request_data.dart +++ b/lib/models/request_data.dart @@ -23,7 +23,7 @@ class RequestData { }) : assert(method != null), assert(baseUrl != null); - String get url => addParametersToUrl(baseUrl, params); + String get url => addParametersToStringUrl(baseUrl, params); factory RequestData.fromHttpRequest(Request request) { var params = Map(); @@ -42,7 +42,7 @@ class RequestData { } Request toHttpRequest() { - var reqUrl = Uri.parse(addParametersToUrl(baseUrl, params)); + var reqUrl = Uri.parse(addParametersToStringUrl(baseUrl, params)); Request request = new Request(methodToString(method), reqUrl); diff --git a/lib/models/retry_policy.dart b/lib/models/retry_policy.dart new file mode 100644 index 0000000..874a208 --- /dev/null +++ b/lib/models/retry_policy.dart @@ -0,0 +1,7 @@ +import 'package:http/http.dart'; + +abstract class RetryPolicy { + bool shouldAttemptRetryOnException(Exception reason) => false; + bool shouldAttemptRetryOnResponse(Response response) => false; + final int maxRetryAttempts = 1; +} \ No newline at end of file diff --git a/lib/utils.dart b/lib/utils.dart index 25a3cbf..70cdb3d 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -1,18 +1,48 @@ /// When having an URL as String and no parameters sent then it adds /// them to the string. -String addParametersToUrl(String url, Map parameters) { +String addParametersToStringUrl(String url, Map parameters) { + return buildUrlString(url, parameters); +} + +Uri addParametersToUrl(Uri url, Map parameters) { if (parameters == null) return url; - String paramUrl = url; - if (parameters != null && parameters.length > 0) { - if (paramUrl.contains("?")) - paramUrl += "&"; - else - paramUrl += "?"; + String paramUrl = url.origin + url.path; + + Map newParameters = {}; + + url.queryParameters.forEach((key, value) { + newParameters[key] = value; + }); + + parameters.forEach((key, value) { + newParameters[key] = value; + }); + + return Uri.parse(buildUrlString(paramUrl, newParameters)); +} + +String buildUrlString(String url, Map parameters) { + // Avoids unnecessary processing. + if (parameters == null) return url; + + // Check if there are parameters to add. + if (parameters.length > 0) { + // Checks if the string url already has parameters. + if (url.contains("?")) { + url += "&"; + } else { + url += "?"; + } + + // Concat every parameter to the string url. parameters.forEach((key, value) { - paramUrl += "$key=$value&"; + url += "$key=$value&"; }); - paramUrl = paramUrl.substring(0, paramUrl.length - 1); + + // Remove last '&' character. + url = url.substring(0, url.length - 1); } - return paramUrl; + + return url; } diff --git a/pubspec.yaml b/pubspec.yaml index 6524b5c..a768f66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,9 @@ name: http_interceptor description: A lightweight, simple plugin that allows you to intercept request and response objects and modify them if desired. -version: 0.2.0 +version: 0.3.0 homepage: https://github.com/CodingAleCR/http_interceptor +issue_tracker: https://github.com/CodingAleCR/http_interceptor/issues +repository: https://github.com/CodingAleCR/http_interceptor environment: sdk: ">=2.1.0 <3.0.0" diff --git a/test/utils_test.dart b/test/utils_test.dart index 4d7032c..605e476 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -2,14 +2,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_interceptor/utils.dart'; main() { - group("addParametersToUrl", () { + group("addParametersToStringUrl", () { test("Adds parameters to a URL string without parameters", () { // Arrange String url = "https://www.google.com/helloworld"; Map parameters = {"foo": "bar", "num": "0"}; // Act - String parameterUrl = addParametersToUrl(url, parameters); + String parameterUrl = addParametersToStringUrl(url, parameters); // Assert expect(parameterUrl, @@ -21,7 +21,7 @@ main() { Map parameters = {"extra": "1", "extra2": "anotherone"}; // Act - String parameterUrl = addParametersToUrl(url, parameters); + String parameterUrl = addParametersToStringUrl(url, parameters); // Assert expect( @@ -30,4 +30,35 @@ main() { "https://www.google.com/helloworld?foo=bar&num=0&extra=1&extra2=anotherone")); }); }); + group("addParametersToUrl", () { + test("Add parameters to Uri Url without parameters", () { + // Arrange + String stringUrl = "https://www.google.com/helloworld"; + Map parameters = {"foo": "bar", "num": "0"}; + Uri url = Uri.parse(stringUrl); + + // Act + Uri parameterUri = addParametersToUrl(url, parameters); + + // Assert + Uri expectedUrl = Uri.https("www.google.com", "/helloworld", parameters); + expect(parameterUri, equals(expectedUrl)); + }); + test("Add parameters to Uri Url with parameters", () { + // Arrange + String authority = "www.google.com"; + String unencodedPath = "/helloworld"; + Map someParameters = {"foo": "bar"}; + Map otherParameters = {"num": "0"}; + Uri url = Uri.https(authority, unencodedPath, someParameters); + + // Act + Uri parameterUri = addParametersToUrl(url, otherParameters); + + // Assert + Map allParameters = {"foo": "bar", "num": "0"}; + Uri expectedUrl = Uri.https("www.google.com", "/helloworld", allParameters); + expect(parameterUri, equals(expectedUrl)); + }); + }); }