diff --git a/.gitignore b/.gitignore index 7fd7b51..273e8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,13 +6,12 @@ build/ - .atom/ .idea .vscode packages/**/ios/ packages/**/android/ -doc/ +doc/api/ pubspec.lock .flutter-plugins *.iml @@ -21,4 +20,7 @@ coverage/ *.log flutter_export_environment.sh !packages/**/example/ios/ -!packages/**/example/android/ \ No newline at end of file +!packages/**/example/android/ + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a9462a4..f9efd3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 3.0.0-beta.1 + +- **Breaking**: Rebuild from scratch. Not backwards compatible with 2.x. +- **Added**: `HttpInterceptor` interface (replaces `InterceptorContract`). Same four methods with `FutureOr` support. +- **Added**: `InterceptedClient.build(...)` and `InterceptedHttp.build(...)` with `interceptors`, `client`, `retryPolicy`, `requestTimeout`, `onRequestTimeout`. +- **Added**: Optional `params` and `paramsAll` on `get`, `post`, `put`, `patch`, `delete`, `head`; merged into URL query. +- **Added**: `String.toUri()` extension. `Uri.addQueryParams(params: ..., paramsAll: ...)` extension. +- **Added**: `Response` JSON decoding extension (`response.jsonMap`, `response.jsonList`, `response.jsonBody`, etc.). +- **Added**: Conditional export `http_interceptor_io.dart` for `IOClient` (VM/mobile/desktop; do not use on web). +- **Removed**: `RequestData`/`ResponseData`. Use `BaseRequest`/`BaseResponse` only; no `copyWith` in core. +- **Removed**: Dependencies `qs_dart` and `validators` (not used in v3). + ## 2.0.0 * feat: Simplify configuration of delay between retries by @jonasschaude in diff --git a/README.md b/README.md index 3a891a1..96f6c03 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This is a plugin that lets you intercept the different requests and responses fr ## Quick Reference -**Already using `http_interceptor`? Check out the [1.0.0 migration guide](./guides/migration_guide_1.0.0.md) for quick reference on the changes made and how to migrate your code.** +**Upgrading from 2.x? See the [3.0.0 migration guide](./guides/migration_guide_3.0.0.md).** - [http\_interceptor](#http_interceptor) - [Quick Reference](#quick-reference) @@ -29,6 +29,8 @@ This is a plugin that lets you intercept the different requests and responses fr - [Using your interceptor](#using-your-interceptor) - [Using interceptors with Client](#using-interceptors-with-client) - [Using interceptors without Client](#using-interceptors-without-client) + - [Working with JSON responses](#working-with-json-responses) + - [Decoding responses into models](#decoding-responses-into-models) - [Retrying requests](#retrying-requests) - [Using self signed certificates](#using-self-signed-certificates) - [InterceptedClient](#interceptedclient) @@ -51,7 +53,8 @@ http_interceptor: - 🚦 Intercept & change unstreamed requests and responses. - ✨ Retrying requests when an error occurs or when the response does not match the desired (useful for handling custom error responses). - 👓 `GET` requests with separated parameters. -- ⚡️ Standard `bodyBytes` on `ResponseData` to encode or decode in the desired format. +- ⚡️ Standard `Response.bodyBytes` for encoding or decoding as needed. +- 📦 Convenience helpers to decode JSON responses and map them into your own models. - 🙌🏼 Array parameters on requests. - 🖋 Supports self-signed certificates (except on Flutter Web). - 🍦 Compatible with vanilla Dart projects or Flutter projects. @@ -67,114 +70,57 @@ import 'package:http_interceptor/http_interceptor.dart'; ### 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 four methods: +Implement `HttpInterceptor` to add logging, headers, error handling, and more. The interface has four methods: - - `interceptRequest`, which triggers before the http request is called - - `interceptResponse`, which triggers after the request is called, it has a response attached to it which the corresponding to said request; - -- `shouldInterceptRequest` and `shouldInterceptResponse`, which are used to determine if the request or response should be intercepted or not. These two methods are optional as they return `true` by default, but they can be useful if you want to conditionally intercept requests or responses based on certain criteria. +- **interceptRequest** – runs before the request is sent. Return the (possibly modified) request. +- **interceptResponse** – runs after the response is received. Return the (possibly modified) response. +- **shouldInterceptRequest** / **shouldInterceptResponse** – return `false` to skip interception for that request/response (default `true`). -You could use this package 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. +All methods support `FutureOr` so you can use sync or async. Modify the request/response in place and return it, or return a new instance. -All four methods use `FutureOr` syntax, which makes it easier to support both synchronous and asynchronous behaviors. - -- Logging with interceptor: +- Logging interceptor: ```dart -class LoggerInterceptor extends InterceptorContract { +class LoggerInterceptor implements HttpInterceptor { @override - BaseRequest interceptRequest({ - required BaseRequest request, - }) { + BaseRequest interceptRequest({required BaseRequest request}) { print('----- Request -----'); print(request.toString()); - print(request.headers.toString()); return request; } @override - BaseResponse interceptResponse({ - required BaseResponse response, - }) { - log('----- Response -----'); - log('Code: ${response.statusCode}'); + BaseResponse interceptResponse({required BaseResponse response}) { + print('----- Response -----'); + print('Code: ${response.statusCode}'); if (response is Response) { - log((response).body); + print(response.body); } return response; } } ``` -- Changing headers with interceptor: - -```dart -class WeatherApiInterceptor implements InterceptorContract { - @override - FutureOr interceptRequest({required BaseRequest request}) async { - try { - request.url.queryParameters['appid'] = OPEN_WEATHER_API_KEY; - request.url.queryParameters['units'] = 'metric'; - request.headers[HttpHeaders.contentTypeHeader] = "application/json"; - } catch (e) { - print(e); - } - return request; - } - - @override - BaseResponse interceptResponse({ - required BaseResponse response, - }) => - response; - - @override - FutureOr shouldInterceptRequest({required BaseRequest request}) async { - // You can conditionally intercept requests here - return true; // Intercept all requests - } - - @override - FutureOr shouldInterceptResponse({required BaseResponse response}) async { - // You can conditionally intercept responses here - return true; // Intercept all responses - } -} -``` - -- You can also react to and modify specific types of requests and responses, such as `StreamedRequest`,`StreamedResponse`, or `MultipartRequest` : +- Adding headers / query params (in-place mutation): ```dart -class MultipartRequestInterceptor implements InterceptorContract { - @override - FutureOr interceptRequest({required BaseRequest request}) async { - if(request is MultipartRequest){ - request.fields['app_version'] = await PackageInfo.fromPlatform().version; - } - return request; - } - - @override - FutureOr interceptResponse({required BaseResponse response}) async { - if(response is StreamedResponse){ - response.stream.asBroadcastStream().listen((data){ - print(data); - }); - } - return response; - } - +class WeatherApiInterceptor implements HttpInterceptor { @override - FutureOr shouldInterceptRequest({required BaseRequest request}) async { - // You can conditionally intercept requests here - return true; // Intercept all requests + BaseRequest interceptRequest({required BaseRequest request}) { + final url = request.url.replace( + queryParameters: { + ...request.url.queryParameters, + 'appid': apiKey, + 'units': 'metric', + }, + ); + return Request(request.method, url) + ..headers.addAll(request.headers) + ..headers[HttpHeaders.contentTypeHeader] = 'application/json'; } @override - FutureOr shouldInterceptResponse({required BaseResponse response}) async { - // You can conditionally intercept responses here - return true; // Intercept all responses - } + BaseResponse interceptResponse({required BaseResponse response}) => response; } ``` @@ -190,24 +136,20 @@ Here is an example with a repository using the `InterceptedClient` class. ```dart class WeatherRepository { - Client client = InterceptedClient.build(interceptors: [ - WeatherApiInterceptor(), - ]); + final client = InterceptedClient.build( + interceptors: [WeatherApiInterceptor()], + ); Future> fetchCityWeather(int id) async { - var parsedWeather; - try { - final response = - await client.get("$baseUrl/weather".toUri(), params: {'id': "$id"}); - if (response.statusCode == 200) { - parsedWeather = json.decode(response.body); - } else { - throw Exception("Error while fetching. \n ${response.body}"); - } - } catch (e) { - print(e); + final response = await client.get( + '$baseUrl/weather'.toUri(), + params: {'id': '$id'}, + ); + if (response.statusCode == 200) { + // Built-in Response JSON helpers: + return response.jsonMap; } - return parsedWeather; + throw Exception('Error while fetching.\\n${response.body}'); } } @@ -223,32 +165,84 @@ Here is an example with a repository using the `InterceptedHttp` class. class WeatherRepository { Future> fetchCityWeather(int id) async { - var parsedWeather; - try { - final http = InterceptedHttp.build(interceptors: [ - WeatherApiInterceptor(), - ]); - final response = - await http.get("$baseUrl/weather".toUri(), params: {'id': "$id"}); - if (response.statusCode == 200) { - parsedWeather = json.decode(response.body); - } else { - return Future.error( - "Error while fetching.", - StackTrace.fromString("${response.body}"), - ); - } - } on SocketException { - return Future.error('No Internet connection 😑'); - } on FormatException { - return Future.error('Bad response format 👎'); - } on Exception { - return Future.error('Unexpected error 😢'); + final http = InterceptedHttp.build(interceptors: [WeatherApiInterceptor()]); + final response = await http.get( + '$baseUrl/weather'.toUri(), + params: {'id': '$id'}, + ); + if (response.statusCode == 200) { + // Built-in Response JSON helpers: + return response.jsonMap; } + return Future.error( + 'Error while fetching.', + StackTrace.fromString(response.body), + ); + } + +} +``` + +### Working with JSON responses + +The `ResponseBodyDecoding` extension adds a few lightweight helpers on `Response` for common JSON use cases: + +```dart +final response = await client.get( + '$baseUrl/weather'.toUri(), + params: {'id': '$id'}, +); + +// Dynamically-typed JSON value (Map/List/primitive or null on empty body). +final Object? json = response.jsonBody; + +// JSON object as a map (throws if body is empty or not a JSON object). +final Map data = response.jsonMap; + +// JSON array as a list (throws if body is empty or not a JSON array). +final List items = response.jsonList; +``` + +### Decoding responses into models + +The `ResponseBodyDecoding` extension provides helpers to turn JSON responses into strongly-typed models with minimal boilerplate. + +```dart +class Weather { + final String description; + final double temperature; + + const Weather({ + required this.description, + required this.temperature, + }); + + factory Weather.fromJson(Map json) { + return Weather( + description: (json['weather'] as List).first['description'] as String, + temperature: (json['main']['temp'] as num).toDouble(), + ); + } +} + +Future fetchCityWeather(int id) async { + final client = InterceptedClient.build( + interceptors: [WeatherApiInterceptor()], + ); + + final response = await client.get( + '$baseUrl/weather'.toUri(), + params: {'id': '$id'}, + ); - return parsedWeather; + if (response.statusCode == 200) { + // Use the built-in JSON mapper: + return response.decodeJson( + (json) => Weather.fromJson(json as Map), + ); } + throw Exception('Error while fetching.\n${response.body}'); } ``` diff --git a/doc/decisions/000-template.md b/doc/decisions/000-template.md new file mode 100644 index 0000000..bac1394 --- /dev/null +++ b/doc/decisions/000-template.md @@ -0,0 +1,18 @@ +# Title + +Date: YYYY-MM-DD + +Status: proposed | rejected | accepted | deprecated | … | superseded by +[0005](0005-example.md) + +## Context + + + +## Decision + + + +## Consequences + + \ No newline at end of file diff --git a/doc/decisions/001-v3-rebuild.md b/doc/decisions/001-v3-rebuild.md new file mode 100644 index 0000000..17a0111 --- /dev/null +++ b/doc/decisions/001-v3-rebuild.md @@ -0,0 +1,34 @@ +# V3 from-scratch rebuild + +Date: 2025-03-15 + +Status: accepted + +## Context + +The library was initally a rewrite of [http_middleware](https://pub.dev/packages/http_middleware). It has reached 2.0.0 with a full feature set (interceptors, retry, timeout, copyWith on requests/responses, query params, etc.). However, the existing implementation had accumulated complexity and made a clean evolution difficult. A from-scratch rebuild was chosen to: + +- Apply the principles, design patterns, and best practices. +- Avoid inheriting code smells and anti-patterns from the previous implementation. +- Prioritize a small API surface and clear behavior over backwards compatibility with 2.x. + +The 2.0.0 API was used as a feature and API reference only; no backwards compatibility with 2.x is required. + +## Decision + +Rebuild the library as version 3 with the following decisions: + +- **Decorator pattern**: The intercepted client wraps a `Client`, implements `Client`, and delegates to the inner client while adding interception. New behavior is added by composition (interceptors, retry, timeout), not by one large class. +- **Strategy pattern**: Interceptors define how to transform request/response; `RetryPolicy` defines when to retry and with what delay. Both are injectable strategies. +- **No copyWith in core**: Interceptors receive and return `BaseRequest`/`BaseResponse` from `package:http`. The supported pattern is in-place mutation or returning the same instance. Cloning (copyWith) was not added to the core API to keep the library simple; it can be a code smell (many clone surfaces). Importantly, `StreamedRequest` and `StreamedResponse` carry streams, which can be consumed only once—you cannot meaningfully “copy” a stream. A copyWith for those types would therefore be either limited (e.g. only URL and headers) or error-prone (e.g. reusing or re-wrapping the same stream). That constraint makes a uniform copyWith story across all request/response types fragile and reinforces the decision to avoid it in core. +- **Small composable units**: Interceptor chain runner, retry executor, and timeout wrapper are separate, testable units. The client’s `send()` orchestrates them in a clear order: request interceptors → (optional) timeout → send → response interceptors; retry re-runs from request interceptors when the policy allows. +- **InterceptedClient and InterceptedHttp**: `InterceptedClient` extends `BaseClient` and overrides `send()`; `InterceptedHttp` is a facade that holds an `InterceptedClient` and exposes `get`, `post`, etc., plus optional `close()`. +- **Single interceptor and retry abstractions**: One `HttpInterceptor` interface and one `RetryPolicy` interface; avoid overlapping or redundant abstractions (YAGNI). + +## Consequences + +- **Removed**: Backwards compatibility with 2.x; `RequestData`/`ResponseData`; copyWith in the core API; any structure or naming copied from the deleted implementation. +- **Added**: Clean implementation under `lib/src/` with interceptors, chain, retry, timeout, and client/facade; alignment with Decorator/Strategy and SOLID; guard clauses and linear control flow where possible. +- **Preserved (conceptually)**: Interceptor contract (intercept request/response, shouldIntercept flags), retry policy (on exception and on response, configurable delay), request timeout and callback, query params (`params`/`paramsAll`) on convenience methods, Uri and String extensions where they fit the minimal API. +- **Documentation**: Migration guide (e.g. [migration_guide_3.0.0.md](../../guides/migration_guide_3.0.0.md)) explains the break from 2.x and how to migrate (e.g. work with `BaseRequest`/`BaseResponse`, no copyWith). + diff --git a/guides/migration_guide_0.4.0.md b/doc/guides/migration_guide_0.4.0.md similarity index 100% rename from guides/migration_guide_0.4.0.md rename to doc/guides/migration_guide_0.4.0.md diff --git a/guides/migration_guide_1.0.0.md b/doc/guides/migration_guide_1.0.0.md similarity index 100% rename from guides/migration_guide_1.0.0.md rename to doc/guides/migration_guide_1.0.0.md diff --git a/guides/migration_guide_2.0.0.md b/doc/guides/migration_guide_2.0.0.md similarity index 100% rename from guides/migration_guide_2.0.0.md rename to doc/guides/migration_guide_2.0.0.md diff --git a/doc/guides/migration_guide_3.0.0.md b/doc/guides/migration_guide_3.0.0.md new file mode 100644 index 0000000..fc0b74a --- /dev/null +++ b/doc/guides/migration_guide_3.0.0.md @@ -0,0 +1,20 @@ +# Migration guide to 3.0.0 + +Version 3 is a from-scratch rebuild. The API is simplified; there are breaking changes. + +## Summary + +- **Interceptor interface** is now `HttpInterceptor` (replaces `InterceptorContract`). Implement `HttpInterceptor`; methods are the same (`interceptRequest`, `interceptResponse`, `shouldInterceptRequest`, `shouldInterceptResponse`) with `FutureOr` support. +- **No RequestData/ResponseData** – use `BaseRequest` and `BaseResponse` from `package:http` only. Interceptors receive and return these types; use in-place mutation or return the same instance. There is no `copyWith` in the core API (cloning was removed to keep the library simple). +- **InterceptedClient** and **InterceptedHttp** are built with `InterceptedClient.build(...)` and `InterceptedHttp.build(...)` with the same options: `interceptors` (required), `client`, `retryPolicy`, `requestTimeout`, `onRequestTimeout`. +- **RetryPolicy** – implement the interface; same methods: `maxRetryAttempts`, `shouldAttemptRetryOnException(Exception, BaseRequest)`, `shouldAttemptRetryOnResponse(BaseResponse)`, `delayRetryAttemptOnException`, `delayRetryAttemptOnResponse`. All retry methods support `FutureOr`. +- **Params** – `get`, `post`, `put`, `patch`, `delete`, and `head` accept optional `params` and `paramsAll`; they are merged into the request URL. Use the `String.toUri()` extension for `'$baseUrl/path'.toUri()`. +- **Self-signed certificates** – pass your own `Client` (e.g. `IOClient` from `package:http/io_client.dart`). On Flutter web, do not import the IO client. For convenience, import `package:http_interceptor/http_interceptor_io.dart` on VM/mobile/desktop to get `IOClient` alongside the rest of the package. + +## Steps + +1. Replace `InterceptorContract` with `HttpInterceptor` and ensure your class implements (not extends) it. +2. Remove any use of `RequestData`/`ResponseData` and `copyWith` on requests/responses; work with `BaseRequest`/`BaseResponse` and mutate in place or return the same instance. +3. Keep using `InterceptedClient.build(interceptors: [...], client: ..., retryPolicy: ...)` and `InterceptedHttp.build(...)` with the same named parameters. +4. Update `RetryPolicy` implementations to implement the interface (all methods, including the delay methods if you need custom delays). +5. Use `'$url'.toUri()` for string URLs (extension from the package). diff --git a/example/devtools_options.yaml b/example/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/example/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index 279576f..e72e0b4 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 8d7af33..9cb3aa5 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -20,10 +20,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 -PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 +PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d COCOAPODS: 1.15.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 76075a6..b43d6f6 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -343,7 +343,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -421,7 +421,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -470,7 +470,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5e31d3d..9c12df5 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,13 +1,16 @@ -import UIKit import Flutter +import UIKit -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 84d6399..5d80a31 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -1,55 +1,76 @@ + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + We need camera access to take pictures and then removing background. + NSPhotoLibraryUsageDescription + We need camera access to choose pictures and then removing background. + UIApplicationSceneManifest - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - example - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - NSPhotoLibraryUsageDescription - We need camera access to choose pictures and then removing background. - NSCameraUsageDescription - We need camera access to take pictures and then removing background. - UIApplicationSupportsIndirectInputEvents + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + diff --git a/example/lib/common.dart b/example/lib/common.dart index 6671af9..4ad5920 100644 --- a/example/lib/common.dart +++ b/example/lib/common.dart @@ -2,11 +2,15 @@ import 'dart:developer'; import 'package:http_interceptor/http_interceptor.dart'; -class LoggerInterceptor extends InterceptorContract { +class LoggerInterceptor implements HttpInterceptor { @override - BaseRequest interceptRequest({ - required BaseRequest request, - }) { + bool shouldInterceptRequest({required BaseRequest request}) => true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => true; + + @override + BaseRequest interceptRequest({required BaseRequest request}) { log('----- Request -----'); log(request.toString()); log(request.headers.toString()); @@ -15,37 +19,13 @@ class LoggerInterceptor extends InterceptorContract { } @override - BaseResponse interceptResponse({ - required BaseResponse response, - }) { + BaseResponse interceptResponse({required BaseResponse response}) { log('----- Response -----'); log('Code: ${response.statusCode}'); log('Response type: ${response.runtimeType}'); if (response is Response) { - log((response).body); + log(response.body); } 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/example/lib/multipart_app.dart b/example/lib/multipart_app.dart index f623378..63eb2da 100644 --- a/example/lib/multipart_app.dart +++ b/example/lib/multipart_app.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:http_interceptor/http_interceptor.dart'; import 'package:http_interceptor_example/common.dart'; @@ -91,9 +92,12 @@ class _MultipartAppState extends State { ), if (pickedImage != null) ...[ const Text('Before'), - Image.file( - File(pickedImage!.path), - ), + if (kIsWeb) + Image.network(pickedImage!.path) + else + Image.file( + File(pickedImage!.path), + ), ], if (noBgImage != null) ...[ const Text('After'), @@ -122,14 +126,22 @@ class RemoveBgRepository { ) async { Uint8List parsedResponse; try { + late MultipartFile file; + if (kIsWeb) { + final Uint8List bytes = await imgFile.readAsBytes(); + file = MultipartFile.fromBytes(fieldName, bytes); + } else { + file = await MultipartFile.fromPath( + fieldName, + imgFile.path, + ); + } + final req = MultipartRequest( - HttpMethod.POST.asString, + 'POST', Uri.parse(baseUrl), )..files.add( - await MultipartFile.fromPath( - fieldName, - imgFile.path, - ), + file, ); final streamResponse = await client.send(req); @@ -157,23 +169,33 @@ class RemoveBgRepository { } } -class RemoveBgApiInterceptor extends InterceptorContract { +class RemoveBgApiInterceptor implements HttpInterceptor { + @override + bool shouldInterceptRequest({required BaseRequest request}) => true; + @override - Future interceptRequest({ - required BaseRequest request, - }) async { - final Map headers = Map.from(request.headers); + bool shouldInterceptResponse({required BaseResponse response}) => true; + + @override + BaseRequest interceptRequest({required BaseRequest request}) { + final headers = Map.from(request.headers); headers[HttpHeaders.contentTypeHeader] = 'application/json'; headers['X-Api-Key'] = kRemoveBgApiKey; - return request.copyWith( - headers: headers, - ); + if (request is MultipartRequest) { + final multipart = request; + final newReq = MultipartRequest(request.method, request.url); + newReq.fields.addAll(multipart.fields); + newReq.files.addAll(multipart.files); + newReq.headers.addAll(headers); + return newReq; + } + + final newReq = Request(request.method, request.url); + newReq.headers.addAll(headers); + return newReq; } @override - BaseResponse interceptResponse({ - required BaseResponse response, - }) => - response; + BaseResponse interceptResponse({required BaseResponse response}) => response; } diff --git a/example/lib/weather_app.dart b/example/lib/weather_app.dart index d59a0cb..15a730a 100644 --- a/example/lib/weather_app.dart +++ b/example/lib/weather_app.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; +import 'dart:developer' show log; import 'dart:io'; import 'dart:math' as math; @@ -13,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; /// If you are going to run this example you need to replace the key. import 'cities.dart'; import 'credentials.dart'; +import 'weather_model.dart'; class WeatherApp extends StatefulWidget { const WeatherApp({super.key}); @@ -161,7 +161,7 @@ class WeatherSearch extends SearchDelegate { } Widget buildWeatherCard(final city) { - return FutureBuilder>( + return FutureBuilder( future: repo.fetchCityWeather(city['id']), builder: (context, snapshot) { if (snapshot.hasError) { @@ -175,10 +175,10 @@ class WeatherSearch extends SearchDelegate { child: CircularProgressIndicator(), ); } - final weather = snapshot.data; - final iconWeather = weather!['weather'][0]['icon']; - final main = weather['main']; - final wind = weather['wind']; + final weather = snapshot.data!; + final iconWeather = weather.condition.icon; + final main = weather.main; + final wind = weather.wind; return Card( margin: const EdgeInsets.all(16.0), child: Container( @@ -189,7 +189,7 @@ class WeatherSearch extends SearchDelegate { children: [ ListTile( leading: Tooltip( - message: weather['weather'][0]['main'], + message: weather.condition.main, child: Image.network( 'https://openweathermap.org/img/w/$iconWeather.png'), ), @@ -197,27 +197,27 @@ class WeatherSearch extends SearchDelegate { subtitle: Text(city['country']), ), ListTile( - title: Text("${main["temp"]} °C"), + title: Text('${main.temp} °C'), subtitle: const Text('Temperature'), ), ListTile( - title: Text("${main["temp_min"]} °C"), + title: Text('${main.tempMin} °C'), subtitle: const Text('Min Temperature'), ), ListTile( - title: Text("${main["temp_max"]} °C"), + title: Text('${main.tempMax} °C'), subtitle: const Text('Max Temperature'), ), ListTile( - title: Text("${main["humidity"]} %"), + title: Text('${main.humidity} %'), subtitle: const Text('Humidity'), ), ListTile( - title: Text("${main["pressure"]} hpa"), + title: Text('${main.pressure} hpa'), subtitle: const Text('Pressure'), ), ListTile( - title: Text("${wind["speed"]} m/s"), + title: Text('${wind.speed} m/s'), subtitle: const Text('Wind Speed'), ), ], @@ -262,32 +262,30 @@ class WeatherRepository { WeatherRepository(this.client); - // Alternatively you can forget about using the Client and just doing the HTTP request with - // the InterceptedHttp.build() call. - // Future> fetchCityWeather(int id) async { - // var parsedWeather; - // try { - // var response = await InterceptedHttp.build( - // interceptors: [WeatherApiInterceptor()], - // ).get('$baseUrl/weather', params: {'id': '$id'}); - // if (response.statusCode == 200) { - // parsedWeather = json.decode(response.body); - // } else { - // throw Exception('Error while fetching. \n ${response.body}'); - // } - // } catch (e) { - // log(e.toString()); + // Alternatively you can skip holding a client instance and do a one-off call + // with `InterceptedHttp.build()`. + // + // Future fetchCityWeather(int id) async { + // final http = InterceptedHttp.build(interceptors: [WeatherApiInterceptor()]); + // final response = await http.get( + // '$baseUrl/weather'.toUri(), + // params: {'id': '$id'}, + // ); + // if (response.statusCode != 200) { + // return Future.error( + // 'Error while fetching.', + // StackTrace.fromString(response.body), + // ); // } - // return parsedWeather; + // return Weather.fromJson(response.jsonMap); // } - Future> fetchCityWeather(int? id) async { - Map parsedWeather; + Future fetchCityWeather(int? id) async { try { final response = await client.get('$baseUrl/weather'.toUri(), params: {'id': '$id'}); if (response.statusCode == 200) { - parsedWeather = jsonDecode(response.body); + return Weather.fromJson(response.jsonMap); } else { return Future.error( 'Error while fetching.', @@ -302,38 +300,37 @@ class WeatherRepository { log(error.toString()); return Future.error('Unexpected error 😢'); } - - return parsedWeather; } } const String kOWApiToken = 'TOKEN'; -class WeatherApiInterceptor extends InterceptorContract { +class WeatherApiInterceptor implements HttpInterceptor { @override - Future interceptRequest({required BaseRequest request}) async { - final cache = await SharedPreferences.getInstance(); + bool shouldInterceptRequest({required BaseRequest request}) => true; - final Map headers = Map.from(request.headers); - headers[HttpHeaders.contentTypeHeader] = 'application/json'; + @override + bool shouldInterceptResponse({required BaseResponse response}) => true; - return request.copyWith( - url: request.url.addParameters({ + @override + Future interceptRequest({required BaseRequest request}) async { + final cache = await SharedPreferences.getInstance(); + final url = request.url.addQueryParams( + params: { 'appid': cache.getString(kOWApiToken) ?? '', 'units': 'metric', - }), - headers: headers, + }, ); + final headers = Map.from(request.headers); + headers[HttpHeaders.contentTypeHeader] = 'application/json'; + return Request(request.method, url)..headers.addAll(headers); } @override - BaseResponse interceptResponse({ - required BaseResponse response, - }) => - response; + BaseResponse interceptResponse({required BaseResponse response}) => response; } -class ExpiredTokenRetryPolicy extends RetryPolicy { +class ExpiredTokenRetryPolicy implements RetryPolicy { @override int get maxRetryAttempts => 2; @@ -343,26 +340,25 @@ class ExpiredTokenRetryPolicy extends RetryPolicy { BaseRequest request, ) async { log(reason.toString()); - return false; } - @override - Duration delayRetryAttemptOnResponse({required int retryAttempt}) { - return const Duration(milliseconds: 250) * math.pow(2.0, retryAttempt); - } - @override Future shouldAttemptRetryOnResponse(BaseResponse response) async { if (response.statusCode == 401) { log('Retrying request...'); final cache = await SharedPreferences.getInstance(); - cache.setString(kOWApiToken, kOpenWeatherApiKey); - return true; } - return false; } + + @override + Duration delayRetryAttemptOnException({required int retryAttempt}) => + Duration(milliseconds: (250 * math.pow(2.0, retryAttempt)).round()); + + @override + Duration delayRetryAttemptOnResponse({required int retryAttempt}) => + Duration(milliseconds: (250 * math.pow(2.0, retryAttempt)).round()); } diff --git a/example/lib/weather_model.dart b/example/lib/weather_model.dart new file mode 100644 index 0000000..4144192 --- /dev/null +++ b/example/lib/weather_model.dart @@ -0,0 +1,81 @@ +class Weather { + Weather({ + required this.condition, + required this.main, + required this.wind, + }); + + factory Weather.fromJson(Map json) { + final weatherList = (json['weather'] as List?) ?? const []; + final conditionJson = weatherList.isNotEmpty + ? weatherList.first as Map + : const {}; + + return Weather( + condition: WeatherCondition.fromJson(conditionJson), + main: WeatherMain.fromJson(json['main'] as Map), + wind: WeatherWind.fromJson(json['wind'] as Map), + ); + } + + final WeatherCondition condition; + final WeatherMain main; + final WeatherWind wind; +} + +class WeatherCondition { + WeatherCondition({ + required this.main, + required this.icon, + }); + + factory WeatherCondition.fromJson(Map json) { + return WeatherCondition( + main: (json['main'] as String?) ?? '', + icon: (json['icon'] as String?) ?? '', + ); + } + + final String main; + final String icon; +} + +class WeatherMain { + WeatherMain({ + required this.temp, + required this.tempMin, + required this.tempMax, + required this.humidity, + required this.pressure, + }); + + factory WeatherMain.fromJson(Map json) { + return WeatherMain( + temp: (json['temp'] as num?) ?? 0, + tempMin: (json['temp_min'] as num?) ?? 0, + tempMax: (json['temp_max'] as num?) ?? 0, + humidity: (json['humidity'] as num?) ?? 0, + pressure: (json['pressure'] as num?) ?? 0, + ); + } + + final num temp; + final num tempMin; + final num tempMax; + final num humidity; + final num pressure; +} + +class WeatherWind { + WeatherWind({ + required this.speed, + }); + + factory WeatherWind.fromJson(Map json) { + return WeatherWind( + speed: (json['speed'] as num?) ?? 0, + ); + } + + final num speed; +} diff --git a/lib/extensions/base_request.dart b/lib/extensions/base_request.dart deleted file mode 100644 index 74682e7..0000000 --- a/lib/extensions/base_request.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http_interceptor/http/http_methods.dart'; - -import './multipart_request.dart'; -import './request.dart'; -import './streamed_request.dart'; - -/// Extends [BaseRequest] to provide copied instances. -extension BaseRequestCopyWith on BaseRequest { - /// Creates a new instance of [BaseRequest] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - /// - /// [body] and [encoding] are only copied if `this` is a [Request] instance. - /// - /// [fields] and [files] are only copied if `this` is a [MultipartRequest] - /// instance. - /// - /// [stream] are only copied if `this` is a [StreamedRequest] instance. - BaseRequest copyWith({ - HttpMethod? method, - Uri? url, - Map? headers, - bool? followRedirects, - int? maxRedirects, - bool? persistentConnection, - // Request only variables. - dynamic body, - Encoding? encoding, - // MultipartRequest only properties. - Map? fields, - List? files, - // StreamedRequest only properties. - Stream>? stream, - }) => 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 deleted file mode 100644 index f6c250c..0000000 --- a/lib/extensions/base_response_io.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; -import 'package:http_interceptor/extensions/io_streamed_response.dart'; - -import './response.dart'; -import './streamed_response.dart'; - -// Extends [BaseRequest] to provide copied instances. -extension BaseResponseCopyWith on BaseResponse { - /// Creates a new instance of [BaseResponse] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - /// - /// [body] are only copied if `this` is a [Response] instance. - /// - /// [stream] and [contentLength] are only copied if `this` is a - /// [StreamedResponse] instance. - /// - /// [inner] are only copied if `this` is a [IOStreamedResponse] instance. - BaseResponse copyWith({ - int? statusCode, - BaseRequest? request, - Map? headers, - bool? isRedirect, - bool? persistentConnection, - String? reasonPhrase, - // `Response` only variables. - String? body, - // `StreamedResponse` only properties. - Stream>? stream, - int? contentLength, - // `IOStreamedResponse` only properties. - HttpClientResponse? inner, - }) => 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 deleted file mode 100644 index 289cb92..0000000 --- a/lib/extensions/base_response_none.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:http/http.dart'; - -import './response.dart'; -import './streamed_response.dart'; - -// Extends [BaseRequest] to provide copied instances. -extension BaseResponseCopyWith on BaseResponse { - /// Creates a new instance of [BaseResponse] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - /// - /// [body] are only copied if `this` is a [Response] instance. - /// - /// [stream] and [contentLength] are only copied if `this` is a - /// [StreamedResponse] instance. - BaseResponse copyWith({ - int? statusCode, - BaseRequest? request, - Map? headers, - bool? isRedirect, - bool? persistentConnection, - String? reasonPhrase, - // `Response` only variables. - String? body, - // `StreamedResponse` only properties. - Stream>? stream, - int? contentLength, - }) => 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 deleted file mode 100644 index 87be15e..0000000 --- a/lib/extensions/io_streamed_response.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; - -extension IOStreamedResponseCopyWith on IOStreamedResponse { - IOStreamedResponse copyWith({ - Stream>? stream, - int? statusCode, - int? contentLength, - BaseRequest? request, - Map? headers, - bool? isRedirect, - bool? persistentConnection, - String? reasonPhrase, - HttpClientResponse? 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 deleted file mode 100644 index 7f90d36..0000000 --- a/lib/extensions/multipart_request.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:http/http.dart'; -import 'package:http_interceptor/http/http_methods.dart'; - -/// Extends [MultipartRequest] to provide copied instances. -extension MultipartRequestCopyWith on MultipartRequest { - /// Creates a new instance of [MultipartRequest] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - MultipartRequest copyWith({ - HttpMethod? method, - Uri? url, - Map? headers, - Map? fields, - List? files, - bool? followRedirects, - int? maxRedirects, - bool? persistentConnection, - }) { - final MultipartRequest clonedRequest = - MultipartRequest(method?.asString ?? this.method, url ?? this.url) - ..headers.addAll(headers ?? this.headers) - ..fields.addAll(fields ?? this.fields); - - for (final MultipartFile file in this.files) { - clonedRequest.files.add( - MultipartFile( - file.field, - file.finalize(), - file.length, - filename: file.filename, - contentType: file.contentType, - ), - ); - } - - this.persistentConnection = - persistentConnection ?? this.persistentConnection; - this.followRedirects = followRedirects ?? this.followRedirects; - this.maxRedirects = maxRedirects ?? this.maxRedirects; - - return clonedRequest; - } -} diff --git a/lib/extensions/request.dart b/lib/extensions/request.dart deleted file mode 100644 index 59c8fdd..0000000 --- a/lib/extensions/request.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http_interceptor/http/http_methods.dart'; - -/// Extends [Request] to provide copied instances. -extension RequestCopyWith on Request { - /// Creates a new instance of [Request] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - Request copyWith({ - HttpMethod? method, - Uri? url, - Map? headers, - String? body, - List? bodyBytes, - Encoding? encoding, - bool? followRedirects, - int? maxRedirects, - bool? persistentConnection, - }) { - final Request copied = Request( - method?.asString ?? this.method, - url ?? this.url, - )..bodyBytes = this.bodyBytes; - - try { - copied.body = this.body; - } catch (e) { - // Do not try to get body as string when it is not parseable - } - - if (body != null) { - copied.body = body; - } - - if (bodyBytes != null) { - copied.bodyBytes = bodyBytes; - } - - return copied - ..headers.addAll(headers ?? this.headers) - ..encoding = encoding ?? this.encoding - ..followRedirects = followRedirects ?? this.followRedirects - ..maxRedirects = maxRedirects ?? this.maxRedirects - ..persistentConnection = - persistentConnection ?? this.persistentConnection; - } -} diff --git a/lib/extensions/response.dart b/lib/extensions/response.dart deleted file mode 100644 index a5fda38..0000000 --- a/lib/extensions/response.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:http/http.dart'; - -/// Extends [Response] to provide copied instances. -extension ResponseCopyWith on Response { - /// Creates a new instance of [Response] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - Response copyWith({ - String? body, - int? statusCode, - BaseRequest? request, - Map? headers, - bool? isRedirect, - bool? persistentConnection, - String? reasonPhrase, - }) => Response( - body ?? this.body, - statusCode ?? this.statusCode, - request: request ?? this.request, - headers: headers ?? this.headers, - isRedirect: isRedirect ?? this.isRedirect, - persistentConnection: persistentConnection ?? this.persistentConnection, - reasonPhrase: reasonPhrase ?? this.reasonPhrase, - ); -} diff --git a/lib/extensions/streamed_request.dart b/lib/extensions/streamed_request.dart deleted file mode 100644 index 43f9a55..0000000 --- a/lib/extensions/streamed_request.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:http/http.dart'; -import 'package:http_interceptor/http/http_methods.dart'; - -/// Extends [StreamedRequest] to provide copied instances. -extension StreamedRequestCopyWith on StreamedRequest { - /// Creates a new instance of [StreamedRequest] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - StreamedRequest copyWith({ - HttpMethod? method, - Uri? url, - Map? headers, - Stream>? stream, - bool? followRedirects, - int? maxRedirects, - bool? persistentConnection, - }) { - // Create a new StreamedRequest with the same method and URL - final StreamedRequest clonedRequest = StreamedRequest( - method?.asString ?? this.method, - url ?? this.url, - )..headers.addAll(headers ?? this.headers); - - // Use a broadcast stream to allow multiple listeners - final Stream> broadcastStream = - stream?.asBroadcastStream() ?? finalize().asBroadcastStream(); - - // Pipe the broadcast stream into the cloned request's sink - broadcastStream.listen( - (List data) => clonedRequest.sink.add(data), - onDone: () => clonedRequest.sink.close(), - ); - - this.persistentConnection = - persistentConnection ?? this.persistentConnection; - this.followRedirects = followRedirects ?? this.followRedirects; - this.maxRedirects = maxRedirects ?? this.maxRedirects; - - return clonedRequest; - } -} diff --git a/lib/extensions/streamed_response.dart b/lib/extensions/streamed_response.dart deleted file mode 100644 index 1167a25..0000000 --- a/lib/extensions/streamed_response.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:http/http.dart'; - -/// Extends [StreamedResponse] to provide copied instances. -extension StreamedResponseCopyWith on StreamedResponse { - /// Creates a new instance of [StreamedResponse] based of on `this`. It copies - /// all the properties and overrides the ones sent via parameters. - StreamedResponse copyWith({ - Stream>? stream, - int? statusCode, - int? contentLength, - BaseRequest? request, - Map? headers, - bool? isRedirect, - bool? persistentConnection, - String? reasonPhrase, - }) => StreamedResponse( - 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, - ); -} diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart deleted file mode 100644 index d3bcd48..0000000 --- a/lib/extensions/string.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Extends [String] to provide URI compatibility. -extension ToURI on String { - /// Converts the current string into a valid URI. Since it uses - /// `Uri.parse` then it can throw `FormatException` if `this` is not - /// a valid string for parsing. - Uri toUri() => Uri.parse(this); -} diff --git a/lib/extensions/uri.dart b/lib/extensions/uri.dart deleted file mode 100644 index 9441e0a..0000000 --- a/lib/extensions/uri.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:http_interceptor/extensions/string.dart'; -import 'package:http_interceptor/utils/utils.dart'; - -/// 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]) => - 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 deleted file mode 100644 index 946d730..0000000 --- a/lib/http/http_methods.dart +++ /dev/null @@ -1,33 +0,0 @@ -// ignore_for_file: constant_identifier_names -/// Enum representation of all available HTTP methods. -enum HttpMethod { - HEAD, - GET, - POST, - PUT, - PATCH, - DELETE, - OPTIONS; - - /// 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; - - @override - String toString() => name; -} diff --git a/lib/http/intercepted_client.dart b/lib/http/intercepted_client.dart deleted file mode 100644 index 9a07250..0000000 --- a/lib/http/intercepted_client.dart +++ /dev/null @@ -1,467 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:http/http.dart'; -import 'package:http_interceptor/extensions/base_request.dart'; -import 'package:http_interceptor/extensions/uri.dart'; - -import '../models/interceptor_contract.dart'; -import '../models/retry_policy.dart'; -import 'http_methods.dart'; - -typedef TimeoutCallback = FutureOr Function(); - -/// Class to be used by the user to set up a new `http.Client` with interceptor -/// support. -/// -/// Call `build()` and pass list of interceptors as parameter. -/// -/// Example: -/// ```dart -/// InterceptedClient client = InterceptedClient.build(interceptors: [ -/// LoggingInterceptor(), -/// ]); -/// ``` -/// -/// Then call the functions you want to, on the created `client` object. -/// ```dart -/// client.get(...); -/// client.post(...); -/// client.put(...); -/// client.delete(...); -/// client.head(...); -/// client.patch(...); -/// client.read(...); -/// client.send(...); -/// client.readBytes(...); -/// client.close(); -/// ``` -/// -/// Don't forget to close the client once you are done, as a client keeps -/// the connection alive with the server by default. -class InterceptedClient extends BaseClient { - /// List of interceptors that will be applied to the requests and responses. - final List interceptors; - - /// Maximum duration of a request. - Duration? requestTimeout; - - /// Request timeout handler - TimeoutCallback? onRequestTimeout; - - /// A policy that defines whether a request or response should trigger a - /// retry. This is useful for implementing JWT token expiration - final RetryPolicy? retryPolicy; - - int _retryCount = 0; - late final Client _inner; - - InterceptedClient._internal({ - required this.interceptors, - this.requestTimeout, - this.onRequestTimeout, - this.retryPolicy, - Client? client, - }) : _inner = client ?? Client(); - - /// Builds a new [InterceptedClient] instance. - /// - /// Interceptors are applied in a linear order. For example a list that looks - /// like this: - /// - /// ```dart - /// InterceptedClient.build( - /// interceptors: [ - /// WeatherApiInterceptor(), - /// LoggerInterceptor(), - /// ], - /// ), - /// ``` - /// - /// Will apply first the `WeatherApiInterceptor` interceptor, so when - /// `LoggerInterceptor` receives the request/response it has already been - /// intercepted. - factory InterceptedClient.build({ - required List interceptors, - Duration? requestTimeout, - TimeoutCallback? onRequestTimeout, - RetryPolicy? retryPolicy, - Client? client, - }) => InterceptedClient._internal( - interceptors: interceptors, - requestTimeout: requestTimeout, - onRequestTimeout: onRequestTimeout, - retryPolicy: retryPolicy, - client: client, - ); - - @override - Future head(Uri url, {Map? headers}) async => - (await _sendUnstreamed( - method: HttpMethod.HEAD, - url: url, - headers: headers, - )) - as Response; - - @override - Future get( - Uri url, { - Map? headers, - Map? params, - }) async => - (await _sendUnstreamed( - method: HttpMethod.GET, - url: url, - headers: headers, - params: params, - )) - as Response; - - @override - Future post( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) async => - (await _sendUnstreamed( - method: HttpMethod.POST, - url: url, - headers: headers, - params: params, - body: body, - encoding: encoding, - )) - as Response; - - @override - Future put( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) async => - (await _sendUnstreamed( - method: HttpMethod.PUT, - url: url, - headers: headers, - params: params, - body: body, - encoding: encoding, - )) - as Response; - - @override - Future patch( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) async => - (await _sendUnstreamed( - method: HttpMethod.PATCH, - url: url, - headers: headers, - params: params, - body: body, - encoding: encoding, - )) - as Response; - - @override - Future delete( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) async => - (await _sendUnstreamed( - method: HttpMethod.DELETE, - url: url, - headers: headers, - params: params, - body: body, - encoding: encoding, - )) - as Response; - - @override - Future read( - Uri url, { - Map? headers, - Map? params, - }) async { - final Response response = await get(url, headers: headers, params: params); - _checkResponseSuccess(url, response); - return response.body; - } - - @override - Future readBytes( - Uri url, { - Map? headers, - Map? params, - }) async { - final Response response = await get(url, headers: headers, params: params); - _checkResponseSuccess(url, response); - return response.bodyBytes; - } - - @override - Future send(BaseRequest request) async { - final BaseResponse response = await _attemptRequest( - request, - isStream: true, - ); - - final BaseResponse interceptedResponse = await _interceptResponse(response); - - if (interceptedResponse is StreamedResponse) { - return interceptedResponse; - } - - throw ClientException( - 'Expected `StreamedResponse`, got ${interceptedResponse.runtimeType}.', - ); - } - - Future _sendUnstreamed({ - required HttpMethod method, - required Uri url, - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) async { - 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) { - if (body is String) { - request.body = body; - } else if (body is List) { - request.bodyBytes = body.cast(); - } else if (body is Map) { - request.bodyFields = body.cast(); - } else { - throw ArgumentError('Invalid request body "$body".'); - } - } - - final BaseResponse response = await _attemptRequest(request); - - // Intercept response - return await _interceptResponse(response); - } - - void _checkResponseSuccess(Uri url, Response response) { - if (response.statusCode < 400) return; - 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 { - _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 { - BaseResponse response; - - try { - // Intercept request - final BaseRequest interceptedRequest = await _interceptRequest(request); - - late final StreamedResponse stream; - - if (requestTimeout == null) { - stream = await _inner.send(interceptedRequest); - } else { - // Use a completer to properly handle timeout and cancellation - final Completer completer = Completer(); - final Future requestFuture = _inner.send( - interceptedRequest, - ); - - // Set up timeout with proper cleanup - bool isCompleted = false; - - final Timer timeoutTimer = Timer(requestTimeout!, () { - if (!isCompleted) { - isCompleted = true; - if (onRequestTimeout != null) { - // If timeout callback is provided, use it - try { - final timeoutResponse = onRequestTimeout!(); - if (timeoutResponse is Future) { - timeoutResponse - .then((response) { - if (!completer.isCompleted) { - completer.complete(response); - } - }) - .catchError((error) { - if (!completer.isCompleted) { - completer.completeError(error); - } - }); - } else { - if (!completer.isCompleted) { - completer.complete(timeoutResponse); - } - } - } catch (error) { - if (!completer.isCompleted) { - completer.completeError(error); - } - } - } else { - // Default timeout behavior - if (!completer.isCompleted) { - completer.completeError( - Exception( - 'Request timeout after ${requestTimeout!.inMilliseconds}ms', - ), - ); - } - } - } - }); - - // Handle the actual request completion - requestFuture - .then((streamResponse) { - timeoutTimer.cancel(); - if (!isCompleted) { - isCompleted = true; - if (!completer.isCompleted) { - completer.complete(streamResponse); - } - } - }) - .catchError((error) { - timeoutTimer.cancel(); - if (!isCompleted) { - isCompleted = true; - if (!completer.isCompleted) { - completer.completeError(error); - } - } - }); - - stream = await completer.future; - } - - response = isStream ? stream : await Response.fromStream(stream); - - if (retryPolicy != null && - retryPolicy!.maxRetryAttempts > _retryCount && - await retryPolicy!.shouldAttemptRetryOnResponse(response)) { - _retryCount += 1; - await Future.delayed( - retryPolicy!.delayRetryAttemptOnResponse(retryAttempt: _retryCount), - ); - return _attemptRequestWithRetries(request, isStream: isStream); - } - } on Exception catch (error, stackTrace) { - if (retryPolicy != null && - retryPolicy!.maxRetryAttempts > _retryCount && - await retryPolicy!.shouldAttemptRetryOnException(error, request)) { - _retryCount += 1; - await Future.delayed( - retryPolicy!.delayRetryAttemptOnException(retryAttempt: _retryCount), - ); - return _attemptRequestWithRetries(request, isStream: isStream); - } else { - await _interceptError( - request: request, - error: error, - stackTrace: stackTrace, - ); - - rethrow; - } - } - - return response; - } - - /// This internal function intercepts the request. - Future _interceptRequest(BaseRequest request) async { - BaseRequest interceptedRequest = request.copyWith(); - for (InterceptorContract interceptor in interceptors) { - if (await interceptor.shouldInterceptRequest( - request: interceptedRequest, - )) { - interceptedRequest = await interceptor.interceptRequest( - request: interceptedRequest, - ); - } - } - - return interceptedRequest; - } - - /// This internal function intercepts the response. - Future _interceptResponse(BaseResponse response) async { - BaseResponse interceptedResponse = response; - for (InterceptorContract interceptor in interceptors) { - if (await interceptor.shouldInterceptResponse( - response: interceptedResponse, - )) { - interceptedResponse = await interceptor.interceptResponse( - response: interceptedResponse, - ); - } - } - - 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 deleted file mode 100644 index e94ccf2..0000000 --- a/lib/http/intercepted_http.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:http_interceptor/http_interceptor.dart'; - -/// Class to be used by the user as a replacement for 'http' with interceptor -/// support. -/// -/// It is a useful class if you want to centralize HTTP request calls -/// since it creates and discards [InterceptedClient] instances after the -/// request is done and that allows you to avoid handling your own [Client] -/// instances. -/// -/// -/// Call `build()` and pass list of interceptors as parameter. -/// -/// Example: -/// ```dart -/// InterceptedHttp http = InterceptedHttp.build(interceptors: [ -/// LoggingInterceptor(), -/// ]); -/// ``` -/// -/// Then call the functions you want to, on the created `http` object. -/// ```dart -/// http.get(...); -/// http.post(...); -/// http.put(...); -/// http.delete(...); -/// http.head(...); -/// http.patch(...); -/// http.send(...); -/// http.read(...); -/// http.readBytes(...); -/// ``` -class InterceptedHttp { - /// List of interceptors that will be applied to the requests and responses. - final List interceptors; - - /// Maximum duration of a request. - final Duration? requestTimeout; - - /// Request timeout handler - TimeoutCallback? onRequestTimeout; - - /// A policy that defines whether a request or response should trigger a - /// retry. This is useful for implementing JWT token expiration - final RetryPolicy? retryPolicy; - - /// Inner client that is wrapped for intercepting. - /// - /// If you don't specify your own client then the library will instantiate - /// a default one. - Client? client; - - InterceptedHttp._internal({ - required this.interceptors, - this.requestTimeout, - this.onRequestTimeout, - this.retryPolicy, - this.client, - }); - - /// Builds a new [InterceptedHttp] instance. It helps avoid creating and - /// managing your own `Client` instances. - /// - /// Interceptors are applied in a linear order. For example a list that looks - /// like this: - /// - /// ```dart - /// InterceptedClient.build( - /// interceptors: [ - /// WeatherApiInterceptor(), - /// LoggerInterceptor(), - /// ], - /// ), - /// ``` - /// - /// Will apply first the `WeatherApiInterceptor` interceptor, so when - /// `LoggerInterceptor` receives the request/response it has already been - /// intercepted. - factory InterceptedHttp.build({ - required List interceptors, - Duration? requestTimeout, - TimeoutCallback? onRequestTimeout, - RetryPolicy? retryPolicy, - Client? client, - }) => InterceptedHttp._internal( - interceptors: interceptors, - requestTimeout: requestTimeout, - onRequestTimeout: onRequestTimeout, - retryPolicy: retryPolicy, - client: client, - ); - - /// Performs a HEAD request with a new [Client] instance and closes it after - /// it has been used. - Future head(Uri url, {Map? headers}) => _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( - Uri url, { - Map? headers, - Map? params, - }) => _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( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) => _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( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) => _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( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) => _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( - Uri url, { - Map? headers, - Map? params, - Object? body, - Encoding? encoding, - }) => _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( - Uri url, { - Map? headers, - Map? params, - }) => _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( - Uri url, { - Map? headers, - Map? 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 InterceptedClient client = InterceptedClient.build( - interceptors: interceptors, - requestTimeout: requestTimeout, - onRequestTimeout: onRequestTimeout, - retryPolicy: retryPolicy, - client: this.client, - ); - try { - return await fn(client); - } finally { - client.close(); - } - } -} diff --git a/lib/http_interceptor.dart b/lib/http_interceptor.dart index e8189b1..d97da7a 100644 --- a/lib/http_interceptor.dart +++ b/lib/http_interceptor.dart @@ -1,20 +1,9 @@ -library; - export 'package:http/http.dart'; -export './extensions/base_request.dart'; -export './extensions/base_response_none.dart' - if (dart.library.io) './extensions/base_response_io.dart'; -export './extensions/multipart_request.dart'; -export './extensions/request.dart'; -export './extensions/response.dart'; -export './extensions/streamed_request.dart'; -export './extensions/streamed_response.dart'; -export './extensions/string.dart'; -export './extensions/uri.dart'; -export './http/http_methods.dart'; -export './http/intercepted_client.dart'; -export './http/intercepted_http.dart'; -export './models/http_interceptor_exception.dart'; -export './models/interceptor_contract.dart'; -export './models/retry_policy.dart'; +export 'src/client/intercepted_client.dart'; +export 'src/client/intercepted_http.dart'; +export 'src/extensions/string_extension.dart'; +export 'src/extensions/response_extension.dart'; +export 'src/extensions/uri_extension.dart'; +export 'src/interceptor/http_interceptor.dart'; +export 'src/retry/retry_policy.dart'; diff --git a/lib/http_interceptor_io.dart b/lib/http_interceptor_io.dart new file mode 100644 index 0000000..e8165be --- /dev/null +++ b/lib/http_interceptor_io.dart @@ -0,0 +1,5 @@ +// Platform-specific export for VM and mobile/desktop. +// Import when you need [IOClient] for self-signed certificates or other TLS. +// Do not import on Flutter web—it pulls in dart:io. +export 'http_interceptor.dart'; +export 'package:http/io_client.dart' show IOClient; diff --git a/lib/models/http_interceptor_exception.dart b/lib/models/http_interceptor_exception.dart deleted file mode 100644 index 0c9d5b7..0000000 --- a/lib/models/http_interceptor_exception.dart +++ /dev/null @@ -1,11 +0,0 @@ -class HttpInterceptorException implements Exception { - final dynamic message; - - HttpInterceptorException([this.message]); - - @override - String toString() { - if (message == null) return "Exception"; - return "Exception: $message"; - } -} diff --git a/lib/models/interceptor_contract.dart b/lib/models/interceptor_contract.dart deleted file mode 100644 index 4b4c910..0000000 --- a/lib/models/interceptor_contract.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import 'package:http/http.dart'; - -///Interceptor interface to create custom Interceptor for http. -///Extend this class and override the functions that you want -///to intercept. -/// -///Intercepting: You have to implement two functions, `interceptRequest` and -///`interceptResponse`. -/// -///Example (Simple logging): -/// -///```dart -/// class LoggingInterceptor implements InterceptorContract { -/// @override -/// FutureOr interceptRequest({required BaseRequest request}) async { -/// print(request.toString()); -/// return data; -/// } -/// -/// @override -/// FutureOr interceptResponse({required BaseResponse response}) async { -/// print(response.toString()); -/// return data; -/// } -/// -///} -///``` -abstract class InterceptorContract { - /// 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; - - /// Intercepts the response. - FutureOr interceptResponse({required BaseResponse response}); - - /// Checks if the error should be intercepted. - FutureOr shouldInterceptError({ - BaseRequest? request, - BaseResponse? response, - }) => true; - - /// 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 deleted file mode 100644 index 481fc40..0000000 --- a/lib/models/retry_policy.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; - -import 'package:http/http.dart'; - -/// Defines the behavior for retrying requests. -/// -/// Example: -/// -/// ```dart -/// class ExpiredTokenRetryPolicy extends RetryPolicy { -/// @override -/// int get maxRetryAttempts => 2; -/// -/// @override -/// bool shouldAttemptRetryOnException(Exception reason, BaseRequest request) { -/// log(reason.toString()); -/// log("Request URL: ${request.url}"); -/// -/// return false; -/// } -/// -/// @override -/// Future shouldAttemptRetryOnResponse(BaseResponse response) async { -/// if (response.statusCode == 401) { -/// log("Retrying request..."); -/// final cache = await SharedPreferences.getInstance(); -/// -/// cache.setString(kOWApiToken, OPEN_WEATHER_API_KEY); -/// -/// return true; -/// } -/// -/// return false; -/// } -/// } -/// ``` -abstract class RetryPolicy { - /// Defines whether the request should be retried when an Exception occurs - /// while making said request to the server. - /// - /// [reason] - The exception that occurred during the request - /// [request] - The original request that failed - /// - /// Returns `true` if the request should be retried, `false` otherwise. - FutureOr shouldAttemptRetryOnException( - Exception reason, - BaseRequest request, - ) => false; - - /// Defines whether the request should be retried after the request has - /// received `response` from the server. - /// - /// [response] - The response received from the server - /// - /// Returns `true` if the request should be retried, `false` otherwise. - /// Common use cases include retrying on 401 (Unauthorized) or 500 (Server Error). - FutureOr shouldAttemptRetryOnResponse(BaseResponse response) => false; - - /// Number of maximum request attempts that can be retried. - /// - /// Default is 1, meaning the original request plus 1 retry attempt. - /// Set to 0 to disable retries, or higher values for more retry attempts. - int get maxRetryAttempts => 1; - - /// Delay before retrying when an exception occurs. - /// - /// [retryAttempt] - The current retry attempt number (1-based) - /// - /// Returns the delay duration. Default is no delay (Duration.zero). - /// Consider implementing exponential backoff for production use. - Duration delayRetryAttemptOnException({required int retryAttempt}) => - Duration.zero; - - /// Delay before retrying when a response indicates retry is needed. - /// - /// [retryAttempt] - The current retry attempt number (1-based) - /// - /// Returns the delay duration. Default is no delay (Duration.zero). - /// Consider implementing exponential backoff for production use. - Duration delayRetryAttemptOnResponse({required int retryAttempt}) => - Duration.zero; -} diff --git a/lib/src/client/intercepted_client.dart b/lib/src/client/intercepted_client.dart new file mode 100644 index 0000000..cd4c40c --- /dev/null +++ b/lib/src/client/intercepted_client.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +import '../interceptor/http_interceptor.dart'; +import '../interceptor/interceptor_chain.dart'; +import '../extensions/uri_extension.dart'; +import '../retry/retry_executor.dart'; +import '../retry/retry_policy.dart'; +import '../timeout_wrapper.dart'; + +/// HTTP client that runs [HttpInterceptor]s on every request and response. +/// +/// Wraps a [Client] (Decorator pattern): delegates to the inner client after +/// running request interceptors, then runs response interceptors on the result. +/// Use [build] to construct with interceptors and optional inner client. +class InterceptedClient extends BaseClient { + InterceptedClient._({ + required List interceptors, + required Client client, + RetryPolicy? retryPolicy, + Duration? requestTimeout, + void Function()? onRequestTimeout, + }) : _chain = InterceptorChain(interceptors), + _client = client, + _retryPolicy = retryPolicy, + _requestTimeout = requestTimeout, + _onRequestTimeout = onRequestTimeout; + + final InterceptorChain _chain; + final Client _client; + final RetryPolicy? _retryPolicy; + final Duration? _requestTimeout; + final void Function()? _onRequestTimeout; + + /// Builds an [InterceptedClient] with the given [interceptors]. + /// + /// [client] defaults to the platform default ([Client()]). Pass a custom + /// client (e.g. [IOClient] with [HttpClient.badCertificateCallback]) for + /// self-signed certificates or other TLS behavior. + /// + /// [retryPolicy] when non-null enables retries on exception or response. + /// + /// [requestTimeout] when non-null applies a per-request timeout. + /// [onRequestTimeout] is invoked when a request times out (if provided). + factory InterceptedClient.build({ + required List interceptors, + Client? client, + RetryPolicy? retryPolicy, + Duration? requestTimeout, + void Function()? onRequestTimeout, + }) { + return InterceptedClient._( + interceptors: interceptors, + client: client ?? Client(), + retryPolicy: retryPolicy, + requestTimeout: requestTimeout, + onRequestTimeout: onRequestTimeout, + ); + } + + Future _execute(BaseRequest request) async { + final interceptedRequest = await _chain.runRequestInterceptors(request); + return _client.send(interceptedRequest); + } + + @override + Future send(BaseRequest request) => withTimeout( + action: () async { + final streamed = _retryPolicy != null + ? await executeWithRetry( + policy: _retryPolicy, + request: request, + attempt: () => _execute(request), + ) + : await _execute(request); + + final response = await _chain.runResponseInterceptors(streamed); + return response as StreamedResponse; + }, + timeout: _requestTimeout, + onTimeout: _onRequestTimeout, + ); + + /// Sends a GET request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future get( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => super.get( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + /// Sends a HEAD request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future head( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => super.head( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + /// Sends a POST request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future post( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => super.post( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + /// Sends a PUT request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future put( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => super.put( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + /// Sends a PATCH request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future patch( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => super.patch( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + /// Sends a DELETE request. [params] and [paramsAll] are merged into [url]'s query. + @override + Future delete( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => super.delete( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + @override + void close() { + _client.close(); + } +} diff --git a/lib/src/client/intercepted_http.dart b/lib/src/client/intercepted_http.dart new file mode 100644 index 0000000..4c239a7 --- /dev/null +++ b/lib/src/client/intercepted_http.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; + +import '../interceptor/http_interceptor.dart'; +import '../extensions/uri_extension.dart'; +import '../retry/retry_policy.dart'; +import 'intercepted_client.dart'; + +/// Facade for one-off or shared intercepted HTTP calls. +/// +/// Holds an [InterceptedClient] and exposes the same API (get, post, put, +/// patch, delete, head, read, readBytes, send). For long-lived apps with many +/// requests, prefer holding an [InterceptedClient] and reusing it. +class InterceptedHttp { + InterceptedHttp._(this._client); + + final InterceptedClient _client; + + /// Builds an [InterceptedHttp] with the same options as [InterceptedClient.build]. + factory InterceptedHttp.build({ + required List interceptors, + Client? client, + RetryPolicy? retryPolicy, + Duration? requestTimeout, + void Function()? onRequestTimeout, + }) { + return InterceptedHttp._( + InterceptedClient.build( + interceptors: interceptors, + client: client, + retryPolicy: retryPolicy, + requestTimeout: requestTimeout, + onRequestTimeout: onRequestTimeout, + ), + ); + } + + Future get( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => _client.get( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + Future head( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => _client.head( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + Future post( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => _client.post( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + Future put( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => _client.put( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + Future patch( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => _client.patch( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + Future delete( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + Map? params, + Map>? paramsAll, + }) => _client.delete( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + body: body, + encoding: encoding, + ); + + Future read( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => _client.read( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + Future readBytes( + Uri url, { + Map? headers, + Map? params, + Map>? paramsAll, + }) => _client.readBytes( + url.addQueryParams(params: params, paramsAll: paramsAll), + headers: headers, + ); + + Future send(BaseRequest request) => _client.send(request); + + void close() => _client.close(); +} diff --git a/lib/src/extensions/response_extension.dart b/lib/src/extensions/response_extension.dart new file mode 100644 index 0000000..6e030c9 --- /dev/null +++ b/lib/src/extensions/response_extension.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +/// Convenience helpers for decoding a [Response] body. +/// +/// These helpers are intentionally lightweight: they do not enforce JSON, do +/// not depend on interceptors, and leave error/status-code handling to the +/// caller. +extension ResponseBodyDecoding on Response { + /// Decodes [body] as JSON and returns the result. + /// + /// The returned value is typically a `Map` or `List`. + /// Throws a [FormatException] if [body] is not valid JSON. + Object? get jsonBody => body.isEmpty ? null : jsonDecode(body); + + /// Decodes [body] as a JSON object (`Map`). + /// + /// Throws if [body] is empty, not valid JSON, or not a JSON object. + Map get jsonMap => jsonDecode(body) as Map; + + /// Decodes [body] as a JSON array (`List`). + /// + /// Throws if [body] is empty, not valid JSON, or not a JSON array. + List get jsonList => jsonDecode(body) as List; + + /// Attempts to decode [body] as JSON and returns `null` on failure. + Object? tryJsonBody() { + if (body.isEmpty) return null; + try { + return jsonDecode(body); + } on FormatException { + return null; + } + } + + /// Maps the decoded JSON body into a model using [fromJson]. + /// + /// This is a small convenience wrapper around [jsonBody] intended to keep + /// call sites concise. + T decodeJson(T Function(Object? json) fromJson) => fromJson(jsonBody); +} diff --git a/lib/src/extensions/string_extension.dart b/lib/src/extensions/string_extension.dart new file mode 100644 index 0000000..ac6e2df --- /dev/null +++ b/lib/src/extensions/string_extension.dart @@ -0,0 +1,7 @@ +/// Extension to parse a [String] as a [Uri]. +/// +/// Allows `"$baseUrl/path".toUri()` when using the library. +extension StringToUri on String { + /// Parses this string as a [Uri]. + Uri toUri() => Uri.parse(this); +} diff --git a/lib/src/extensions/uri_extension.dart b/lib/src/extensions/uri_extension.dart new file mode 100644 index 0000000..d109e9f --- /dev/null +++ b/lib/src/extensions/uri_extension.dart @@ -0,0 +1,35 @@ +/// Merges [params] and [paramsAll] into this [Uri]'s query. +/// +/// [params] are single-value query parameters; [paramsAll] support multiple +/// values per key (array parameters). Existing query parameters are preserved +/// and merged with the new ones. +extension UriQueryParams on Uri { + /// Returns a new [Uri] with [params] and [paramsAll] merged into the query. + Uri addQueryParams({ + Map? params, + Map>? paramsAll, + }) { + if (params == null && paramsAll == null) return this; + final p = Map.from(queryParameters); + if (params != null) p.addAll(params); + final pa = Map>.from(queryParametersAll); + if (paramsAll != null) { + for (final e in paramsAll.entries) { + pa[e.key] = List.from(e.value); + } + } + // Uri.replace only has queryParameters (single value per key); build query + // manually to support multiple values per key. + final parts = [ + ...p.entries.map( + (e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', + ), + ...pa.entries.expand( + (e) => e.value.map( + (v) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(v)}', + ), + ), + ]; + return replace(query: parts.isEmpty ? null : parts.join('&')); + } +} diff --git a/lib/src/handlers/response_handler.dart b/lib/src/handlers/response_handler.dart new file mode 100644 index 0000000..9146d5e --- /dev/null +++ b/lib/src/handlers/response_handler.dart @@ -0,0 +1,18 @@ +import 'package:http/http.dart'; + +/// Converts a [StreamedResponse] into the [BaseResponse] that will be passed +/// to response interceptors. +/// +/// Used by the pipeline so the same chain can run on either [StreamedResponse] +/// (for [Client.send]) or [Response] (for get/post/put/patch/delete after +/// reading the body). +typedef ResponseToIntercept = + Future Function(StreamedResponse streamed); + +/// Returns the [StreamedResponse] as-is. Use for [Client.send]. +Future interceptStreamedResponse(StreamedResponse streamed) => + Future.value(streamed); + +/// Reads the response body and returns a [Response]. Use for get/post/put/patch/delete. +Future interceptBufferedResponse(StreamedResponse streamed) => + Response.fromStream(streamed); diff --git a/lib/src/interceptor/http_interceptor.dart b/lib/src/interceptor/http_interceptor.dart new file mode 100644 index 0000000..41846af --- /dev/null +++ b/lib/src/interceptor/http_interceptor.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:http/http.dart'; + +/// Strategy for transforming HTTP requests and responses. +/// +/// Implement this interface to add cross-cutting behavior (logging, auth +/// headers, error handling) without modifying the client. Interceptors +/// receive and return [BaseRequest]/[BaseResponse]; use in-place mutation +/// or return the same instance. Order of execution is the order in the list. +abstract interface class HttpInterceptor { + /// Runs before the request is sent. Return the (possibly modified) request. + FutureOr interceptRequest({required BaseRequest request}); + + /// Runs after the response is received. Return the (possibly modified) response. + FutureOr interceptResponse({required BaseResponse response}); + + /// Whether to run [interceptRequest] for this request. Defaults to true. + FutureOr shouldInterceptRequest({required BaseRequest request}) => true; + + /// Whether to run [interceptResponse] for this response. Defaults to true. + FutureOr shouldInterceptResponse({required BaseResponse response}) => + true; +} diff --git a/lib/src/interceptor/interceptor_chain.dart b/lib/src/interceptor/interceptor_chain.dart new file mode 100644 index 0000000..026fb9b --- /dev/null +++ b/lib/src/interceptor/interceptor_chain.dart @@ -0,0 +1,41 @@ +import 'package:http/http.dart'; + +import 'http_interceptor.dart'; + +/// Runs a list of [HttpInterceptor]s in order with guard clauses. +/// +/// For each interceptor: if [shouldInterceptRequest]/[shouldInterceptResponse] +/// is false, the request/response is passed through unchanged; otherwise +/// [interceptRequest]/[interceptResponse] is called and the result is used +/// for the next interceptor. +class InterceptorChain { + InterceptorChain(this._interceptors); + + final List _interceptors; + + /// Runs request interceptors in order. Returns the final request. + Future runRequestInterceptors(BaseRequest request) async { + BaseRequest current = request; + for (final interceptor in _interceptors) { + final shouldIntercept = await interceptor.shouldInterceptRequest( + request: current, + ); + if (!shouldIntercept) continue; + current = await interceptor.interceptRequest(request: current); + } + return current; + } + + /// Runs response interceptors in order. Returns the final response. + Future runResponseInterceptors(BaseResponse response) async { + BaseResponse current = response; + for (final interceptor in _interceptors) { + final shouldIntercept = await interceptor.shouldInterceptResponse( + response: current, + ); + if (!shouldIntercept) continue; + current = await interceptor.interceptResponse(response: current); + } + return current; + } +} diff --git a/lib/src/retry/retry_executor.dart b/lib/src/retry/retry_executor.dart new file mode 100644 index 0000000..e6a2066 --- /dev/null +++ b/lib/src/retry/retry_executor.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:http/http.dart'; + +import 'retry_policy.dart'; + +/// Runs [attempt] and retries according to [policy] when the policy says so. +/// +/// [attempt] should run request interceptors and send (one full try). [request] +/// is passed to the policy for exception retries. Each retry runs [attempt] +/// again so interceptors can run anew (e.g. refresh token). +Future executeWithRetry({ + required RetryPolicy policy, + required BaseRequest request, + required Future Function() attempt, +}) async { + final totalAllowed = 1 + policy.maxRetryAttempts; + int tries = 0; + + while (true) { + tries++; + try { + final response = await attempt(); + final shouldRetry = await policy.shouldAttemptRetryOnResponse(response); + if (!shouldRetry || tries >= totalAllowed) return response; + await Future.delayed( + policy.delayRetryAttemptOnResponse(retryAttempt: tries), + ); + } on Exception catch (e) { + if (tries >= totalAllowed) rethrow; + final shouldRetry = await policy.shouldAttemptRetryOnException( + e, + request, + ); + if (!shouldRetry) rethrow; + await Future.delayed( + policy.delayRetryAttemptOnException(retryAttempt: tries), + ); + } + } +} diff --git a/lib/src/retry/retry_policy.dart b/lib/src/retry/retry_policy.dart new file mode 100644 index 0000000..93cd27f --- /dev/null +++ b/lib/src/retry/retry_policy.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:http/http.dart'; + +/// Strategy for when to retry a request (on exception or on response) and +/// how long to wait before each retry. +abstract interface class RetryPolicy { + /// Maximum number of retry attempts (0 = no retries, 1 = one retry, etc.). + int get maxRetryAttempts; + + /// Whether to retry after an [Exception] during the request. + /// + /// Return true to retry (subject to [maxRetryAttempts]); false to fail immediately. + FutureOr shouldAttemptRetryOnException( + Exception reason, + BaseRequest request, + ); + + /// Whether to retry after receiving [response]. + /// + /// Return true to retry (e.g. 401 refresh token); false to return the response. + FutureOr shouldAttemptRetryOnResponse(BaseResponse response); + + /// Delay before retrying after an exception. [retryAttempt] is 1-based. + Duration delayRetryAttemptOnException({required int retryAttempt}) => + Duration.zero; + + /// Delay before retrying after a response. [retryAttempt] is 1-based. + Duration delayRetryAttemptOnResponse({required int retryAttempt}) => + Duration.zero; +} diff --git a/lib/src/timeout_wrapper.dart b/lib/src/timeout_wrapper.dart new file mode 100644 index 0000000..1b1c634 --- /dev/null +++ b/lib/src/timeout_wrapper.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +/// Wraps [action] in a [Duration] timeout and optionally invokes [onTimeout]. +/// +/// If [timeout] is null, [action] runs with no timeout. If the timeout fires, +/// [onTimeout] is called (if non-null) and a [TimeoutException] is thrown. +Future withTimeout({ + required Future Function() action, + Duration? timeout, + void Function()? onTimeout, +}) async { + if (timeout == null || timeout == Duration.zero) return action(); + return action().timeout( + timeout, + onTimeout: onTimeout != null + ? () { + onTimeout(); + throw TimeoutException('Request timed out after $timeout'); + } + : null, + ); +} diff --git a/lib/utils/query_parameters.dart b/lib/utils/query_parameters.dart deleted file mode 100644 index 1d0b2c5..0000000 --- a/lib/utils/query_parameters.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:qs_dart/qs_dart.dart' as qs; -import 'package:validators/validators.dart' as validators; - -/// Takes a string and appends [parameters] as query parameters of [url]. -/// -/// Throws [ArgumentError] if [url] is not a valid URL. -String buildUrlString(String url, Map? parameters) { - late final Uri uri; - - try { - if (!validators.isURL(url)) { - throw FormatException('Invalid URL format'); - } - uri = Uri.parse(url); - } on FormatException { - throw ArgumentError.value(url, 'url', 'Must be a valid URL'); - } - - return parameters?.isNotEmpty ?? false - ? uri - .replace( - query: qs.encode( - {...uri.queryParametersAll, ...?parameters}, - qs.EncodeOptions( - listFormat: qs.ListFormat.repeat, - skipNulls: false, - strictNullHandling: false, - ), - ), - queryParameters: null, - ) - .toString() - : url; -} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart deleted file mode 100644 index 7276b49..0000000 --- a/lib/utils/utils.dart +++ /dev/null @@ -1 +0,0 @@ -export 'query_parameters.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 5e4f146..88feb04 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: http_interceptor description: A lightweight, simple plugin that allows you to intercept request and response objects and modify them if desired. -version: 2.0.0 +version: 3.0.0 homepage: https://github.com/CodingAleCR/http_interceptor issue_tracker: https://github.com/CodingAleCR/http_interceptor/issues repository: https://github.com/CodingAleCR/http_interceptor @@ -12,8 +12,6 @@ environment: dependencies: http: ^1.2.1 - qs_dart: ^1.3.8 - validators: ^3.0.0 dev_dependencies: lints: ^6.1.0 diff --git a/test/extensions/base_reponse_test.dart b/test/extensions/base_reponse_test.dart deleted file mode 100644 index be5917d..0000000 --- a/test/extensions/base_reponse_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:test/test.dart'; - -void main() { - group('BaseResponse.copyWith: ', () { - test('Response is copied from BaseResponse', () { - // Arrange - final BaseResponse baseResponse = Response("{'foo': 'bar'}", 200); - - // Act - final copiedBaseRequest = baseResponse.copyWith(); - final copied = copiedBaseRequest as Response; - - // Assert - final response = baseResponse as Response; - expect(copied.hashCode, isNot(equals(response.hashCode))); - expect(copied.statusCode, equals(response.statusCode)); - expect(copied.body, equals(response.body)); - expect(copied.headers, equals(response.headers)); - expect(copied.isRedirect, equals(response.isRedirect)); - expect(copied.reasonPhrase, equals(response.reasonPhrase)); - expect( - copied.persistentConnection, - equals(response.persistentConnection), - ); - }); - }); -} diff --git a/test/extensions/base_request_test.dart b/test/extensions/base_request_test.dart deleted file mode 100644 index 8c0db0c..0000000 --- a/test/extensions/base_request_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:convert'; - -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:test/test.dart'; - -void main() { - group('BaseRequest.copyWith: ', () { - test('Request is copied from BaseRequest', () { - // Arrange - final BaseRequest baseRequest = Request( - "GET", - Uri.https("www.google.com", "/helloworld"), - )..body = jsonEncode({'some_param': 'some value'}); - final copiedBaseRequest = baseRequest.copyWith(); - - // Act - final copied = copiedBaseRequest as Request; - - // Assert - final request = baseRequest as Request; - expect(copied.hashCode, isNot(equals(request.hashCode))); - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - }); -} diff --git a/test/extensions/request_test.dart b/test/extensions/request_test.dart deleted file mode 100644 index 0e1221b..0000000 --- a/test/extensions/request_test.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:test/test.dart'; - -void main() { - late BaseRequest baseRequest; - late Request request; - - setUpAll(() { - baseRequest = Request("GET", Uri.https("www.google.com", "/helloworld")) - ..body = jsonEncode({'some_param': 'some value'}) - ..headers.addAll({ - HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8', - }); - request = baseRequest as Request; - }); - - group('BaseRequest.copyWith: ', () { - test('Request is copied from BaseRequest', () { - // Arrange - final copiedBaseRequest = baseRequest.copyWith(); - - // Act - final copied = copiedBaseRequest as Request; - - // Assert - expect(copied.hashCode, isNot(equals(request.hashCode))); - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - }); - - group('Request.copyWith:', () { - test('Request is copied without differences', () { - // Arrange - - // Act - final Request copied = request.copyWith(); - - // Assert - expect(copied.hashCode, isNot(equals(request.hashCode))); - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different URI', () { - // Arrange - final Uri newUrl = Uri.https("www.google.com", "/foobar"); - - // Act - final Request copied = request.copyWith(url: newUrl); - - // Assert - expect(copied.url, allOf([equals(newUrl), isNot(equals(request.url))])); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different method', () { - // Arrange - const newMethod = HttpMethod.POST; - - // Act - final Request copied = request.copyWith(method: newMethod); - - // Assert - expect(copied.url, equals(request.url)); - expect( - copied.method, - allOf([ - equals(HttpMethod.POST.asString), - isNot(equals(request.method)), - ]), - ); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different headers', () { - // Arrange - final newHeaders = Map.from(request.headers); - newHeaders['Authorization'] = 'Bearer token'; - - // Act - final Request copied = request.copyWith(headers: newHeaders); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect( - copied.headers, - allOf([equals(newHeaders), isNot(equals(request.headers))]), - ); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different headers', () { - // Arrange - final newHeaders = Map.from(request.headers); - newHeaders['Authorization'] = 'Bearer token'; - - // Act - final Request copied = request.copyWith(headers: newHeaders); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect( - copied.headers, - allOf([equals(newHeaders), isNot(equals(request.headers))]), - ); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied overriding headers', () { - // Arrange - final newHeaders = Map.from(request.headers); - newHeaders['content-type'] = 'text/plain; charset=utf-8'; - - // Act - final Request copied = request.copyWith(headers: newHeaders); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect( - copied.headers, - allOf([equals(newHeaders), isNot(equals(request.headers))]), - ); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different body', () { - // Arrange - final newBody = - jsonDecode(request.body.isNotEmpty ? request.body : '{}') as Map; - newBody['hello'] = 'world'; - - // Act - final Request copied = request.copyWith(body: jsonEncode(newBody)); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect( - copied.body, - allOf([equals(jsonEncode(newBody)), isNot(equals(request.body))]), - ); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - - test('Request is copied with GZip encoded body', () { - // Arrange - final gzip = GZipCodec(); - final newBody = - jsonDecode(request.body.isNotEmpty ? request.body : '{}') as Map; - newBody['hello'] = 'world'; - - // Act - final utfBytes = utf8.encode(jsonEncode(newBody)); - final gzipBytes = gzip.encode(utfBytes); - final Request copied = request.copyWith(body: base64.encode(gzipBytes)); - - // Assert - final decodedBody = utf8.decode(gzip.decode(base64.decode(copied.body))); - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect( - decodedBody, - allOf([equals(jsonEncode(newBody)), isNot(equals(request.body))]), - ); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - - test('Request is copied with different encoding', () { - // Arrange - final newEncoding = Encoding.getByName('latin1'); - final changedHeaders = {'content-type': 'text/plain; charset=iso-8859-1'} - ..addAll(request.headers); - - final Request updatedHeadersRequest = request.copyWith( - headers: changedHeaders, - ); - - // Act - final Request copied = updatedHeadersRequest.copyWith( - encoding: newEncoding, - ); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers.length, equals(request.headers.length)); - expect(updatedHeadersRequest.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect( - copied.encoding, - allOf([equals(newEncoding), isNot(equals(request.encoding))]), - ); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different followRedirects', () { - // Arrange - const newFollowRedirects = false; - - // Act - final Request copied = request.copyWith( - followRedirects: newFollowRedirects, - ); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect( - copied.followRedirects, - allOf([ - equals(newFollowRedirects), - isNot(equals(request.followRedirects)), - ]), - ); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - test('Request is copied with different maxRedirects', () { - // Arrange - const newMaxRedirects = 2; - - // Act - final Request copied = request.copyWith(maxRedirects: newMaxRedirects); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect( - copied.maxRedirects, - allOf([equals(newMaxRedirects), isNot(equals(request.maxRedirects))]), - ); - expect(copied.persistentConnection, equals(request.persistentConnection)); - }); - - test('Request is copied with different persistentConnection', () { - // Arrange - const newPersistentConnection = false; - - // Act - final Request copied = request.copyWith( - persistentConnection: newPersistentConnection, - ); - - // Assert - expect(copied.url, equals(request.url)); - expect(copied.method, equals(request.method)); - expect(copied.headers, equals(request.headers)); - expect(copied.body, equals(request.body)); - expect(copied.encoding, equals(request.encoding)); - expect(copied.followRedirects, equals(request.followRedirects)); - expect(copied.maxRedirects, equals(request.maxRedirects)); - expect( - copied.persistentConnection, - allOf([ - equals(newPersistentConnection), - isNot(equals(request.persistentConnection)), - ]), - ); - }); - }); -} diff --git a/test/extensions/response_test.dart b/test/extensions/response_test.dart deleted file mode 100644 index a4980ac..0000000 --- a/test/extensions/response_test.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:test/test.dart'; - -void main() { - late BaseResponse baseResponse; - late Response response; - - setUpAll(() { - baseResponse = Response( - "{'foo': 'bar'}", - 200, - headers: {'some_header': 'header_value'}, - ); - response = baseResponse as Response; - }); - - group('BaseResponse.copyWith: ', () { - test('Response is copied from BaseResponse', () { - // Arrange - final copiedBaseRequest = baseResponse.copyWith(); - - // Act - final copied = copiedBaseRequest as Response; - - // Assert - expect(copied.hashCode, isNot(equals(response.hashCode))); - expect(copied.statusCode, equals(response.statusCode)); - expect(copied.body, equals(response.body)); - expect(copied.headers, equals(response.headers)); - expect(copied.isRedirect, equals(response.isRedirect)); - expect(copied.reasonPhrase, equals(response.reasonPhrase)); - expect( - copied.persistentConnection, - equals(response.persistentConnection), - ); - }); - }); -} diff --git a/test/extensions/string_test.dart b/test/extensions/string_test.dart deleted file mode 100644 index c08e2ea..0000000 --- a/test/extensions/string_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:http_interceptor/extensions/string.dart'; -import 'package:test/test.dart'; - -void main() { - group("toUri extension", () { - test("Can convert string to https Uri", () { - // Arrange - String stringUrl = "https://www.google.com/helloworld"; - // Act - Uri convertedUri = stringUrl.toUri(); - // Assert - Uri expectedUrl = Uri.https("www.google.com", "/helloworld"); - - expect(convertedUri, equals(expectedUrl)); - }); - - test("Can convert string to http Uri", () { - // Arrange - String stringUrl = "http://www.google.com/helloworld"; - // Act - Uri convertedUri = stringUrl.toUri(); - // Assert - Uri expectedUrl = Uri.http("www.google.com", "/helloworld"); - - expect(convertedUri, equals(expectedUrl)); - }); - - test("Can convert string to http Uri", () { - // Arrange - String stringUrl = "path/to/helloworld"; - // Act - Uri convertedUri = stringUrl.toUri(); - // Assert - Uri expectedUrl = Uri.file("path/to/helloworld"); - - expect(convertedUri, equals(expectedUrl)); - }); - }); -} diff --git a/test/extensions/uri_test.dart b/test/extensions/uri_test.dart deleted file mode 100644 index 2b41054..0000000 --- a/test/extensions/uri_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:http_interceptor/extensions/uri.dart'; -import 'package:test/test.dart'; - -void main() { - group("addParameters extension", () { - test("Add parameters to Uri without parameters", () { - // Arrange - String stringUrl = "https://www.google.com/helloworld"; - Map parameters = {"foo": "bar", "num": "0"}; - Uri url = Uri.parse(stringUrl); - - // Act - Uri parameterUri = url.addParameters(parameters); - - // Assert - Uri expectedUrl = Uri.https("www.google.com", "/helloworld", parameters); - expect(parameterUri, equals(expectedUrl)); - }); - test("Add parameters to Uri 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 = url.addParameters(otherParameters); - - // Assert - Map allParameters = {"foo": "bar", "num": "0"}; - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - allParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }); - test("Add parameters with array to Uri Url without parameters", () { - // Arrange - String stringUrl = "https://www.google.com/helloworld"; - Map parameters = { - "foo": "bar", - "num": ["0", "1"], - }; - Uri url = Uri.parse(stringUrl); - - // Act - Uri parameterUri = url.addParameters(parameters); - - // Assert - Uri expectedUrl = Uri.https("www.google.com", "/helloworld", parameters); - expect(parameterUri, equals(expectedUrl)); - }); - test("Add parameters to Uri Url with array parameters", () { - // Arrange - String authority = "www.google.com"; - String unencodedPath = "/helloworld"; - Map someParameters = { - "foo": ["bar", "bar1"], - }; - Map otherParameters = {"num": "0"}; - Uri url = Uri.https(authority, unencodedPath, someParameters); - - // Act - Uri parameterUri = url.addParameters(otherParameters); - - // Assert - Map allParameters = { - "foo": ["bar", "bar1"], - "num": "0", - }; - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - allParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }); - test("Add non-string parameters to Uri without parameters", () { - // Arrange - String stringUrl = "https://www.google.com/helloworld"; - Map expectedParameters = {"foo": "bar", "num": "1"}; - Map parameters = {"foo": "bar", "num": 1}; - Uri url = Uri.parse(stringUrl); - - // Act - Uri parameterUri = url.addParameters(parameters); - - // Assert - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - expectedParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }); - test("Add non-string parameters to Uri 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 = url.addParameters(otherParameters); - - // Assert - Map allParameters = {"foo": "bar", "num": "0"}; - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - allParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }); - test( - "Add non-string parameters with array to Uri Url without parameters", - () { - // Arrange - String stringUrl = "https://www.google.com/helloworld"; - Map expectedParameters = { - "foo": "bar", - "num": ["0", "1"], - }; - Map parameters = { - "foo": "bar", - "num": ["0", 1], - }; - Uri url = Uri.parse(stringUrl); - - // Act - Uri parameterUri = url.addParameters(parameters); - - // Assert - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - expectedParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }, - ); - test("Add non-string parameters to Uri Url with array parameters", () { - // Arrange - String authority = "www.google.com"; - String unencodedPath = "/helloworld"; - Map someParameters = { - "foo": ["bar", "bar1"], - }; - Map otherParameters = { - "num": "0", - "num2": 1, - "num3": ["3", 2], - }; - Uri url = Uri.https(authority, unencodedPath, someParameters); - - // Act - Uri parameterUri = url.addParameters(otherParameters); - - // Assert - Map expectedParameters = { - "foo": ["bar", "bar1"], - "num": "0", - "num2": "1", - "num3": ["3", "2"], - }; - Uri expectedUrl = Uri.https( - "www.google.com", - "/helloworld", - expectedParameters, - ); - expect(parameterUri, equals(expectedUrl)); - }); - }); -} diff --git a/test/http/http_methods_test.dart b/test/http/http_methods_test.dart deleted file mode 100644 index 173700a..0000000 --- a/test/http/http_methods_test.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'package:http_interceptor/http/http_methods.dart'; -import 'package:test/test.dart'; - -void main() { - group("Can parse from string", () { - test("with HEAD method", () { - // Arrange - late final HttpMethod method; - final String methodString = "HEAD"; - - // Act - method = HttpMethod.fromString(methodString); - - // Assert - expect(method, equals(HttpMethod.HEAD)); - }); - test("with GET method", () { - // Arrange - late final HttpMethod method; - final String methodString = "GET"; - - // Act - method = HttpMethod.fromString(methodString); - - // Assert - expect(method, equals(HttpMethod.GET)); - }); - test("with POST method", () { - // Arrange - late final HttpMethod method; - final String methodString = "POST"; - - // Act - method = HttpMethod.fromString(methodString); - - // Assert - expect(method, equals(HttpMethod.POST)); - }); - test("with PUT method", () { - // Arrange - late final HttpMethod method; - final String methodString = "PUT"; - - // Act - method = HttpMethod.fromString(methodString); - - // Assert - expect(method, equals(HttpMethod.PUT)); - }); - test("with PATCH method", () { - // Arrange - late final HttpMethod method; - final String methodString = "PATCH"; - - // Act - method = HttpMethod.fromString(methodString); - - // Assert - expect(method, equals(HttpMethod.PATCH)); - }); - test("with DELETE method", () { - // Arrange - late final HttpMethod method; - final String methodString = "DELETE"; - - // Act - 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 - final String methodString; - final HttpMethod method = HttpMethod.HEAD; - - // Act - methodString = method.asString; - - // Assert - expect(methodString, equals("HEAD")); - }); - test("to 'GET' string.", () { - // Arrange - final String methodString; - final HttpMethod method = HttpMethod.GET; - - // Act - methodString = method.asString; - - // Assert - expect(methodString, equals("GET")); - }); - test("to 'POST' string.", () { - // Arrange - final String methodString; - final HttpMethod method = HttpMethod.POST; - - // Act - methodString = method.asString; - - // Assert - expect(methodString, equals("POST")); - }); - test("to 'PUT' string.", () { - // Arrange - final String methodString; - final HttpMethod method = HttpMethod.PUT; - - // Act - methodString = method.asString; - - // Assert - expect(methodString, equals("PUT")); - }); - test("to 'PATCH' string.", () { - // Arrange - final String methodString; - final HttpMethod method = HttpMethod.PATCH; - - // Act - methodString = method.asString; - - // Assert - expect(methodString, equals("PATCH")); - }); - test("to 'DELETE' string.", () { - // Arrange - final String methodString; - final HttpMethod method = HttpMethod.DELETE; - - // Act - methodString = method.asString; - - // 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 - final String methodString = "UNSUPPORTED"; - - // Act - // Assert - expect(() => 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 deleted file mode 100644 index 8ea1662..0000000 --- a/test/http/intercepted_client_error_test.dart +++ /dev/null @@ -1,210 +0,0 @@ -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 deleted file mode 100644 index 2aafbb9..0000000 --- a/test/http/intercepted_client_test.dart +++ /dev/null @@ -1,653 +0,0 @@ -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/models/retry_policy_test.dart b/test/models/retry_policy_test.dart deleted file mode 100644 index 4c19bad..0000000 --- a/test/models/retry_policy_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:http_interceptor/http_interceptor.dart'; -import 'package:test/test.dart'; - -void main() { - late RetryPolicy testObject; - - setUp(() { - testObject = TestRetryPolicy(); - }); - - group("maxRetryAttempts", () { - test("defaults to 1", () { - expect(testObject.maxRetryAttempts, 1); - }); - - test("can be overridden", () { - testObject = TestRetryPolicy(maxRetryAttempts: 5); - - expect(testObject.maxRetryAttempts, 5); - }); - }); - - group("delayRetryAttemptOnException", () { - test("returns no delay by default", () async { - // Act - final result = testObject.delayRetryAttemptOnException(retryAttempt: 0); - - // Assert - expect(result, Duration.zero); - }); - }); - - group("delayRetryAttemptOnResponse", () { - test("returns no delay by default", () async { - // Act - final result = testObject.delayRetryAttemptOnResponse(retryAttempt: 0); - - // Assert - expect(result, Duration.zero); - }); - }); - - group("shouldAttemptRetryOnException", () { - test("returns false by default", () async { - expect( - await testObject.shouldAttemptRetryOnException( - Exception("Test Exception."), - Request('GET', Uri()), - ), - false, - ); - }); - }); - - group("shouldAttemptRetryOnResponse", () { - test("returns false by default", () async { - expect( - await testObject.shouldAttemptRetryOnResponse(Response('', 200)), - false, - ); - }); - }); -} - -class TestRetryPolicy extends RetryPolicy { - TestRetryPolicy({int maxRetryAttempts = 1}) - : internalMaxRetryAttempts = maxRetryAttempts; - - final int internalMaxRetryAttempts; - - @override - int get maxRetryAttempts => internalMaxRetryAttempts; -} diff --git a/test/src/client/intercepted_client_test.dart b/test/src/client/intercepted_client_test.dart new file mode 100644 index 0000000..b409333 --- /dev/null +++ b/test/src/client/intercepted_client_test.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:test/test.dart'; + +void main() { + group('InterceptedClient', () { + test('send runs request then response interceptors', () async { + // arrange + var requestSeen = false; + var responseSeen = false; + final client = InterceptedClient.build( + interceptors: [ + _Interceptor( + onRequest: (r) { + requestSeen = true; + return r; + }, + onResponse: (r) { + responseSeen = true; + return r; + }, + ), + ], + client: _FakeClient(Response('ok', 200)), + ); + + // act + final response = await client.get(Uri.parse('https://example.com/')); + + // assert + expect(response.body, 'ok'); + expect(requestSeen, true); + expect(responseSeen, true); + + client.close(); + }); + + test('get with params merges into url', () async { + // arrange + Uri? capturedUrl; + final client = InterceptedClient.build( + interceptors: [ + _Interceptor( + onRequest: (r) { + capturedUrl = r.url; + return r; + }, + ), + ], + client: _FakeClient(Response('', 200)), + ); + + // act + await client.get( + Uri.parse('https://example.com/path'), + params: {'a': '1', 'b': '2'}, + ); + + // assert + expect(capturedUrl?.queryParameters['a'], '1'); + expect(capturedUrl?.queryParameters['b'], '2'); + + client.close(); + }); + }); +} + +class _FakeClient implements Client { + _FakeClient(this._response); + + final Response _response; + + @override + void close() {} + + @override + Future delete( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => Future.value(_response); + + @override + Future get(Uri url, {Map? headers}) => + Future.value(_response); + + @override + Future head(Uri url, {Map? headers}) => + Future.value(_response); + + @override + Future patch( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => Future.value(_response); + + @override + Future post( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => Future.value(_response); + + @override + Future put( + Uri url, { + Map? headers, + Object? body, + Encoding? encoding, + }) => Future.value(_response); + + @override + Future read(Uri url, {Map? headers}) => + Future.value(_response.body); + + @override + Future readBytes(Uri url, {Map? headers}) => + Future.value(_response.bodyBytes); + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(_response.bodyBytes), + _response.statusCode, + request: request, + headers: _response.headers, + ); + } +} + +class _Interceptor implements HttpInterceptor { + _Interceptor({this.onRequest, this.onResponse}); + + final BaseRequest Function(BaseRequest)? onRequest; + final BaseResponse Function(BaseResponse)? onResponse; + + @override + BaseRequest interceptRequest({required BaseRequest request}) => + onRequest != null ? onRequest!(request) : request; + + @override + BaseResponse interceptResponse({required BaseResponse response}) => + onResponse != null ? onResponse!(response) : response; + + @override + bool shouldInterceptRequest({required BaseRequest request}) => true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => true; +} diff --git a/test/src/extensions/string_extension_test.dart b/test/src/extensions/string_extension_test.dart new file mode 100644 index 0000000..8683887 --- /dev/null +++ b/test/src/extensions/string_extension_test.dart @@ -0,0 +1,20 @@ +import 'package:http_interceptor/src/extensions/string_extension.dart'; +import 'package:test/test.dart'; + +void main() { + group('StringToUri', () { + test('toUri parses string as Uri', () { + // arrange + const url = 'https://example.com/path?x=1'; + + // act + final uri = url.toUri(); + + // assert + expect(uri.scheme, 'https'); + expect(uri.host, 'example.com'); + expect(uri.path, '/path'); + expect(uri.queryParameters['x'], '1'); + }); + }); +} diff --git a/test/src/interceptor/interceptor_chain_test.dart b/test/src/interceptor/interceptor_chain_test.dart new file mode 100644 index 0000000..05dbe94 --- /dev/null +++ b/test/src/interceptor/interceptor_chain_test.dart @@ -0,0 +1,164 @@ +import 'package:http/http.dart'; +import 'package:http_interceptor/src/interceptor/http_interceptor.dart'; +import 'package:http_interceptor/src/interceptor/interceptor_chain.dart'; +import 'package:test/test.dart'; + +void main() { + group('InterceptorChain', () { + test('runRequestInterceptors runs interceptors in order', () async { + // arrange + final request = Request('GET', Uri.parse('https://example.com/')); + final order = []; + final chain = InterceptorChain([ + _Interceptor((r) { + order.add(1); + return r; + }), + _Interceptor((r) { + order.add(2); + return r; + }), + ]); + + // act + await chain.runRequestInterceptors(request); + + // assert + expect(order, [1, 2]); + }); + + test('runRequestInterceptors passes result of one to next', () async { + // arrange + final request = Request('GET', Uri.parse('https://example.com/')); + final modified = Request('POST', Uri.parse('https://other.com/')); + final chain = InterceptorChain([_Interceptor((_) => modified)]); + + // act + final result = await chain.runRequestInterceptors(request); + + // assert + expect(result.method, 'POST'); + expect(result.url.toString(), 'https://other.com/'); + }); + + test( + 'runRequestInterceptors skips only that interceptor when shouldInterceptRequest is false', + () async { + // arrange + final request = Request('GET', Uri.parse('https://example.com/')); + var firstCalled = false; + var secondCalled = false; + final chain = InterceptorChain([ + _Interceptor((r) { + firstCalled = true; + return r; + }, shouldInterceptRequest: () => false), + _Interceptor((r) { + secondCalled = true; + return r; + }), + ]); + + // act + await chain.runRequestInterceptors(request); + + // assert + expect(firstCalled, false); + expect(secondCalled, true); + }, + ); + + test('runResponseInterceptors runs interceptors in order', () async { + // arrange + final response = Response('body', 200); + final order = []; + final chain = InterceptorChain([ + _Interceptor( + (r) => r, + onResponse: (res) { + order.add(1); + return res; + }, + ), + _Interceptor( + (r) => r, + onResponse: (res) { + order.add(2); + return res; + }, + ), + ]); + + // act + await chain.runResponseInterceptors(response); + + // assert + expect(order, [1, 2]); + }); + + test( + 'runResponseInterceptors skips only that interceptor when shouldInterceptResponse is false', + () async { + // arrange + final response = Response('body', 200); + var firstCalled = false; + var secondCalled = false; + final chain = InterceptorChain([ + _Interceptor( + (r) => r, + onResponse: (r) { + firstCalled = true; + return r; + }, + shouldInterceptResponse: () => false, + ), + _Interceptor( + (r) => r, + onResponse: (res) { + secondCalled = true; + return res; + }, + ), + ]); + + // act + await chain.runResponseInterceptors(response); + + // assert + expect(firstCalled, false); + expect(secondCalled, true); + }, + ); + }); +} + +class _Interceptor implements HttpInterceptor { + _Interceptor( + this._onRequest, { + this.onResponse, + bool Function()? shouldInterceptRequest, + bool Function()? shouldInterceptResponse, + }) : _shouldRequest = shouldInterceptRequest, + _shouldResponse = shouldInterceptResponse; + + final BaseRequest Function(BaseRequest) _onRequest; + final BaseResponse Function(BaseResponse)? onResponse; + final bool Function()? _shouldRequest; + final bool Function()? _shouldResponse; + + @override + BaseRequest interceptRequest({required BaseRequest request}) => + _onRequest(request); + + @override + BaseResponse interceptResponse({required BaseResponse response}) => + onResponse != null ? onResponse!(response) : response; + + @override + bool shouldInterceptRequest({required BaseRequest request}) => + _shouldRequest?.call() ?? true; + + @override + bool shouldInterceptResponse({required BaseResponse response}) => + _shouldResponse?.call() ?? true; +} diff --git a/test/src/request_response/response_extension_test.dart b/test/src/request_response/response_extension_test.dart new file mode 100644 index 0000000..e96654d --- /dev/null +++ b/test/src/request_response/response_extension_test.dart @@ -0,0 +1,120 @@ +import 'package:http/http.dart'; +import 'package:http_interceptor/src/extensions/response_extension.dart'; +import 'package:test/test.dart'; + +void main() { + group('ResponseBodyDecoding', () { + test('jsonBody returns null for empty body', () { + // arrange + final res = Response('', 200); + + // act + final decoded = res.jsonBody; + + // assert + expect(decoded, isNull); + }); + + test('jsonBody decodes JSON object', () { + // arrange + final res = Response('{"a":1}', 200); + + // act + final decoded = res.jsonBody; + + // assert + expect(decoded, isA>()); + expect(decoded, {'a': 1}); + }); + + test('jsonBody decodes JSON array', () { + // arrange + final res = Response('[1,2,3]', 200); + + // act + final decoded = res.jsonBody; + + // assert + expect(decoded, [1, 2, 3]); + }); + + test('jsonMap decodes JSON object', () { + // arrange + final res = Response('{"a":1}', 200); + + // act + final decoded = res.jsonMap; + + // assert + expect(decoded, {'a': 1}); + }); + + test('jsonMap throws when JSON is an array', () { + // arrange + final res = Response('[1,2,3]', 200); + + // act + Map act() => res.jsonMap; + + // assert + expect(act, throwsA(isA())); + }); + + test('jsonList decodes JSON array', () { + // arrange + final res = Response('[1,2,3]', 200); + + // act + final decoded = res.jsonList; + + // assert + expect(decoded, [1, 2, 3]); + }); + + test('jsonList throws when JSON is an object', () { + // arrange + final res = Response('{"a":1}', 200); + + // act + List act() => res.jsonList; + + // assert + expect(act, throwsA(isA())); + }); + + test('tryJsonBody returns null on invalid JSON', () { + // arrange + final res = Response('not json', 200); + + // act + final decoded = res.tryJsonBody(); + + // assert + expect(decoded, isNull); + }); + + test('tryJsonBody returns decoded value on valid JSON', () { + // arrange + final res = Response('{"a":1}', 200); + + // act + final decoded = res.tryJsonBody(); + + // assert + expect(decoded, {'a': 1}); + }); + + test('decodeJson maps decoded JSON', () { + // arrange + final res = Response('{"a":1}', 200); + + // act + final value = res.decodeJson( + (json) => (json as Map)['a'], + ); + + // assert + expect(value, 1); + }); + }); +} diff --git a/test/src/request_response/uri_extension_test.dart b/test/src/request_response/uri_extension_test.dart new file mode 100644 index 0000000..43a1e67 --- /dev/null +++ b/test/src/request_response/uri_extension_test.dart @@ -0,0 +1,57 @@ +import 'package:http_interceptor/src/extensions/uri_extension.dart'; +import 'package:test/test.dart'; + +void main() { + group('UriQueryParams', () { + test('addQueryParams with params merges into empty', () { + // arrange + final uri = Uri.parse('https://example.com/path'); + + // act + final result = uri.addQueryParams(params: {'a': '1', 'b': '2'}); + + // assert + expect(result.queryParameters, {'a': '1', 'b': '2'}); + }); + + test('addQueryParams with paramsAll adds multiple values per key', () { + // arrange + final uri = Uri.parse('https://example.com/path'); + + // act + final result = uri.addQueryParams( + paramsAll: { + 'x': ['1', '2'], + 'y': ['3'], + }, + ); + + // assert + expect(result.queryParametersAll['x'], ['1', '2']); + expect(result.queryParametersAll['y'], ['3']); + }); + + test('addQueryParams merges with existing query', () { + // arrange + final uri = Uri.parse('https://example.com/path?foo=bar'); + + // act + final result = uri.addQueryParams(params: {'a': '1'}); + + // assert + expect(result.queryParameters['foo'], 'bar'); + expect(result.queryParameters['a'], '1'); + }); + + test('addQueryParams with null params and paramsAll returns same uri', () { + // arrange + final uri = Uri.parse('https://example.com/path?x=1'); + + // act + final result = uri.addQueryParams(); + + // assert + expect(result, uri); + }); + }); +} diff --git a/test/src/retry/retry_executor_test.dart b/test/src/retry/retry_executor_test.dart new file mode 100644 index 0000000..611e965 --- /dev/null +++ b/test/src/retry/retry_executor_test.dart @@ -0,0 +1,143 @@ +import 'package:http/http.dart'; +import 'package:http_interceptor/src/retry/retry_executor.dart'; +import 'package:http_interceptor/src/retry/retry_policy.dart'; +import 'package:test/test.dart'; + +void main() { + group('executeWithRetry', () { + test( + 'returns response when policy says do not retry on response', + () async { + // arrange + var attempts = 0; + + // act + final result = await executeWithRetry( + policy: _Policy(maxRetryAttempts: 2, onResponse: (_) => false), + request: Request('GET', Uri.parse('https://example.com/')), + attempt: () async { + attempts++; + return StreamedResponse( + Stream.empty(), + 200, + request: Request('GET', Uri.parse('https://example.com/')), + ); + }, + ); + + // assert + expect(result.statusCode, 200); + expect(attempts, 1); + }, + ); + + test( + 'retries when policy says retry on response until maxAttempts', + () async { + // arrange + var attempts = 0; + + // act + final result = await executeWithRetry( + policy: _Policy( + maxRetryAttempts: 2, + onResponse: (r) => r.statusCode == 500, + ), + request: Request('GET', Uri.parse('https://example.com/')), + attempt: () async { + attempts++; + if (attempts < 3) { + return StreamedResponse( + Stream.empty(), + 500, + request: Request('GET', Uri.parse('https://example.com/')), + ); + } + return StreamedResponse( + Stream.empty(), + 200, + request: Request('GET', Uri.parse('https://example.com/')), + ); + }, + ); + + // assert + expect(result.statusCode, 200); + expect(attempts, 3); + }, + ); + + test('retries on exception when policy says so', () async { + // arrange + var attempts = 0; + + // act + final result = await executeWithRetry( + policy: _Policy(maxRetryAttempts: 2, onException: () => true), + request: Request('GET', Uri.parse('https://example.com/')), + attempt: () async { + attempts++; + if (attempts < 2) throw Exception('network'); + return StreamedResponse( + Stream.empty(), + 200, + request: Request('GET', Uri.parse('https://example.com/')), + ); + }, + ); + + // assert + expect(result.statusCode, 200); + expect(attempts, 2); + }); + + test('rethrows when policy says do not retry on exception', () async { + // arrange + var attempts = 0; + + // act + Future act() => executeWithRetry( + policy: _Policy(maxRetryAttempts: 1, onException: () => false), + request: Request('GET', Uri.parse('https://example.com/')), + attempt: () async { + attempts++; + throw Exception('network'); + }, + ); + + // assert + expect(act, throwsA(isA())); + expect(attempts, 1); + }); + }); +} + +class _Policy implements RetryPolicy { + _Policy({ + required this.maxRetryAttempts, + bool Function(BaseResponse)? onResponse, + bool Function()? onException, + }) : _onResponse = onResponse ?? ((_) => false), + _onException = onException ?? (() => false); + + @override + final int maxRetryAttempts; + final bool Function(BaseResponse) _onResponse; + final bool Function() _onException; + + @override + bool shouldAttemptRetryOnException(Exception reason, BaseRequest request) => + _onException(); + + @override + bool shouldAttemptRetryOnResponse(BaseResponse response) => + _onResponse(response); + + @override + Duration delayRetryAttemptOnException({required int retryAttempt}) => + Duration.zero; + + @override + Duration delayRetryAttemptOnResponse({required int retryAttempt}) => + Duration.zero; +} diff --git a/test/utils/utils_test.dart b/test/utils/utils_test.dart deleted file mode 100644 index 7b4d7ed..0000000 --- a/test/utils/utils_test.dart +++ /dev/null @@ -1,266 +0,0 @@ -import 'package:http_interceptor/utils/utils.dart'; -import 'package:test/test.dart'; - -void main() { - group("buildUrlString", () { - test("Adds parameters to a URL string without parameters", () { - // Arrange - final String url = "https://www.google.com/helloworld"; - final Map parameters = {"foo": "bar", "num": "0"}; - - // Act - final String parameterUrl = buildUrlString(url, parameters); - - // Assert - expect( - parameterUrl, - equals("https://www.google.com/helloworld?foo=bar&num=0"), - ); - }); - - test("Adds parameters to a URL string with parameters", () { - // Arrange - final String url = "https://www.google.com/helloworld?foo=bar&num=0"; - final Map parameters = { - "extra": "1", - "extra2": "anotherone", - }; - - // Act - final String parameterUrl = buildUrlString(url, parameters); - - // Assert - expect( - parameterUrl, - equals( - "https://www.google.com/helloworld?foo=bar&num=0&extra=1&extra2=anotherone", - ), - ); - }); - - test("Adds parameters with array to a URL string without parameters", () { - // Arrange - final String url = "https://www.google.com/helloworld"; - final Map parameters = { - "foo": "bar", - "num": ["0", "1"], - }; - - // Act - final String parameterUrl = buildUrlString(url, parameters); - - // Assert - expect( - parameterUrl, - equals("https://www.google.com/helloworld?foo=bar&num=0&num=1"), - ); - }); - - test("Null parameters returns original URL", () { - final url = "https://example.com/path"; - expect(buildUrlString(url, null), equals(url)); - }); - - test("Empty parameters returns original URL", () { - final url = "https://example.com/path"; - expect(buildUrlString(url, {}), equals(url)); - }); - - test("Null parameter value becomes empty assignment", () { - final url = "https://example.com/path"; - final params = {"a": null}; - expect( - buildUrlString(url, params), - equals("https://example.com/path?a="), - ); - }); - - test("Overrides existing parameter", () { - final url = "https://example.com/path?foo=bar"; - final params = {"foo": "baz", "x": "y"}; - expect( - buildUrlString(url, params), - equals("https://example.com/path?foo=baz&x=y"), - ); - }); - - test("Preserves fragment without existing query", () { - final url = "https://example.com/path#section"; - final params = {"a": "1"}; - expect( - buildUrlString(url, params), - equals("https://example.com/path?a=1#section"), - ); - }); - - test("Preserves fragment with existing query", () { - final url = "https://example.com/path?foo=bar#section"; - final params = {"baz": "qux"}; - expect( - buildUrlString(url, params), - equals("https://example.com/path?foo=bar&baz=qux#section"), - ); - }); - - test("Invalid URL does not trigger concatenation fallback", () { - final url = "not a valid url"; - final params = {"a": "b"}; - expect(() => buildUrlString(url, params), throwsArgumentError); - }); - - test("Encodes special characters in keys and values", () { - final url = "https://example.com"; - final params = {"a b": "c d", "ä": "ö"}; - expect( - buildUrlString(url, params), - equals("https://example.com?a%20b=c%20d&%C3%A4=%C3%B6"), - ); - }); - - test("Numeric and boolean values are stringified", () { - final url = "https://example.com"; - final params = {"int": 42, "bool": true}; - expect( - buildUrlString(url, params), - equals("https://example.com?int=42&bool=true"), - ); - }); - - test("List parameter overrides existing singular key", () { - final url = "https://example.com/path?x=1"; - final params = { - "x": ["2", "3"], - }; - expect( - buildUrlString(url, params), - equals("https://example.com/path?x=2&x=3"), - ); - }); - - test('encodes a query string object (basic key/value)', () { - final String testUrl = 'https://example.com/path'; - expect(buildUrlString(testUrl, {'a': 'b'}), equals('$testUrl?a=b')); - expect(buildUrlString(testUrl, {'a': '1'}), equals('$testUrl?a=1')); - expect( - buildUrlString(testUrl, {'a': '1', 'b': '2'}), - equals('$testUrl?a=1&b=2'), - ); - expect(buildUrlString(testUrl, {'a': 'A_Z'}), equals('$testUrl?a=A_Z')); - }); - - test('encodes various unicode characters', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'a': '€'}), - equals('$testUrl?a=%E2%82%AC'), - ); - expect( - buildUrlString(testUrl, {'a': ''}), - equals('$testUrl?a=%EE%80%80'), - ); - expect(buildUrlString(testUrl, {'a': 'א'}), equals('$testUrl?a=%D7%90')); - expect( - buildUrlString(testUrl, {'a': '𐐷'}), - equals('$testUrl?a=%F0%90%90%B7'), - ); - }); - - test('increasing number of pairs', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'a': 'b', 'c': 'd'}), - equals('$testUrl?a=b&c=d'), - ); - expect( - buildUrlString(testUrl, {'a': 'b', 'c': 'd', 'e': 'f'}), - equals('$testUrl?a=b&c=d&e=f'), - ); - expect( - buildUrlString(testUrl, {'a': 'b', 'c': 'd', 'e': 'f', 'g': 'h'}), - equals('$testUrl?a=b&c=d&e=f&g=h'), - ); - }); - - test('list values get repeated keys', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, { - 'a': ['b', 'c', 'd'], - 'e': 'f', - }), - equals('$testUrl?a=b&a=c&a=d&e=f'), - ); - }); - - test('empty map yields no query string', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {}), - buildUrlString(testUrl, {}).toString(), - ); - }); - - test('single key with empty string value', () { - final String testUrl = 'https://example.com/path'; - expect(buildUrlString(testUrl, {'a': ''}), equals('$testUrl?a=')); - }); - - test('null value is not skipped', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'a': null, 'b': '2'}), - equals('$testUrl?a=&b=2'), - ); - }); - - test('keys with special characters are encoded', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'a b': 'c d'}), - equals('$testUrl?a%20b=c%20d'), - ); - expect( - buildUrlString(testUrl, {'ä': 'ö'}), - equals('$testUrl?%C3%A4=%C3%B6'), - ); - }); - - test('values containing reserved characters', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'q': 'foo@bar.com'}), - equals('$testUrl?q=foo%40bar.com'), - ); - expect( - buildUrlString(testUrl, {'path': '/home'}), - equals('$testUrl?path=%2Fhome'), - ); - }); - - test('plus sign and space in value', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'v': 'a+b c'}), - equals('$testUrl?v=a%2Bb%20c'), - ); - }); - - test('list values including numbers and empty strings', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, { - 'x': ['1', '', '3'], - }), - equals('$testUrl?x=1&x=&x=3'), - ); - }); - - test('multiple keys maintain insertion order', () { - final String testUrl = 'https://example.com/path'; - expect( - buildUrlString(testUrl, {'first': '1', 'second': '2', 'third': '3'}), - equals('$testUrl?first=1&second=2&third=3'), - ); - }); - }); -}