diff --git a/chopper/lib/chopper.dart b/chopper/lib/chopper.dart index 38fd56aa..a2b503ab 100644 --- a/chopper/lib/chopper.dart +++ b/chopper/lib/chopper.dart @@ -7,11 +7,13 @@ export 'src/annotations.dart'; export 'src/authenticator.dart'; export 'src/base.dart'; export 'src/chopper_http_exception.dart'; +export 'src/chopper_exception.dart'; export 'src/chopper_log_record.dart'; export 'src/constants.dart'; export 'src/extensions.dart'; -export 'src/http_logging_interceptor.dart'; -export 'src/interceptor.dart'; +export 'src/chain/chain.dart'; +export 'src/interceptors/interceptor.dart'; +export 'src/converters.dart'; export 'src/list_format.dart'; export 'src/request.dart'; export 'src/response.dart'; diff --git a/chopper/lib/src/annotations.dart b/chopper/lib/src/annotations.dart index 899b5ecb..1f10db7f 100644 --- a/chopper/lib/src/annotations.dart +++ b/chopper/lib/src/annotations.dart @@ -376,7 +376,7 @@ typedef ConvertRequest = FutureOr Function(Request request); /// A function that should convert the body of a [Response] from the HTTP /// representation to a Dart object. -typedef ConvertResponse = FutureOr Function(Response response); +typedef ConvertResponse = FutureOr> Function(Response response); /// {@template FactoryConverter} /// Defines custom [Converter] methods for a single network API endpoint. diff --git a/chopper/lib/src/authenticator.dart b/chopper/lib/src/authenticator.dart index 1d0ba176..5845ba67 100644 --- a/chopper/lib/src/authenticator.dart +++ b/chopper/lib/src/authenticator.dart @@ -33,7 +33,7 @@ abstract class Authenticator { /// Returns a [Request] that includes credentials to satisfy /// an authentication challenge received in [response], based on /// the incoming [request] or optionally, the [originalRequest] - /// (which was not modified with any previous [RequestInterceptor]s). + /// (which was not modified with any previous [Interceptor]s). /// /// Otherwise, return `null` if the challenge cannot be satisfied. /// diff --git a/chopper/lib/src/base.dart b/chopper/lib/src/base.dart index 066f9cc7..d917f081 100644 --- a/chopper/lib/src/base.dart +++ b/chopper/lib/src/base.dart @@ -2,24 +2,15 @@ import 'dart:async'; import 'package:chopper/src/annotations.dart'; import 'package:chopper/src/authenticator.dart'; +import 'package:chopper/src/chain/call.dart'; import 'package:chopper/src/constants.dart'; -import 'package:chopper/src/interceptor.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; import 'package:chopper/src/request.dart'; import 'package:chopper/src/response.dart'; -import 'package:chopper/src/utils.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -@visibleForTesting -const List allowedInterceptorsType = [ - RequestInterceptor, - RequestInterceptorFunc, - ResponseInterceptor, - ResponseInterceptorFunc1, - ResponseInterceptorFunc2, - DynamicResponseInterceptorFunc, -]; - /// ChopperClient is the main class of the Chopper API. /// /// It manages registered services, encodes and decodes data, and intercepts @@ -46,8 +37,7 @@ base class ChopperClient { final ErrorConverter? errorConverter; late final Map _services; - late final List _requestInterceptors; - late final List _responseInterceptors; + late final List interceptors; final StreamController _requestController = StreamController.broadcast(); final StreamController _responseController = @@ -79,11 +69,10 @@ base class ChopperClient { /// ); /// ``` /// - /// [RequestInterceptor]s and [ResponseInterceptor]s can be added to the client + /// [Interceptor]s can be added to the client /// with the [interceptors] parameter. /// - /// See [RequestInterceptor], [ResponseInterceptor], [HttpLoggingInterceptor], - /// [HeadersInterceptor], [CurlInterceptor] + /// See [HttpLoggingInterceptor], [HeadersInterceptor], [CurlInterceptor] /// /// ```dart /// final chopper = ChopperClient( @@ -114,7 +103,7 @@ base class ChopperClient { ChopperClient({ Uri? baseUrl, http.Client? client, - Iterable? interceptors, + this.interceptors = const [], this.authenticator, this.converter, this.errorConverter, @@ -126,36 +115,13 @@ base class ChopperClient { ), baseUrl = baseUrl ?? Uri(), httpClient = client ?? http.Client(), - _clientIsInternal = client == null, - assert( - interceptors?.every(_isAnInterceptor) ?? true, - 'Unsupported type for interceptors, it only support the following types:\n' - ' - ${allowedInterceptorsType.join('\n - ')}', - ), - _requestInterceptors = [ - ...?interceptors?.where(_isRequestInterceptor), - ], - _responseInterceptors = [ - ...?interceptors?.where(_isResponseInterceptor), - ] { + _clientIsInternal = client == null { _services = { for (final ChopperService service in services?.toSet() ?? []) service.definitionType: service..client = this }; } - static bool _isRequestInterceptor(value) => - value is RequestInterceptor || value is RequestInterceptorFunc; - - static bool _isResponseInterceptor(value) => - value is ResponseInterceptor || - value is ResponseInterceptorFunc1 || - value is ResponseInterceptorFunc2 || - value is DynamicResponseInterceptorFunc; - - static bool _isAnInterceptor(value) => - _isResponseInterceptor(value) || _isRequestInterceptor(value); - /// Retrieve any service included in the [ChopperClient] /// /// ```dart @@ -183,100 +149,6 @@ base class ChopperClient { return service as ServiceType; } - Future _encodeRequest(Request request) async => - converter?.convertRequest(request) ?? request; - - static Future> _decodeResponse( - Response response, - Converter withConverter, - ) async => - await withConverter.convertResponse(response); - - Future _interceptRequest(Request req) async { - final body = req.body; - for (final i in _requestInterceptors) { - if (i is RequestInterceptor) { - req = await i.onRequest(req); - } else if (i is RequestInterceptorFunc) { - req = await i(req); - } - } - - assert( - body == req.body, - 'Interceptors should not transform the body of the request' - 'Use Request converter instead', - ); - - return req; - } - - Future> _interceptResponse( - Response res, - ) async { - final body = res.body; - for (final i in _responseInterceptors) { - if (i is ResponseInterceptor) { - res = await i.onResponse(res) as Response; - } else if (i is ResponseInterceptorFunc1) { - res = await i(res); - } else if (i is ResponseInterceptorFunc2) { - res = await i(res); - } else if (i is DynamicResponseInterceptorFunc) { - res = await i(res) as Response; - } - } - - assert( - body == res.body, - 'Interceptors should not transform the body of the response' - 'Use Response converter instead', - ); - - return res; - } - - Future> _handleErrorResponse( - Response response, - ) async { - var error = response.body; - if (errorConverter != null) { - final errorRes = await errorConverter?.convertError( - response, - ); - error = errorRes?.error ?? errorRes?.body; - } - - return Response(response.base, null, error: error); - } - - Future> _handleSuccessResponse( - Response response, - ConvertResponse? responseConverter, - ) async { - if (responseConverter != null) { - response = await responseConverter(response); - } else if (converter != null) { - response = - await _decodeResponse(response, converter!); - } - - return Response( - response.base, - response.body, - ); - } - - Future _handleRequestConverter( - Request request, - ConvertRequest? requestConverter, - ) async => - request.body != null || request.parts.isNotEmpty - ? requestConverter != null - ? await requestConverter(request) - : await _encodeRequest(request) - : request; - /// Sends a pre-build [Request], applying all provided [Interceptor]s and /// [Converter]s. /// @@ -292,63 +164,22 @@ base class ChopperClient { Future> send( Request request, { ConvertRequest? requestConverter, - ConvertResponse? responseConverter, + ConvertResponse? responseConverter, }) async { - final Request req = await _interceptRequest( - await _handleRequestConverter(request, requestConverter), + final call = Call( + request: request, + client: this, + requestCallback: _requestController.add, ); - _requestController.add(req); - - final streamRes = await httpClient.send(await req.toBaseRequest()); - if (isTypeOf>>()) { - return Response(streamRes, (streamRes.stream) as BodyType); - } - - final response = await http.Response.fromStream(streamRes); - dynamic res = Response(response, response.body); - - if (authenticator != null) { - final Request? updatedRequest = - await authenticator!.authenticate(req, res, request); - - if (updatedRequest != null) { - res = await send( - updatedRequest, - requestConverter: requestConverter, - responseConverter: responseConverter, - ); - // To prevent double call with typed response - if (_responseIsSuccessful(res.statusCode)) { - await authenticator!.onAuthenticationSuccessful - ?.call(updatedRequest, res, request); - return _processResponse(res); - } else { - res = await _handleErrorResponse(res); - await authenticator!.onAuthenticationFailed - ?.call(updatedRequest, res, request); - return _processResponse(res); - } - } - } - - res = _responseIsSuccessful(res.statusCode) - ? await _handleSuccessResponse( - res, - responseConverter, - ) - : await _handleErrorResponse(res); - - return _processResponse(res); - } + final response = await call.execute( + requestConverter, + responseConverter, + ); - Future> _processResponse( - dynamic res, - ) async { - res = await _interceptResponse(res); - _responseController.add(res); + _responseController.add(response); - return res; + return response; } /// Makes a HTTP GET request using the [send] function. @@ -501,20 +332,17 @@ base class ChopperClient { _responseController.close(); _services.clear(); - _requestInterceptors.clear(); - _responseInterceptors.clear(); - if (_clientIsInternal) { httpClient.close(); } } /// A stream of processed [Request]s, as in after all [Converter]s, and - /// [RequestInterceptor]s have been run. + /// [Interceptor]s have been run. Stream get onRequest => _requestController.stream; /// A stream of processed [Response]s, as in after all [Converter]s and - /// [ResponseInterceptor]s have been run. + /// [Interceptor]s have been run. Stream get onResponse => _responseController.stream; } @@ -548,6 +376,3 @@ abstract class ChopperService { // TODO: use runtimeType Type get definitionType; } - -bool _responseIsSuccessful(int statusCode) => - statusCode >= 200 && statusCode < 300; diff --git a/chopper/lib/src/chain/call.dart b/chopper/lib/src/chain/call.dart new file mode 100644 index 00000000..4bb277a7 --- /dev/null +++ b/chopper/lib/src/chain/call.dart @@ -0,0 +1,58 @@ +import 'package:chopper/src/annotations.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/chain/interceptor_chain.dart'; +import 'package:chopper/src/interceptors/authenticator_interceptor.dart'; +import 'package:chopper/src/interceptors/http_call_interceptor.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/interceptors/request_converter_interceptor.dart'; +import 'package:chopper/src/interceptors/request_stream_interceptor.dart'; +import 'package:chopper/src/interceptors/response_converter_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// {@template Call} +/// A single call to a HTTP endpoint. It holds the [request] and the [client]. +/// {@endtemplate} +class Call { + /// {@macro Call} + Call({ + required this.request, + required this.client, + required this.requestCallback, + }); + + /// Request to be executed. + final Request request; + + /// Chopper client that created this call. + final ChopperClient client; + + /// Callback to send intercepted and converted request to the stream controller. + final void Function(Request event) requestCallback; + + Future> execute( + ConvertRequest? requestConverter, + ConvertResponse? responseConverter, + ) async { + final interceptors = [ + RequestConverterInterceptor(client.converter, requestConverter), + ...client.interceptors, + RequestStreamInterceptor(requestCallback), + if (client.authenticator != null) + AuthenticatorInterceptor(client.authenticator!), + ResponseConverterInterceptor( + converter: client.converter, + errorConverter: client.errorConverter, + responseConverter: responseConverter, + ), + HttpCallInterceptor(client.httpClient), + ]; + + final interceptorChain = InterceptorChain( + request: request, + interceptors: interceptors, + ); + + return await interceptorChain.proceed(request); + } +} diff --git a/chopper/lib/src/chain/chain.dart b/chopper/lib/src/chain/chain.dart new file mode 100644 index 00000000..327c2e5a --- /dev/null +++ b/chopper/lib/src/chain/chain.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// A single chain instance in the chain of interceptors that is called in order to process requests and responses. +/// +/// The chain is used to proceed to the next interceptor in the chain. +/// Call [proceed] to proceed to the next interceptor in the chain. +/// ```dart +/// await chain.proceed(request); +/// ``` +abstract interface class Chain { + /// Proceed to the next interceptor in the chain. + /// Provide the [request] to be processed by the next interceptor. + FutureOr> proceed(Request request); + + /// The request to be processed by the chain up to this point. + /// The request is provide by the previous interceptor in the chain. + Request get request; +} diff --git a/chopper/lib/src/chain/interceptor_chain.dart b/chopper/lib/src/chain/interceptor_chain.dart new file mode 100644 index 00000000..116299ff --- /dev/null +++ b/chopper/lib/src/chain/interceptor_chain.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chopper_exception.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// {@template InterceptorChain} +/// A chain of interceptors that are called in order to process requests and responses. +/// {@endtemplate} +class InterceptorChain implements Chain { + /// {@macro InterceptorChain} + InterceptorChain({ + required this.interceptors, + required this.request, + this.index = 0, + }) : assert(interceptors.isNotEmpty, 'Interceptors list must not be empty'); + + @override + final Request request; + + /// Response received from the next interceptor in the chain. + Response? response; + + /// List of interceptors to be called in order. + final List interceptors; + + /// Index of the current interceptor in the chain. + final int index; + + @override + FutureOr> proceed(Request request) async { + assert(index < interceptors.length, 'Interceptor index out of bounds'); + if (index - 1 >= 0 && interceptors[index - 1] is! InternalInterceptor) { + assert( + this.request.body == request.body, + 'Interceptor [${interceptors[index - 1].runtimeType}] should not transform the body of the request, ' + 'Use Request converter instead', + ); + } + + final interceptor = interceptors[index]; + final next = copyWith(request: request, index: index + 1); + response = await interceptor.intercept(next); + + if (index + 1 < interceptors.length && + interceptor is! InternalInterceptor) { + if (response == null) { + throw ChopperException('Response is null', request: request); + } + + assert( + response?.body == next.response?.body, + 'Interceptor [${interceptor.runtimeType}] should not transform the body of the response, ' + 'Use Response converter instead', + ); + } + + return response!; + } + + /// Copy the current [InterceptorChain]. With updated [request] or [index]. + InterceptorChain copyWith({ + Request? request, + int? index, + }) => + InterceptorChain( + request: request ?? this.request, + index: index ?? this.index, + interceptors: interceptors, + ); +} diff --git a/chopper/lib/src/chopper_exception.dart b/chopper/lib/src/chopper_exception.dart new file mode 100644 index 00000000..de26ef24 --- /dev/null +++ b/chopper/lib/src/chopper_exception.dart @@ -0,0 +1,24 @@ +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// {@template ChopperException} +/// An exception thrown when something goes wrong with Chopper. +/// {@endtemplate} +class ChopperException implements Exception { + /// {@macro ChopperException} + ChopperException(this.message, {this.response, this.request}); + + /// The response that caused the exception. + final Response? response; + + /// The request that caused the exception. + final Request? request; + + /// The message of the exception. + final String message; + + @override + String toString() { + return 'ChopperException: $message ${response != null ? ', \nResponse: $response' : ''}${request != null ? ', \nRequest: $request' : ''}'; + } +} diff --git a/chopper/lib/src/chopper_http_exception.dart b/chopper/lib/src/chopper_http_exception.dart index cae57ce2..c07a3bd4 100644 --- a/chopper/lib/src/chopper_http_exception.dart +++ b/chopper/lib/src/chopper_http_exception.dart @@ -1,9 +1,13 @@ import 'package:chopper/src/response.dart'; +/// {@template ChopperHttpException} /// An exception thrown when a [Response] is unsuccessful < 200 or > 300. +/// {@endtemplate} class ChopperHttpException implements Exception { + /// {@macro ChopperHttpException} ChopperHttpException(this.response); + /// The response that caused the exception. final Response response; @override diff --git a/chopper/lib/src/interceptor.dart b/chopper/lib/src/converters.dart similarity index 61% rename from chopper/lib/src/interceptor.dart rename to chopper/lib/src/converters.dart index d76e2920..d9a9c130 100644 --- a/chopper/lib/src/interceptor.dart +++ b/chopper/lib/src/converters.dart @@ -1,74 +1,20 @@ import 'dart:async'; import 'dart:convert'; -import 'package:chopper/src/constants.dart'; import 'package:chopper/src/request.dart'; import 'package:chopper/src/response.dart'; import 'package:chopper/src/utils.dart'; -import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -/// An interface for implementing response interceptors. -/// -/// [ResponseInterceptor]s are called after [Converter.convertResponse]. -/// -/// While [ResponseInterceptor]s *can* modify the body of responses, -/// converting (decoding) the response body should be handled by [Converter]s. -/// -/// See built-in [HttpLoggingInterceptor] for a fully functional example implementation. -/// -/// A short example for extracting a header value from a response: -/// -/// ```dart -/// class MyResponseInterceptor implements ResponseInterceptor { -/// String _token; -/// -/// @override -/// FutureOr onResponse(Response response) { -/// _token = response.headers['auth_token']; -/// return response; -/// } -/// } -/// ``` -@immutable -abstract interface class ResponseInterceptor { - FutureOr onResponse(Response response); -} - -/// An interface for implementing request interceptors. -/// -/// [RequestInterceptor]s are called after [Converter.convertRequest]. -/// -/// While [RequestInterceptor]s *can* modify the body of requests, -/// converting (encoding) the request body should be handled by [Converter]s. -/// -/// See built-in [CurlInterceptor] and [HttpLoggingInterceptor] for fully -/// functional example implementations. -/// -/// A short example for adding an authentication token to every request: -/// -/// ```dart -/// class MyRequestInterceptor implements ResponseInterceptor { -/// @override -/// FutureOr onRequest(Request request) { -/// return applyHeader(request, 'auth_token', 'Bearer $token'); -/// } -/// } -/// ``` -/// -/// (See [applyHeader(request, name, value)] and [applyHeaders(request, headers)].) -@immutable -abstract interface class RequestInterceptor { - FutureOr onRequest(Request request); -} +import 'constants.dart'; /// An interface for implementing request and response converters. /// /// [Converter]s convert objects to and from their representation in HTTP. /// -/// [convertRequest] is called before [RequestInterceptor]s +/// [convertRequest] is called before [Interceptor]s /// and [convertResponse] is called just after the HTTP response, -/// before [ResponseInterceptor]s. +/// before returning through the [Interceptor]s. /// /// See [JsonConverter] and [FormUrlEncodedConverter] for example implementations. @immutable @@ -93,79 +39,13 @@ abstract interface class Converter { /// An interface for implementing error response converters. /// /// An `ErrorConverter` is called only on error responses -/// (statusCode < 200 || statusCode >= 300) and before any [ResponseInterceptor]s. +/// (statusCode < 200 || statusCode >= 300) and before returning to any [Interceptor]s. abstract interface class ErrorConverter { /// Converts the received [Response] to a [Response] which has a body with the /// HTTP representation of the original body. FutureOr convertError(Response response); } -/// {@template HeadersInterceptor} -/// A [RequestInterceptor] that adds [headers] to every request. -/// -/// Note that this interceptor will overwrite existing headers having the same -/// keys as [headers]. -/// {@endtemplate} -@immutable -class HeadersInterceptor implements RequestInterceptor { - final Map headers; - - /// {@macro HeadersInterceptor} - const HeadersInterceptor(this.headers); - - @override - Future onRequest(Request request) async => - applyHeaders(request, headers); -} - -typedef ResponseInterceptorFunc1 = FutureOr> - Function( - Response response, -); -typedef ResponseInterceptorFunc2 = FutureOr> - Function( - Response response, -); -typedef DynamicResponseInterceptorFunc = FutureOr Function( - Response response, -); -typedef RequestInterceptorFunc = FutureOr Function(Request request); - -/// A [RequestInterceptor] implementation that prints a curl request equivalent -/// to the network call channeled through it for debugging purposes. -/// -/// Thanks, @edwardaux -@immutable -class CurlInterceptor implements RequestInterceptor { - @override - Future onRequest(Request request) async { - final http.BaseRequest baseRequest = await request.toBaseRequest(); - final List curlParts = ['curl -v -X ${baseRequest.method}']; - for (final MapEntry header in baseRequest.headers.entries) { - curlParts.add("-H '${header.key}: ${header.value}'"); - } - // this is fairly naive, but it should cover most cases - if (baseRequest is http.Request) { - final String body = baseRequest.body; - if (body.isNotEmpty) { - curlParts.add("-d '$body'"); - } - } - if (baseRequest is http.MultipartRequest) { - for (final MapEntry field in baseRequest.fields.entries) { - curlParts.add("-f '${field.key}: ${field.value}'"); - } - for (final http.MultipartFile file in baseRequest.files) { - curlParts.add("-f '${file.field}: ${file.filename ?? ''}'"); - } - } - curlParts.add('"${baseRequest.url}"'); - chopperLogger.info(curlParts.join(' ')); - - return request; - } -} - /// {@template JsonConverter} /// A [Converter] implementation that calls [json.encode] on [Request]s and /// [json.decode] on [Response]s using the [dart:convert](https://api.dart.dev/stable/2.10.3/dart-convert/dart-convert-library.html) diff --git a/chopper/lib/src/extensions.dart b/chopper/lib/src/extensions.dart index 8d5586c2..3586938f 100644 --- a/chopper/lib/src/extensions.dart +++ b/chopper/lib/src/extensions.dart @@ -25,3 +25,7 @@ extension StripStringExtension on String { String strip([String? character]) => character != null ? leftStrip(character).rightStrip(character) : trim(); } + +extension StatusCodeIntExtension on int { + bool get isSuccessfulStatusCode => this >= 200 && this < 300; +} diff --git a/chopper/lib/src/interceptors/authenticator_interceptor.dart b/chopper/lib/src/interceptors/authenticator_interceptor.dart new file mode 100644 index 00000000..91b558e9 --- /dev/null +++ b/chopper/lib/src/interceptors/authenticator_interceptor.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:chopper/src/authenticator.dart'; +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/extensions.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// {@template AuthenticatorInterceptor} +/// Internal interceptor that handles authentication provided by [authenticator]. +/// {@endtemplate} +class AuthenticatorInterceptor implements InternalInterceptor { + /// {@macro AuthenticatorInterceptor} + AuthenticatorInterceptor(this._authenticator); + + /// Authenticator to be used for authentication. + final Authenticator _authenticator; + + @override + FutureOr> intercept( + Chain chain) async { + final originalRequest = chain.request; + + Response response = await chain.proceed(originalRequest); + + final Request? updatedRequest = await _authenticator.authenticate( + originalRequest, + response, + originalRequest, + ); + + if (updatedRequest != null) { + response = await chain.proceed(updatedRequest); + if (response.statusCode.isSuccessfulStatusCode) { + await _authenticator.onAuthenticationSuccessful + ?.call(updatedRequest, response, originalRequest); + } else { + await _authenticator.onAuthenticationFailed + ?.call(updatedRequest, response, originalRequest); + } + } + + return response; + } +} diff --git a/chopper/lib/src/interceptors/curl_interceptor.dart b/chopper/lib/src/interceptors/curl_interceptor.dart new file mode 100644 index 00000000..9eb4256c --- /dev/null +++ b/chopper/lib/src/interceptors/curl_interceptor.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/response.dart'; +import 'package:chopper/src/utils.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// A [Interceptor] implementation that prints a curl request equivalent +/// to the network call channeled through it for debugging purposes. +/// +/// Thanks, @edwardaux +@immutable +class CurlInterceptor implements Interceptor { + @override + FutureOr> intercept( + Chain chain) async { + final http.BaseRequest baseRequest = await chain.request.toBaseRequest(); + final List curlParts = ['curl -v -X ${baseRequest.method}']; + for (final MapEntry header in baseRequest.headers.entries) { + curlParts.add("-H '${header.key}: ${header.value}'"); + } + // this is fairly naive, but it should cover most cases + if (baseRequest is http.Request) { + final String body = baseRequest.body; + if (body.isNotEmpty) { + curlParts.add("-d '$body'"); + } + } + if (baseRequest is http.MultipartRequest) { + for (final MapEntry field in baseRequest.fields.entries) { + curlParts.add("-f '${field.key}: ${field.value}'"); + } + for (final http.MultipartFile file in baseRequest.files) { + curlParts.add("-f '${file.field}: ${file.filename ?? ''}'"); + } + } + curlParts.add('"${baseRequest.url}"'); + chopperLogger.info(curlParts.join(' ')); + + return chain.proceed(chain.request); + } +} diff --git a/chopper/lib/src/interceptors/headers_interceptor.dart b/chopper/lib/src/interceptors/headers_interceptor.dart new file mode 100644 index 00000000..7f7057e0 --- /dev/null +++ b/chopper/lib/src/interceptors/headers_interceptor.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/response.dart'; +import 'package:chopper/src/utils.dart'; +import 'package:meta/meta.dart'; + +/// {@template HeadersInterceptor} +/// A [Interceptor] that adds [headers] to every request. +/// +/// Note that this interceptor will overwrite existing headers having the same +/// keys as [headers]. +/// {@endtemplate} +@immutable +class HeadersInterceptor implements Interceptor { + final Map headers; + + /// {@macro HeadersInterceptor} + const HeadersInterceptor(this.headers); + + @override + FutureOr> intercept( + Chain chain) async => + chain.proceed( + applyHeaders( + chain.request, + headers, + ), + ); +} diff --git a/chopper/lib/src/interceptors/http_call_interceptor.dart b/chopper/lib/src/interceptors/http_call_interceptor.dart new file mode 100644 index 00000000..b7b204bf --- /dev/null +++ b/chopper/lib/src/interceptors/http_call_interceptor.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chopper_exception.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/response.dart'; +import 'package:http/http.dart' as http; + +import '../utils.dart'; + +/// {@template HttpCallInterceptor} +/// Internal interceptor that handles the actual HTTP calls. HTTP calls are handled by [_httpClient] for http package. +/// {@endtemplate} +class HttpCallInterceptor implements InternalInterceptor { + /// {@macro HttpCallInterceptor} + const HttpCallInterceptor(this._httpClient); + + /// HTTP client to be used for making the actual HTTP calls. + final http.Client _httpClient; + + @override + FutureOr> intercept( + Chain chain) async { + final finalRequest = await chain.request.toBaseRequest(); + final streamRes = await _httpClient.send(finalRequest); + + if (isTypeOf>>()) { + return Response(streamRes, (streamRes.stream) as BodyType); + } else if (isTypeOf()) { + final response = await http.Response.fromStream(streamRes); + return Response(response, response.body as BodyType); + } else { + throw ChopperException('Unsupported type', request: chain.request); + } + } +} diff --git a/chopper/lib/src/http_logging_interceptor.dart b/chopper/lib/src/interceptors/http_logging_interceptor.dart similarity index 62% rename from chopper/lib/src/http_logging_interceptor.dart rename to chopper/lib/src/interceptors/http_logging_interceptor.dart index bdf37b35..95bb6b2c 100644 --- a/chopper/lib/src/http_logging_interceptor.dart +++ b/chopper/lib/src/interceptors/http_logging_interceptor.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'package:chopper/src/chain/chain.dart'; import 'package:chopper/src/chopper_log_record.dart'; -import 'package:chopper/src/interceptor.dart'; -import 'package:chopper/src/request.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; import 'package:chopper/src/response.dart'; import 'package:chopper/src/utils.dart'; import 'package:http/http.dart' as http; @@ -61,7 +61,7 @@ enum Level { } /// {@template http_logging_interceptor} -/// A [RequestInterceptor] and [ResponseInterceptor] implementation which logs +/// A [Interceptor] implementation which logs /// HTTP request and response data. /// /// Log levels can be set by applying [level] for more fine grained control @@ -73,8 +73,7 @@ enum Level { /// or in a non-production environment. /// {@endtemplate} @immutable -class HttpLoggingInterceptor - implements RequestInterceptor, ResponseInterceptor { +class HttpLoggingInterceptor implements Interceptor { /// {@macro http_logging_interceptor} HttpLoggingInterceptor({this.level = Level.body, Logger? logger}) : _logger = logger ?? chopperLogger, @@ -87,18 +86,21 @@ class HttpLoggingInterceptor final bool _logHeaders; @override - FutureOr onRequest(Request request) async { - if (level == Level.none) return request; - final http.BaseRequest base = await request.toBaseRequest(); - - String startRequestMessage = '--> ${base.method} ${base.url.toString()}'; - String bodyMessage = ''; - if (base is http.Request) { - if (base.body.isNotEmpty) { - bodyMessage = base.body; + FutureOr> intercept( + Chain chain) async { + final request = chain.request; + if (level == Level.none) return chain.proceed(request); + final http.BaseRequest baseRequest = await request.toBaseRequest(); + + String startRequestMessage = + '--> ${baseRequest.method} ${baseRequest.url.toString()}'; + String bodyRequestMessage = ''; + if (baseRequest is http.Request) { + if (baseRequest.body.isNotEmpty) { + bodyRequestMessage = baseRequest.body; if (!_logHeaders) { - startRequestMessage += ' (${base.bodyBytes.length}-byte body)'; + startRequestMessage += ' (${baseRequest.bodyBytes.length}-byte body)'; } } } @@ -108,53 +110,53 @@ class HttpLoggingInterceptor _logger.info(ChopperLogRecord(startRequestMessage, request: request)); if (_logHeaders) { - base.headers.forEach( + baseRequest.headers.forEach( (k, v) => _logger.info(ChopperLogRecord('$k: $v', request: request)), ); - if (base.contentLength != null && - base.headers['content-length'] == null) { + if (baseRequest.contentLength != null && + baseRequest.headers['content-length'] == null) { _logger.info(ChopperLogRecord( - 'content-length: ${base.contentLength}', + 'content-length: ${baseRequest.contentLength}', request: request, )); } } - if (_logBody && bodyMessage.isNotEmpty) { + if (_logBody && bodyRequestMessage.isNotEmpty) { _logger.info(ChopperLogRecord('', request: request)); - _logger.info(ChopperLogRecord(bodyMessage, request: request)); + _logger.info(ChopperLogRecord(bodyRequestMessage, request: request)); } if (_logHeaders || _logBody) { _logger.info(ChopperLogRecord( - '--> END ${base.method}', + '--> END ${baseRequest.method}', request: request, )); } + final stopWatch = Stopwatch()..start(); - return request; - } + final response = await chain.proceed(request); + + stopWatch.stop(); - @override - FutureOr onResponse(Response response) { if (level == Level.none) return response; - final base = response.base; + final baseResponse = response.base; String bytes = ''; String reasonPhrase = response.statusCode.toString(); - String bodyMessage = ''; - if (base is http.Response) { - if (base.reasonPhrase != null) { + String bodyResponseMessage = ''; + if (baseResponse is http.Response) { + if (baseResponse.reasonPhrase != null) { reasonPhrase += - ' ${base.reasonPhrase != reasonPhrase ? base.reasonPhrase : ''}'; + ' ${baseResponse.reasonPhrase != reasonPhrase ? baseResponse.reasonPhrase : ''}'; } - if (base.body.isNotEmpty) { - bodyMessage = base.body; + if (baseResponse.body.isNotEmpty) { + bodyResponseMessage = baseResponse.body; if (!_logBody && !_logHeaders) { - bytes = ' (${response.bodyBytes.length}-byte body)'; + bytes = ', ${response.bodyBytes.length}-byte body'; } } } @@ -162,27 +164,27 @@ class HttpLoggingInterceptor // Always start on a new line _logger.info(ChopperLogRecord('', response: response)); _logger.info(ChopperLogRecord( - '<-- $reasonPhrase ${base.request?.method} ${base.request?.url.toString()}$bytes', + '<-- $reasonPhrase ${baseResponse.request?.method} ${baseResponse.request?.url.toString()} (${stopWatch.elapsedMilliseconds}ms$bytes)', response: response, )); if (_logHeaders) { - base.headers.forEach( + baseResponse.headers.forEach( (k, v) => _logger.info(ChopperLogRecord('$k: $v', response: response)), ); - if (base.contentLength != null && - base.headers['content-length'] == null) { + if (baseResponse.contentLength != null && + baseResponse.headers['content-length'] == null) { _logger.info(ChopperLogRecord( - 'content-length: ${base.contentLength}', + 'content-length: ${baseResponse.contentLength}', response: response, )); } } - if (_logBody && bodyMessage.isNotEmpty) { + if (_logBody && bodyResponseMessage.isNotEmpty) { _logger.info(ChopperLogRecord('', response: response)); - _logger.info(ChopperLogRecord(bodyMessage, response: response)); + _logger.info(ChopperLogRecord(bodyResponseMessage, response: response)); } if (_logBody || _logHeaders) { diff --git a/chopper/lib/src/interceptors/interceptor.dart b/chopper/lib/src/interceptors/interceptor.dart new file mode 100644 index 00000000..9671d398 --- /dev/null +++ b/chopper/lib/src/interceptors/interceptor.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:chopper/chopper.dart'; +import 'package:meta/meta.dart'; + +export 'package:chopper/src/interceptors/curl_interceptor.dart'; +export 'package:chopper/src/interceptors/headers_interceptor.dart'; +export 'package:chopper/src/interceptors/http_logging_interceptor.dart'; + +/// The interface for implementing interceptors. +/// Interceptors are used for intercepting request, responses and preforming operations on them. +/// +/// Interceptor are called in a Chain order. +/// The first interceptor in the chain calls the next interceptor in the chain and so on. +/// The last interceptor in the chain return the response back to the previous interceptor in the chain and so on. +/// This means the request are processed in the order defined by the chain. +/// The responses are process in the reverse order defined by the chain. +/// +/// Chopper has a few built-in interceptors which can be inspected as fully working examples: +/// [HttpLoggingInterceptor], [CurlInterceptor] and [HeaderInterceptor]. +/// +/// A short example for adding an authentication token to every request: +/// +/// ```dart +/// class MyRequestInterceptor implements Interceptor { +/// final String token; +/// +/// @override +/// FutureOr> intercept(Chain chain) async { +/// final request = applyHeader(chain.request, 'auth_token', 'Bearer $token'); +/// return chain.proceed(request); +/// } +/// } +/// ``` +/// A short example for extracting a header value from a response: +/// +/// ```dart +/// class MyResponseInterceptor implements Interceptor { +/// String _token; +/// +/// @override +/// FutureOr> intercept(Chain chain) async { +/// final response = await chain.proceed(chain.request); +/// +/// _token = response.headers['auth_token']; +/// return response; +/// } +/// } +/// ``` +/// +/// **While [Interceptor]s *can* modify the body of requests and responses, +/// converting (encoding) the request/response body should be handled by [Converter]s.** +@immutable +abstract interface class Interceptor { + FutureOr> intercept(Chain chain); +} diff --git a/chopper/lib/src/interceptors/internal_interceptor.dart b/chopper/lib/src/interceptors/internal_interceptor.dart new file mode 100644 index 00000000..a6a80201 --- /dev/null +++ b/chopper/lib/src/interceptors/internal_interceptor.dart @@ -0,0 +1,4 @@ +import 'package:chopper/src/interceptors/interceptor.dart'; + +/// An interface for implementing Internal interceptors only used by Chopper itself. +abstract interface class InternalInterceptor implements Interceptor {} diff --git a/chopper/lib/src/interceptors/request_converter_interceptor.dart b/chopper/lib/src/interceptors/request_converter_interceptor.dart new file mode 100644 index 00000000..beb05993 --- /dev/null +++ b/chopper/lib/src/interceptors/request_converter_interceptor.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:chopper/src/annotations.dart'; +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +/// {@template RequestConverterInterceptor} +/// Internal interceptor that handles request conversion provided by [_requestConverter] or [_converter]. +/// {@endtemplate} +class RequestConverterInterceptor implements InternalInterceptor { + /// {@macro RequestConverterInterceptor} + RequestConverterInterceptor(this._converter, this._requestConverter); + + /// Converter to be used for request conversion. + final Converter? _converter; + + /// Request converter to be used for request conversion. + final ConvertRequest? _requestConverter; + + @override + FutureOr> intercept( + Chain chain) async => + await chain.proceed( + await _handleRequestConverter( + chain.request, + _requestConverter, + ), + ); + + /// Converts the [request] using [_requestConverter] if it is not null, otherwise uses [_converter]. + Future _handleRequestConverter( + Request request, + ConvertRequest? requestConverter, + ) async => + request.body != null || request.parts.isNotEmpty + ? requestConverter != null + ? await requestConverter(request) + : await _encodeRequest(request) + : request; + + /// Encodes the [request] using [_converter] if not null. + Future _encodeRequest(Request request) async => + _converter?.convertRequest(request) ?? request; +} diff --git a/chopper/lib/src/interceptors/request_stream_interceptor.dart b/chopper/lib/src/interceptors/request_stream_interceptor.dart new file mode 100644 index 00000000..377d5310 --- /dev/null +++ b/chopper/lib/src/interceptors/request_stream_interceptor.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; + +class RequestStreamInterceptor implements InternalInterceptor { + const RequestStreamInterceptor(this.callback); + + final FutureOr Function(Request event) callback; + + @override + FutureOr> intercept( + Chain chain) async { + await callback(chain.request); + + return chain.proceed(chain.request); + } +} diff --git a/chopper/lib/src/interceptors/response_converter_interceptor.dart b/chopper/lib/src/interceptors/response_converter_interceptor.dart new file mode 100644 index 00000000..5d2dd188 --- /dev/null +++ b/chopper/lib/src/interceptors/response_converter_interceptor.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:chopper/src/annotations.dart'; +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chain/interceptor_chain.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/extensions.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/response.dart'; +import 'package:chopper/src/utils.dart'; + +/// {@template ResponseConverterInterceptor} +/// Internal interceptor that handles response conversion provided by [_converter], [_responseConverter] or converts error instead with provided [_errorConverter]. +/// {@endtemplate} +class ResponseConverterInterceptor implements InternalInterceptor { + /// {@macro ResponseConverterInterceptor} + ResponseConverterInterceptor({ + Converter? converter, + ErrorConverter? errorConverter, + FutureOr> Function(Response)? responseConverter, + }) : _responseConverter = responseConverter, + _errorConverter = errorConverter, + _converter = converter; + + /// Converter to be used for response conversion. + final Converter? _converter; + + /// Error converter to be used for error conversion. + final ErrorConverter? _errorConverter; + + /// Response converter to be used for response conversion. + final ConvertResponse? _responseConverter; + + @override + FutureOr> intercept( + Chain chain) async { + final realChain = chain as InterceptorChain; + final typedChain = switch (isTypeOf>>()) { + true => realChain, + false => realChain.copyWith(), + }; + + final response = await typedChain.proceed(chain.request); + + return response.statusCode.isSuccessfulStatusCode + ? _handleSuccessResponse(response, _responseConverter) + : _handleErrorResponse(response); + } + + /// Handles the successful response by converting it using [_responseConverter] or [_converter]. + Future> _handleSuccessResponse( + Response response, + ConvertResponse? responseConverter, + ) async { + Response? newResponse; + if (responseConverter != null) { + newResponse = await responseConverter(response); + } else if (_converter != null) { + newResponse = await _decodeResponse(response, _converter!); + } + + return Response( + newResponse?.base ?? response.base, + newResponse?.body ?? response.body, + ); + } + + /// Converts the [response] using [_converter]. + Future> _decodeResponse( + Response response, + Converter withConverter, + ) async => + await withConverter.convertResponse(response); + + /// Handles the error response by converting it using [_errorConverter]. + Future> _handleErrorResponse( + Response response, + ) async { + var error = response.body; + if (_errorConverter != null) { + final errorRes = await _errorConverter?.convertError( + response, + ); + error = errorRes?.error ?? errorRes?.body; + } + + return Response(response.base, null, error: error); + } +} diff --git a/chopper/test/authenticator_test.dart b/chopper/test/authenticator_test.dart index 7b454462..d1e6e1a6 100644 --- a/chopper/test/authenticator_test.dart +++ b/chopper/test/authenticator_test.dart @@ -1,6 +1,8 @@ import 'dart:convert' show jsonEncode; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/headers_interceptor.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -14,7 +16,7 @@ void main() async { baseUrl: baseUrl, client: httpClient, interceptors: [ - (Request req) => applyHeader(req, 'foo', 'bar'), + HeadersInterceptor({'foo': 'bar'}), ], converter: JsonConverter(), authenticator: FakeAuthenticator(), diff --git a/chopper/test/base_test.dart b/chopper/test/base_test.dart index d3e0fee2..8d34bbfb 100644 --- a/chopper/test/base_test.dart +++ b/chopper/test/base_test.dart @@ -3,7 +3,11 @@ import 'dart:async'; import 'dart:convert'; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/constants.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/utils.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -810,52 +814,6 @@ void main() { } }); - test('wrong type for interceptor', () { - expect( - () => ChopperClient(interceptors: [(bool foo) => 'bar']), - throwsA(isA()), - ); - - try { - ChopperClient( - interceptors: [ - (bool foo) => 'bar', - ], - ); - } on AssertionError catch (error) { - expect( - error.toString(), - contains( - 'Unsupported type for interceptors, it only support the following types:\n' - ' - ${allowedInterceptorsType.join('\n - ')}', - ), - ); - } - }, testOn: 'vm'); - - test('wrong type for interceptor', () { - expect( - () => ChopperClient(interceptors: [(bool foo) => 'bar']), - throwsA(isA()), - ); - - try { - ChopperClient( - interceptors: [ - (bool foo) => 'bar', - ], - ); - } on AssertionError catch (error) { - expect( - error.toString(), - contains( - 'Unsupported type for interceptors, it only support the following types:\\n' - ' - ${allowedInterceptorsType.join('\\n - ')}', - ), - ); - } - }, testOn: 'browser'); - test('Query Map 1', () async { final httpClient = MockClient((request) async { expect( diff --git a/chopper/test/chain/authenticator_interceptor_test.dart b/chopper/test/chain/authenticator_interceptor_test.dart new file mode 100644 index 00000000..79e1b18b --- /dev/null +++ b/chopper/test/chain/authenticator_interceptor_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:chopper/chopper.dart'; +import 'package:chopper/src/interceptors/authenticator_interceptor.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + late MockAuthenticator authenticator; + late AuthenticatorInterceptor authenticatorInterceptor; + late MockChain chain; + final request = Request('GET', Uri.parse('bar'), Uri.parse('foo')); + + setUp(() { + chain = MockChain( + request, + () => Response( + http.Response('', 200), + '', + ), + ); + authenticator = MockAuthenticator(() => null); + authenticatorInterceptor = AuthenticatorInterceptor(authenticator); + }); + + test('Intercepted response is authenticated, chain.proceed called once', + () async { + await authenticatorInterceptor.intercept(chain); + + expect(authenticator.authenticateCalled, 1); + expect(chain.proceedCalled, 1); + }); + + test('Intercepted response is not authenticated, chain.proceed called twice', + () async { + authenticator = MockAuthenticator(() => request); + authenticatorInterceptor = AuthenticatorInterceptor(authenticator); + + await authenticatorInterceptor.intercept(chain); + + expect(authenticator.authenticateCalled, 1); + expect(chain.proceedCalled, 2); + }); + + test( + 'Intercepted response is not authenticated, authentication is successful', + () async { + authenticator = MockAuthenticator(() => request); + authenticatorInterceptor = AuthenticatorInterceptor(authenticator); + + await authenticatorInterceptor.intercept(chain); + + expect(authenticator.authenticateCalled, 1); + expect(chain.proceedCalled, 2); + expect(authenticator.onAuthenticationSuccessfulCalled, 1); + }); + + test('Intercepted response is not authenticated, authentication failed', + () async { + chain = MockChain( + request, + () => Response( + http.Response('', 400), + '', + ), + ); + authenticator = MockAuthenticator(() => request); + authenticatorInterceptor = AuthenticatorInterceptor(authenticator); + + await authenticatorInterceptor.intercept(chain); + + expect(authenticator.authenticateCalled, 1); + expect(chain.proceedCalled, 2); + expect(authenticator.onAuthenticationFailedCalled, 1); + }); +} + +class MockChain implements Chain { + MockChain(this.request, this.onProceed); + + int proceedCalled = 0; + + final Response Function() onProceed; + + @override + FutureOr> proceed(Request request) async { + proceedCalled++; + return onProceed(); + } + + @override + final Request request; +} + +class MockAuthenticator implements Authenticator { + MockAuthenticator(this.onAuthenticate) { + onAuthenticationFailed = ( + Request request, + Response response, [ + Request? originalRequest, + ]) { + onAuthenticationFailedCalled++; + return; + }; + + onAuthenticationSuccessful = ( + Request request, + Response response, [ + Request? originalRequest, + ]) { + onAuthenticationSuccessfulCalled++; + return; + }; + } + + final Request? Function() onAuthenticate; + + int authenticateCalled = 0; + int onAuthenticationFailedCalled = 0; + int onAuthenticationSuccessfulCalled = 0; + @override + AuthenticationCallback? onAuthenticationFailed; + + @override + AuthenticationCallback? onAuthenticationSuccessful; + + @override + FutureOr authenticate( + Request request, + Response response, [ + Request? originalRequest, + ]) async { + authenticateCalled++; + return onAuthenticate(); + } +} diff --git a/chopper/test/chain/interceptor_chain_test.dart b/chopper/test/chain/interceptor_chain_test.dart new file mode 100644 index 00000000..c7dd28b5 --- /dev/null +++ b/chopper/test/chain/interceptor_chain_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chain/interceptor_chain.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/interceptors/internal_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + group('InterceptorChain', () { + late Request mockRequest; + late MockInterceptor mockInterceptor; + late InterceptorChain interceptorChain; + + setUp(() { + mockRequest = + Request('GET', Uri.parse('bar'), Uri.parse('http://localhost')); + mockInterceptor = MockInterceptor(); + interceptorChain = InterceptorChain( + interceptors: [mockInterceptor], + request: mockRequest, + ); + }); + + test('is created correctly', () { + expect(interceptorChain.interceptors, [mockInterceptor]); + expect(interceptorChain.request, mockRequest); + }); + + test('copyWith method works as expected', () { + final newRequest = + Request('GET', Uri.parse('foo'), Uri.parse('http://localhost')); + final copiedChain = + interceptorChain.copyWith(request: newRequest, index: 666); + expect(copiedChain.request, newRequest); + expect(copiedChain.interceptors, [mockInterceptor]); + expect(copiedChain.index, 666); + }); + + test('A empty Interceptor chain throws assertion', () { + expect( + () => InterceptorChain( + interceptors: [], + request: mockRequest, + ), + throwsA(isA())); + }); + + test( + 'Intercept chain proceed called with index out of bounds throws assertion', + () async { + final chain = InterceptorChain( + interceptors: [mockInterceptor], + request: mockRequest, + index: 666, + ); + expect(chain.proceed(mockRequest), throwsA(isA())); + }); + }); + + group('interceptor chain proceed tests', () { + late Request mockRequest; + late MockInterceptor mockInterceptor; + late InterceptorChain interceptorChain; + setUp(() { + mockRequest = Request( + 'GET', + Uri.parse('bar'), + Uri.parse('http://localhost'), + body: 'Test', + ); + mockInterceptor = MockInterceptor(); + interceptorChain = InterceptorChain( + interceptors: [mockInterceptor], + request: mockRequest, + ); + }); + + test('proceed method works as expected, invokes the interceptor', () async { + final response = await interceptorChain.proceed(mockRequest); + expect(response.base.request, mockRequest); + expect(response.body, 'TestResponse'); + expect(mockInterceptor.called, 1); + }); + + test('proceed modifies request body, throws assertion', () async { + interceptorChain = InterceptorChain( + interceptors: [RequestModifierInterceptor(), mockInterceptor], + request: mockRequest, + ); + + expect( + () => interceptorChain.proceed(mockRequest), + throwsA( + isA().having( + (e) => e.message, + 'assertion', + 'Interceptor [RequestModifierInterceptor] should not transform the body of the request, ' + 'Use Request converter instead'), + ), + ); + }); + + test('proceed modifies response body, throws assertion', () async { + interceptorChain = InterceptorChain( + interceptors: [ResponseModifierInterceptor(), mockInterceptor], + request: mockRequest, + ); + + expect( + () => interceptorChain.proceed(mockRequest), + throwsA( + isA().having( + (e) => e.message, + 'assertion', + 'Interceptor [ResponseModifierInterceptor] should not transform the body of the response, ' + 'Use Response converter instead'), + ), + ); + }); + + test( + 'Internal interceptor is allowed modify request/response when proceeding, return normally', + () async { + interceptorChain = InterceptorChain( + interceptors: [InternalModifierInterceptor(), mockInterceptor], + request: mockRequest, + ); + + expect( + () => interceptorChain.proceed(mockRequest), + returnsNormally, + ); + }); + + test('proceed chain is broken before reaching the end, returns normally', + () { + interceptorChain = InterceptorChain( + interceptors: [ + PassthroughInterceptor(), + mockInterceptor, + PassthroughInterceptor(), + ], + request: mockRequest, + ); + + expect( + () => interceptorChain.proceed(mockRequest), + returnsNormally, + ); + }); + }); +} + +class RequestModifierInterceptor implements Interceptor { + @override + FutureOr> intercept(Chain chain) { + return chain.proceed( + chain.request.copyWith( + body: '${chain.request.body} modified!', + ), + ); + } +} + +class ResponseModifierInterceptor implements Interceptor { + @override + FutureOr> intercept( + Chain chain) async { + final response = await chain.proceed(chain.request); + + return response.copyWith( + body: '${response.body ?? ''} modified!' as BodyType); + } +} + +class DoubleProceedInterceptor implements Interceptor { + @override + FutureOr> intercept( + Chain chain) async { + final _ = await chain.proceed(chain.request); + final response2 = await chain.proceed(chain.request); + + return response2; + } +} + +class PassthroughInterceptor implements Interceptor { + @override + FutureOr> intercept( + Chain chain) async { + return await chain.proceed(chain.request); + } +} + +class InternalModifierInterceptor implements InternalInterceptor { + @override + FutureOr> intercept( + Chain chain) async { + final request = chain.request.copyWith( + body: '${chain.request.body} modified!', + ); + + final response = await chain.proceed(request); + + return response.copyWith( + body: '${response.body ?? ''} modified!' as BodyType); + } +} + +// ignore: must_be_immutable +class MockInterceptor implements InternalInterceptor { + MockInterceptor({this.response}); + + int called = 0; + + final Response? response; + + @override + FutureOr> intercept(Chain chain) { + called++; + return response as Response? ?? + Response(http.Response('TestResponse', 200, request: chain.request), + 'TestResponse' as BodyType); + } +} diff --git a/chopper/test/chain/request_converter_interceptor_test.dart b/chopper/test/chain/request_converter_interceptor_test.dart new file mode 100644 index 00000000..dede0994 --- /dev/null +++ b/chopper/test/chain/request_converter_interceptor_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chain/interceptor_chain.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/interceptors/request_converter_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + late InterceptorChain interceptorChain; + + test('request body is null and parts is empty, is not converted', () async { + final testRequest = Request('GET', Uri.parse('foo'), Uri.parse('bar')); + final converter = RequestConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + RequestConverterInterceptor( + converter, + null, + ), + RequestInterceptor(onRequest: (request) { + expect(request.body, null); + }), + ], + request: testRequest, + ); + + await interceptorChain.proceed(testRequest); + + expect(converter.called, 0); + }); + + test( + 'request body is not null and parts is empty, requestConverter is not provided, request is converted by converter', + () async { + final testRequest = Request('GET', Uri.parse('foo'), Uri.parse('bar'), + body: 'not converted'); + final converter = RequestConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + RequestConverterInterceptor( + converter, + null, + ), + RequestInterceptor(onRequest: (request) { + expect(request.body, 'converted'); + }), + ], + request: testRequest, + ); + + await interceptorChain.proceed(testRequest); + + expect(converter.called, 1); + }); + + test( + 'request body is null and parts is not empty, requestConverter is not provided, request is converted by converter', + () async { + final testRequest = Request('GET', Uri.parse('foo'), Uri.parse('bar'), + parts: [PartValue('not converted', 1)]); + final converter = RequestConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + RequestConverterInterceptor( + converter, + null, + ), + RequestInterceptor(onRequest: (request) { + expect(request.body, 'converted'); + }), + ], + request: testRequest, + ); + + await interceptorChain.proceed(testRequest); + + expect(converter.called, 1); + }); + + test( + 'request body is not null and parts is empty, requestConverter is provided, request is converted by requestConverter', + () async { + final testRequest = Request('GET', Uri.parse('foo'), Uri.parse('bar'), + body: 'not converted'); + final converter = RequestConverter(); + int called = 0; + interceptorChain = InterceptorChain( + interceptors: [ + RequestConverterInterceptor( + converter, + (req) { + called++; + return req.copyWith(body: 'foo'); + }, + ), + RequestInterceptor(onRequest: (request) { + expect(request.body, 'foo'); + }), + ], + request: testRequest, + ); + + await interceptorChain.proceed(testRequest); + + expect(called, 1); + expect(converter.called, 0); + }); +} + +// ignore mutability warning for test class. +//ignore: must_be_immutable +class RequestConverter implements Converter { + int called = 0; + @override + FutureOr convertRequest(Request request) { + called++; + return request.copyWith(body: 'converted'); + } + + @override + FutureOr> convertResponse( + Response response) { + return response as Response; + } +} + +// ignore: must_be_immutable +class RequestInterceptor implements Interceptor { + RequestInterceptor({this.onRequest}); + + final void Function(Request)? onRequest; + int called = 0; + + @override + FutureOr> intercept(Chain chain) { + called++; + onRequest?.call(chain.request); + return Response(http.Response('TestResponse', 200, request: chain.request), + 'TestResponse' as BodyType); + } +} diff --git a/chopper/test/chain/response_converter_interceptor_test.dart b/chopper/test/chain/response_converter_interceptor_test.dart new file mode 100644 index 00000000..1edfe680 --- /dev/null +++ b/chopper/test/chain/response_converter_interceptor_test.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/chain/interceptor_chain.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/interceptors/response_converter_interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + late InterceptorChain interceptorChain; + final testRequest = Request('GET', Uri.parse('foo'), Uri.parse('bar')); + + group('response converter tests', () { + test( + 'response is successful converter is null and response converter is null, response is not converted', + () async { + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor(), + ResponseInterceptor(), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, 'TestResponse'); + }); + + test( + 'response is successful converter is not null and response converter is null, response is converted', + () async { + final converter = ResponseConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor(converter: converter), + ResponseInterceptor(), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, 'converted'); + expect(converter.called, 1); + }); + + test( + 'response is successful converter is not null and response converter is not null, response is converted by response converter', + () async { + final converter = ResponseConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor( + converter: converter, + responseConverter: (response) => + response.copyWith(body: 'response converted')), + ResponseInterceptor(), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, 'response converted'); + expect(converter.called, 0); + }); + + test( + 'response is unsuccessful converter is not null and response converter is not null, response is not converted', + () async { + final converter = ResponseConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor( + converter: converter, + responseConverter: (response) => + response.copyWith(body: 'response converted')), + ResponseInterceptor( + response: Response( + http.Response('error base', 500, request: testRequest), + 'error')), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, null); + expect(response.error, 'error'); + expect(converter.called, 0); + }); + }); + + group('response error converter tests', () { + final errorResponse = Response( + http.Response('error base', 500, request: testRequest), 'error'); + test( + 'response is unsuccessful converter is null, response is not converted', + () async { + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor(), + ResponseInterceptor(response: errorResponse), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, null); + expect(response.error, 'error'); + }); + + test( + 'response is unsuccessful converter is not null, response is converted', + () async { + final converter = ResponseErrorConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor(errorConverter: converter), + ResponseInterceptor(response: errorResponse), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, null); + expect(response.error, 'converted'); + expect(converter.called, 1); + }); + + test( + 'response is successful converter is not null, response is not converter', + () async { + final converter = ResponseErrorConverter(); + interceptorChain = InterceptorChain( + interceptors: [ + ResponseConverterInterceptor(errorConverter: converter), + ResponseInterceptor(), + ], + request: testRequest, + ); + + final response = await interceptorChain.proceed(testRequest); + + expect(response.body, 'TestResponse'); + expect(converter.called, 0); + }); + }); +} + +// ignore mutability warning for test class. +//ignore: must_be_immutable +class ResponseConverter implements Converter { + int called = 0; + + @override + FutureOr convertRequest(Request request) { + return request; + } + + @override + FutureOr> convertResponse( + Response response) { + called++; + return response.copyWith(body: 'converted' as BodyType); + } +} + +// ignore mutability warning for test class. +//ignore: must_be_immutable +class ResponseErrorConverter implements ErrorConverter { + int called = 0; + + @override + FutureOr> convertError( + Response response) { + called++; + return response.copyWith(body: 'converted' as BodyType); + } +} + +class ResponseInterceptor implements Interceptor { + ResponseInterceptor({this.response}); + + final Response? response; + + @override + FutureOr> intercept(Chain chain) { + return response as Response? ?? + Response( + http.Response('TestResponse base', 200, request: chain.request), + 'TestResponse' as BodyType); + } +} diff --git a/chopper/test/client_test.dart b/chopper/test/client_test.dart index 88b0d2a1..b8e5497a 100644 --- a/chopper/test/client_test.dart +++ b/chopper/test/client_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/interceptors/headers_interceptor.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; @@ -12,7 +14,7 @@ void main() { baseUrl: baseUrl, client: httpClient, interceptors: [ - (Request req) => applyHeader(req, 'foo', 'bar'), + HeadersInterceptor({'foo': 'bar'}), ], converter: JsonConverter(), ); diff --git a/chopper/test/converter_test.dart b/chopper/test/converter_test.dart index e1b44d33..17281cc3 100644 --- a/chopper/test/converter_test.dart +++ b/chopper/test/converter_test.dart @@ -1,6 +1,9 @@ import 'dart:convert' as dart_convert; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; diff --git a/chopper/test/form_test.dart b/chopper/test/form_test.dart index ef5859d1..4f4a77a2 100644 --- a/chopper/test/form_test.dart +++ b/chopper/test/form_test.dart @@ -1,4 +1,5 @@ -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/converters.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; diff --git a/chopper/test/helpers/fake_chain.dart b/chopper/test/helpers/fake_chain.dart new file mode 100644 index 00000000..c3ec8372 --- /dev/null +++ b/chopper/test/helpers/fake_chain.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; +import 'package:http/http.dart' as http; + +class FakeChain implements Chain { + FakeChain(this.request, {this.response}); + + @override + final Request request; + final Response? response; + + @override + FutureOr> proceed(Request request) { + return response as Response? ?? + Response(http.Response('TestChain', 200), 'TestChain' as BodyType); + } +} diff --git a/chopper/test/http_logging_interceptor_test.dart b/chopper/test/http_logging_interceptor_test.dart index 9a2d49f4..b2ba8aaa 100644 --- a/chopper/test/http_logging_interceptor_test.dart +++ b/chopper/test/http_logging_interceptor_test.dart @@ -1,10 +1,12 @@ -import 'package:chopper/src/http_logging_interceptor.dart'; +import 'package:chopper/src/interceptors/http_logging_interceptor.dart'; import 'package:chopper/src/request.dart'; import 'package:chopper/src/response.dart'; import 'package:chopper/src/utils.dart'; import 'package:http/http.dart' as http; import 'package:test/test.dart'; +import 'helpers/fake_chain.dart'; + void main() { final fakeRequest = Request( 'POST', @@ -20,7 +22,7 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest); + await logger.intercept(FakeChain(fakeRequest)); expect( logs, @@ -35,11 +37,11 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest); + await logger.intercept(FakeChain(fakeRequest)); expect( logs, - equals( + containsAll( [ '', '--> POST base/ (4-byte body)', @@ -53,11 +55,11 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest); + await logger.intercept(FakeChain(fakeRequest)); expect( logs, - equals( + containsAll( [ '', '--> POST base/', @@ -75,11 +77,11 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest); + await logger.intercept(FakeChain(fakeRequest)); expect( logs, - equals( + containsAll( [ '', '--> POST base/', @@ -115,7 +117,7 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest)); expect( logs, @@ -130,14 +132,14 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest, response: fakeResponse)); expect( logs, - equals( + containsAll( [ '', - '<-- 200 POST base/ (16-byte body)', + '<-- 200 POST base/ (0ms, 16-byte body)', ], ), ); @@ -148,14 +150,14 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest, response: fakeResponse)); expect( logs, - equals( + containsAll( [ '', - '<-- 200 POST base/', + '<-- 200 POST base/ (0ms)', 'foo: bar', 'content-length: 16', '<-- END HTTP', @@ -169,14 +171,14 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest, response: fakeResponse)); expect( logs, - equals( + containsAll( [ '', - '<-- 200 POST base/', + '<-- 200 POST base/ (0ms)', 'foo: bar', 'content-length: 16', '', @@ -212,12 +214,12 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest - .copyWith(headers: {...fakeRequest.headers, 'content-length': '42'})); + await logger.intercept(FakeChain(fakeRequest.copyWith( + headers: {...fakeRequest.headers, 'content-length': '42'}))); expect( logs, - equals( + containsAll( [ '', '--> POST base/', @@ -236,12 +238,12 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onRequest(fakeRequest - .copyWith(headers: {...fakeRequest.headers, 'content-length': '42'})); + await logger.intercept(FakeChain(fakeRequest.copyWith( + headers: {...fakeRequest.headers, 'content-length': '42'}))); expect( logs, - equals( + containsAll( [ '', '--> POST base/', @@ -261,14 +263,14 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest, response: fakeResponse)); expect( logs, - equals( + containsAll( [ '', - '<-- 200 POST base/', + '<-- 200 POST base/ (0ms)', 'foo: bar', 'content-length: 42', '<-- END HTTP', @@ -281,14 +283,14 @@ void main() { final logs = []; chopperLogger.onRecord.listen((r) => logs.add(r.message)); - await logger.onResponse(fakeResponse); + await logger.intercept(FakeChain(fakeRequest, response: fakeResponse)); expect( logs, - equals( + containsAll( [ '', - '<-- 200 POST base/', + '<-- 200 POST base/ (0ms)', 'foo: bar', 'content-length: 42', '', diff --git a/chopper/test/interceptors_test.dart b/chopper/test/interceptors_test.dart index 3ee651d0..a1d1a2ac 100644 --- a/chopper/test/interceptors_test.dart +++ b/chopper/test/interceptors_test.dart @@ -1,10 +1,16 @@ import 'dart:async'; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/chain/chain.dart'; +import 'package:chopper/src/interceptors/interceptor.dart'; +import 'package:chopper/src/request.dart'; +import 'package:chopper/src/response.dart'; +import 'package:chopper/src/utils.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; +import 'helpers/fake_chain.dart'; import 'test_service.dart'; void main() { @@ -44,25 +50,6 @@ void main() { ); }); - test('RequestInterceptorFunc', () async { - final chopper = ChopperClient( - interceptors: [ - (Request request) => request.copyWith( - uri: request.uri.replace(path: '${request.uri.path}/intercept'), - ), - ], - services: [ - HttpTestService.create(), - ], - client: requestClient, - ); - - await chopper.getService().getTest( - '1234', - dynamicHeader: '', - ); - }); - test('ResponseInterceptor', () async { final chopper = ChopperClient( interceptors: [ResponseIntercept()], @@ -80,85 +67,6 @@ void main() { expect(ResponseIntercept.intercepted, isA<_Intercepted>()); }); - test('ResponseInterceptorFunc', () async { - dynamic intercepted; - - final chopper = ChopperClient( - interceptors: [ - (Response response) { - intercepted = _Intercepted(response.body); - - return response; - }, - ], - services: [ - HttpTestService.create(), - ], - client: responseClient, - ); - - await chopper.getService().getTest( - '1234', - dynamicHeader: '', - ); - - expect(intercepted, isA<_Intercepted>()); - }); - - test('TypedResponseInterceptorFunc1', () async { - dynamic intercepted; - - final chopper = ChopperClient( - interceptors: [ - (Response response) { - intercepted = _Intercepted(response.body); - - return response; - }, - ], - services: [ - HttpTestService.create(), - ], - client: responseClient, - ); - - await chopper.getService().getTest( - '1234', - dynamicHeader: '', - ); - - expect(intercepted, isA<_Intercepted>()); - }); - - test('TypedResponseInterceptorFunc2', () async { - final client = MockClient((http.Request req) async { - return http.Response('["1","2"]', 200); - }); - - dynamic intercepted; - - final chopper = ChopperClient( - client: client, - converter: JsonConverter(), - interceptors: [ - (Response response) { - expect(isTypeOf(), isTrue); - expect(isTypeOf>(), isTrue); - intercepted = _Intercepted(response.body as BodyType); - - return response; - }, - ], - services: [ - HttpTestService.create(), - ], - ); - - await chopper.getService().listString(); - - expect(intercepted, isA<_Intercepted>>()); - }); - test('headers', () async { final client = MockClient((http.Request req) async { expect(req.headers.containsKey('foo'), isTrue); @@ -195,7 +103,7 @@ void main() { final curl = CurlInterceptor(); var log = ''; chopperLogger.onRecord.listen((r) => log = r.message); - await curl.onRequest(fakeRequest); + await curl.intercept(FakeChain(fakeRequest)); expect( log, @@ -224,7 +132,7 @@ void main() { final curl = CurlInterceptor(); var log = ''; chopperLogger.onRecord.listen((r) => log = r.message); - await curl.onRequest(fakeRequestMultipart); + await curl.intercept(FakeChain(fakeRequestMultipart)); expect( log, @@ -236,22 +144,31 @@ void main() { }); } -class ResponseIntercept implements ResponseInterceptor { +class ResponseIntercept implements Interceptor { static dynamic intercepted; @override - FutureOr onResponse(Response response) { + FutureOr> intercept( + Chain chain) async { + final response = await chain.proceed(chain.request); + intercepted = _Intercepted(response.body); return response; } } -class RequestIntercept implements RequestInterceptor { +class RequestIntercept implements Interceptor { @override - FutureOr onRequest(Request request) => request.copyWith( + FutureOr> intercept( + Chain chain) async { + final request = chain.request; + return chain.proceed( + request.copyWith( uri: request.uri.replace(path: '${request.uri}/intercept'), - ); + ), + ); + } } class _Intercepted { diff --git a/chopper/test/json_test.dart b/chopper/test/json_test.dart index 6a8c637f..640ece6d 100644 --- a/chopper/test/json_test.dart +++ b/chopper/test/json_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/converters.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:test/test.dart'; diff --git a/chopper/test/multipart_test.dart b/chopper/test/multipart_test.dart index 8943a412..34a66b00 100644 --- a/chopper/test/multipart_test.dart +++ b/chopper/test/multipart_test.dart @@ -1,4 +1,7 @@ -import 'package:chopper/chopper.dart'; +import 'package:chopper/src/base.dart'; +import 'package:chopper/src/constants.dart'; +import 'package:chopper/src/converters.dart'; +import 'package:chopper/src/request.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:http_parser/http_parser.dart'; diff --git a/example/bin/main_json_serializable.dart b/example/bin/main_json_serializable.dart index d95b7f24..08ab2639 100644 --- a/example/bin/main_json_serializable.dart +++ b/example/bin/main_json_serializable.dart @@ -32,8 +32,8 @@ main() async { // the generated service MyService.create(), ], - /* ResponseInterceptorFunc | RequestInterceptorFunc | ResponseInterceptor | RequestInterceptor */ - interceptors: [authHeader], + /* Interceptors */ + interceptors: [AuthInterceptor()], ); final myService = chopper.getService(); @@ -57,11 +57,19 @@ main() async { } } -Future authHeader(Request request) async => applyHeader( - request, - 'Authorization', - '42', +class AuthInterceptor implements Interceptor { + @override + FutureOr> intercept( + Chain chain) async { + return chain.proceed( + applyHeader( + chain.request, + 'Authorization', + '42', + ), ); + } +} typedef JsonFactory = T Function(Map json); diff --git a/example/bin/main_json_serializable_squadron_worker_pool.dart b/example/bin/main_json_serializable_squadron_worker_pool.dart index e9564899..f2e268cd 100644 --- a/example/bin/main_json_serializable_squadron_worker_pool.dart +++ b/example/bin/main_json_serializable_squadron_worker_pool.dart @@ -13,7 +13,7 @@ import 'package:http/testing.dart'; import 'package:squadron/squadron.dart'; import 'package:http/http.dart' as http; -import 'main_json_serializable.dart' show authHeader; +import 'main_json_serializable.dart' show AuthInterceptor; typedef JsonFactory = T Function(Map json); @@ -134,8 +134,8 @@ Future main() async { // the generated service MyService.create(), ], - /* ResponseInterceptorFunc | RequestInterceptorFunc | ResponseInterceptor | RequestInterceptor */ - interceptors: [authHeader], + /* Interceptor */ + interceptors: [AuthInterceptor()], ); final myService = chopper.getService(); diff --git a/faq.md b/faq.md index a79ed397..aaf23c00 100644 --- a/faq.md +++ b/faq.md @@ -36,11 +36,20 @@ final chopper = ChopperClient( interceptors: [_addQuery], ); -Request _addQuery(Request req) { - final params = Map.from(req.parameters); - params['key'] = '123'; +class QueryInterceptor implements Interceptor { - return req.copyWith(parameters: params); + @override + FutureOr> intercept(Chain chain) async { + final request = _addQuery(chain.request); + return chain.proceed(request); + } + + Request _addQuery(Request req) { + final params = Map.from(req.parameters); + params['key'] = '123'; + + return req.copyWith(parameters: params); + } } ``` @@ -67,13 +76,17 @@ Future postRequest(@Body() Map data); You may need to change the base URL of your network calls during runtime, for example, if you have to use different servers or routes dynamically in your app in case of a "regular" or a "paid" user. You can store the current server base url in your SharedPreferences (encrypt/decrypt it if needed) and use it in an interceptor like this: ```dart -... -(Request request) async => - SharedPreferences.containsKey('baseUrl') - ? request.copyWith( - baseUri: Uri.parse(SharedPreferences.getString('baseUrl')) - ): request -... +class BaseUrlInterceptor implements Interceptor { + @override + FutureOr> intercept(Chain chain) async { + final request = SharedPreferences.containsKey('baseUrl') + ? chain.request.copyWith( + baseUri: Uri.parse(SharedPreferences.getString('baseUrl'))) + : chain.request; + + return chain.proceed(request); + } +} ``` ## Mock ChopperClient for testing @@ -152,22 +165,35 @@ if the refresh token is not valid anymore, drop the session (and navigate to the Simple code example: ```dart -interceptors: [ - // Auth Interceptor - (Request request) async => applyHeader(request, 'authorization', - SharedPrefs.localStorage.getString(tokenHeader), - override: false), - (Response response) async { +class AuthInterceptor implements Interceptor { + + @override + FutureOr> intercept(Chain chain) async { + final request = applyHeader(chain.request, 'authorization', + SharedPrefs.localStorage.getString(tokenHeader), + override: false); + + final response = await chain.proceed(request); + if (response?.statusCode == 401) { SharedPrefs.localStorage.remove(tokenHeader); // Navigate to some login page or just request new token } + return response; - }, -] + } +} + +... +interceptors: [ + AuthInterceptor(), + // ... other interceptors + ] +... ``` The actual implementation of the algorithm above may vary based on how the backend API - more precisely the login and session handling - of your app looks like. +Breaking out of the authentication flow/inteceptor can be achieved in multiple ways. For example by throwing an exception or by using a service handles navigation. See [interceptor](interceptors.md) for more info. ### Authorized HTTP requests using the special Authenticator interceptor @@ -406,7 +432,7 @@ Future main() async { // the generated service MyService.create(), ], - /* ResponseInterceptorFunc | RequestInterceptorFunc | ResponseInterceptor | RequestInterceptor */ + /* Interceptor */ interceptors: [authHeader], ); diff --git a/interceptors.md b/interceptors.md index b8121023..c6834d7d 100644 --- a/interceptors.md +++ b/interceptors.md @@ -2,38 +2,92 @@ ## **Request** -Implement `RequestInterceptor` class or define function with following signature `FutureOr RequestInterceptorFunc(Request request)` +Implement `Interceptor` class. -Request interceptor are called just before sending request +{% hint style="info" %} +Request interceptor are called just before sending request. +{% endhint %} ```dart -final chopper = ChopperClient( - interceptors: [ - (request) async => request.copyWith(body: {}), - ] -); +class MyRequestInterceptor implements Interceptor { + + MyRequestInterceptor(this.token); + + final String token; + + @override + FutureOr> intercept(Chain chain) async { + final request = applyHeader(chain.request, 'auth_token', 'Bearer $token'); + return chain.proceed(request); + } +} ``` ## **Response** -Implement `ResponseInterceptor` class or define function with following signature `FutureOr ResponseInterceptorFunc(Response response)` +Implement `Interceptor` class. {% hint style="info" %} -Called after successful or failed request +Called after successful or failed request. {% endhint %} ```dart -final chopper = ChopperClient( - interceptors: [ - (Response response) async => response.replace(body: {}), - ] -); +class MyResponseInterceptor implements Interceptor { + MyResponseInterceptor(this._token); + + String _token; + + @override + FutureOr> intercept(Chain chain) async { + final response = await chain.proceed(chain.request); + _token = response.headers['auth_token']; + return response; + } +} ``` -## Builtins +## Breaking out of an interceptor + +In some cases you may run into a case where it's not possible to continue within an interceptor and want to break out/cancel the request. This can be achieved by throwing an exception. +This will not return a response and the request will not be executed. + +>Keep in mind that when throwing an exception you also need to handle/catch the exception in calling code. + +For example if you want to stop the request if the token is expired: -* [CurlInterceptor](https://pub.dev/documentation/chopper/latest/chopper/CurlInterceptor-class.html) -* [HttpLoggingInterceptor](https://pub.dev/documentation/chopper/latest/chopper/HttpLoggingInterceptor-class.html) +```dart +class AuthInterceptor implements Interceptor { + + @override + FutureOr> intercept(Chain chain) async { + final request = applyHeader(chain.request, 'authorization', + SharedPrefs.localStorage.getString(tokenHeader), + override: false); + + final response = await chain.proceed(request); + + if (response?.statusCode == 401) { + // Refreshing fails + final bool isRefreshed = await _refreshToken(); + if(!isRefreshed){ + // Throw a exception to stop the request. + throw Exception('Token expired'); + } + } + + return response; + } +} +``` + +It's not strictly needed to throw an exception in order to break out of the interceptor. +Other construction can also be used depending on how the project is structured. +Another could be calling a service that is injected or providing a callback that handles the state of the app. + +## Builtins +* [CurlInterceptor](https://pub.dev/documentation/chopper/latest/chopper/CurlInterceptor-class.html): Interceptor that prints curl commands for each execute request +* [HeadersInterceptor](https://pub.dev/documentation/chopper/latest/chopper/HeadersInterceptor-class.html): Interceptor that adds headers to each request +* [HttpLoggingInterceptor](https://pub.dev/documentation/chopper/latest/chopper/HttpLoggingInterceptor-class.html): Interceptor that logs request and response data Both the `CurlInterceptor` and `HttpLoggingInterceptor` use the dart [logging package](https://pub.dev/packages/logging). In order to see logging in console the logging package also needs to be added to your project and configured.