From b22738d7cdf007dfc0eb92b24a1a2f8302b5e1ac Mon Sep 17 00:00:00 2001 From: Yaroslav Vorobev Date: Thu, 2 Feb 2023 21:39:46 +0300 Subject: [PATCH] feat: redirect policy and request streaming --- example/fetch_client_redirect_modes.dart | 16 ++++ example/fetch_client_stream_request.dart | 48 ++++++++++ lib/fetch_client.dart | 1 + lib/src/fetch_client.dart | 108 ++++++++++++++++++++--- lib/src/redirect_policy.dart | 28 ++++++ pubspec.yaml | 4 +- test/client_conformance_test.dart | 39 ++++++++ 7 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 example/fetch_client_redirect_modes.dart create mode 100644 example/fetch_client_stream_request.dart create mode 100644 lib/src/redirect_policy.dart diff --git a/example/fetch_client_redirect_modes.dart b/example/fetch_client_redirect_modes.dart new file mode 100644 index 0000000..74b8446 --- /dev/null +++ b/example/fetch_client_redirect_modes.dart @@ -0,0 +1,16 @@ +import 'package:fetch_client/fetch_client.dart'; +import 'package:http/http.dart'; + + +void main() async { + final client = FetchClient( + mode: RequestMode.cors, + redirectPolicy: RedirectPolicy.probeHead, // or RedirectPolicy.probe + ); + final uri = Uri.https('jsonplaceholder.typicode.com', 'guide'); + final response = await client.send( + Request('GET', uri)..followRedirects = false, + ); + + print(response.headers['location']); +} diff --git a/example/fetch_client_stream_request.dart b/example/fetch_client_stream_request.dart new file mode 100644 index 0000000..0af48c7 --- /dev/null +++ b/example/fetch_client_stream_request.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fetch_client/fetch_client.dart'; +import 'package:http/http.dart'; + + +void main(List args) async { + final client = FetchClient( + mode: RequestMode.cors, + streamRequests: true, + ); + + final uri = Uri.https('api.restful-api.dev', 'objects'); + + final stream = (() async* { + yield Uint8List.fromList( + ''' + { + "name": "My cool data", + "data": { + "data_part_1": "part_1", + '''.codeUnits, + ); + await Future.delayed(const Duration(seconds: 1)); + yield Uint8List.fromList( + ''' + "data_part_2": "part_2" + } + } + '''.codeUnits, + ); + })(); + + final request = StreamedRequest('POST', uri)..headers.addAll({ + 'content-type': 'application/json', + }); + + stream.listen( + request.sink.add, + onDone: request.sink.close, + onError: request.sink.addError, + ); + + final response = await client.send(request); + + print(await utf8.decodeStream(response.stream)); +} diff --git a/lib/fetch_client.dart b/lib/fetch_client.dart index 23c4b15..bd19b79 100644 --- a/lib/fetch_client.dart +++ b/lib/fetch_client.dart @@ -7,3 +7,4 @@ export 'package:fetch_api/fetch_api.dart' show RequestCache, RequestCredentials, export 'src/fetch_client.dart'; export 'src/fetch_response.dart'; +export 'src/redirect_policy.dart'; diff --git a/lib/src/fetch_client.dart b/lib/src/fetch_client.dart index 8547705..afc07e1 100644 --- a/lib/src/fetch_client.dart +++ b/lib/src/fetch_client.dart @@ -3,6 +3,7 @@ import 'package:fetch_api/fetch_api.dart'; import 'package:http/http.dart' show BaseClient, BaseRequest, ClientException; import 'fetch_response.dart'; import 'on_done.dart'; +import 'redirect_policy.dart'; /// HTTP client based on Fetch API. @@ -10,12 +11,12 @@ import 'on_done.dart'; /// /// This implementation has some restrictions: /// * [BaseRequest.persistentConnection] is translated to -/// [RequestInit.keepalive]. +/// [FetchOptions.keepalive] (if [streamRequests] is disabled). /// * [BaseRequest.contentLength] is ignored. -/// * When [BaseRequest.followRedirects] is `false` request will throw, but -/// without any helpful information (this is the limitation of Fetch, if you -/// need to get target URL, rely on [FetchResponse.redirected] and -/// [FetchResponse.url]). +/// * When [BaseRequest.followRedirects] is `true` you can get redirect +/// information via [FetchResponse.redirected] and [FetchResponse.url]). +/// If [BaseRequest.followRedirects] is `false` [redirectPolicy] takes place +/// and dictates [FetchClient] actions. /// * [BaseRequest.maxRedirects] is ignored. class FetchClient extends BaseClient { FetchClient({ @@ -25,6 +26,8 @@ class FetchClient extends BaseClient { this.referrer = '', this.referrerPolicy = RequestReferrerPolicy.strictOriginWhenCrossOrigin, this.integrity = '', + this.redirectPolicy = RedirectPolicy.alwaysFollow, + this.streamRequests = false, }); /// The mode you want to use for the request @@ -49,6 +52,16 @@ class FetchClient extends BaseClient { /// (e.g.,`sha256-BpfBw7ivV8q2jLiT13fxDYAe2tJllusRSZ273h2nFSE=`) final String integrity; + /// Client redirect policy, defines how client should handle + /// [BaseRequest.followRedirects]. + final RedirectPolicy redirectPolicy; + + /// Whether to use [ReadableStream] as body for requests streaming. + /// + /// This feature is unsupported in old browsers, check + /// [compatibility chart](https://developer.mozilla.org/en-US/docs/Web/API/Request#browser_compatibility). + final bool streamRequests; + final _abortCallbacks = []; var _closed = false; @@ -58,27 +71,54 @@ class FetchClient extends BaseClient { if (_closed) throw ClientException('Client is closed', request.url); - final body = await request.finalize().toBytes(); + final byteStream = request.finalize(); + final dynamic body; + if (['GET', 'HEAD'].contains(request.method.toUpperCase())) + body = null; + else if (streamRequests) { + body = fetch_compatibility_layer.createReadableStream( + fetch_compatibility_layer.createReadableStreamSourceFromStream( + byteStream, + ), + ); + } else { + final bytes = await byteStream.toBytes(); + body = bytes.isEmpty ? null : bytes; + } + final abortController = AbortController(); - final init = fetch_compatibility_layer.createRequestInit( - body: body.isEmpty ? null : body, + final init = fetch_compatibility_layer.createFetchOptions( + body: body, method: request.method, - redirect: request.followRedirects + redirect: (request.followRedirects || redirectPolicy == RedirectPolicy.alwaysFollow) ? RequestRedirect.follow - : RequestRedirect.error, + : RequestRedirect.manual, headers: fetch_compatibility_layer.createHeadersFromMap(request.headers), mode: mode, credentials: credentials, cache: cache, referrer: referrer, referrerPolicy: referrerPolicy, - keepalive: request.persistentConnection, + keepalive: !streamRequests && request.persistentConnection, signal: abortController.signal, + duplex: !streamRequests ? null : RequestDuplex.half, ); final Response response; try { response = await fetch(request.url.toString(), init); + + if ( + response.type == 'opaqueredirect' && + !request.followRedirects && + redirectPolicy != RedirectPolicy.alwaysFollow + ) + return _probeRedirect( + request: request, + initialResponse: response, + init: init, + abortController: abortController, + ); } catch (e) { throw ClientException('Failed to execute fetch: $e', request.url); } @@ -112,8 +152,6 @@ class FetchClient extends BaseClient { headers: { for (final header in response.headers.entries()) header.first: header.last, - if (response.redirected) - 'location': response.url, }, isRedirect: false, persistentConnection: false, @@ -123,6 +161,50 @@ class FetchClient extends BaseClient { ); } + /// Makes probe request and returns "redirect" response. + Future _probeRedirect({ + required BaseRequest request, + required Response initialResponse, + required FetchOptions init, + required AbortController abortController, + }) async { + init.requestRedirect = RequestRedirect.follow; + + if (redirectPolicy == RedirectPolicy.probeHead) + init.method = 'HEAD'; + else + init.method = 'GET'; + + final Response response; + try { + response = await fetch(request.url.toString(), init); + + // Cancel before even reading response + if (redirectPolicy == RedirectPolicy.probe) + abortController.abort(); + } catch (e) { + throw ClientException('Failed to execute probe fetch: $e', request.url); + } + + return FetchResponse( + const Stream.empty(), + 302, + cancel: () {}, + url: initialResponse.url, + redirected: false, + request: request, + headers: { + for (final header in response.headers.entries()) + header.first: header.last, + 'location': response.url, + }, + isRedirect: true, + persistentConnection: false, + reasonPhrase: 'Found', + contentLength: null, + ); + } + /// Closes the client. /// /// This terminates all active requests. diff --git a/lib/src/redirect_policy.dart b/lib/src/redirect_policy.dart new file mode 100644 index 0000000..43d2ba7 --- /dev/null +++ b/lib/src/redirect_policy.dart @@ -0,0 +1,28 @@ +import 'package:fetch_api/fetch_api.dart' show RequestRedirect, ResponseInstanceMembers; + +import 'fetch_client.dart'; +import 'fetch_response.dart'; + + +/// How [FetchClient] should handle redirects. +enum RedirectPolicy { + /// Default policy - always follow redirects. + /// If redirect is occurred the only way to know about it is via + /// [FetchResponse.redirected] and [FetchResponse.url]. + alwaysFollow, + /// Probe via HTTP `GET` request. + /// + /// Is this mode request is made with [RequestRedirect.manual], with + /// no redirects, normal response is returned as usual. + /// + /// If redirect is occurred additional `GET` request will be send and canceled + /// before body will be available. Returning response with only headers and + /// artificial `Location` header crafted from [ResponseInstanceMembers.url]. + /// + /// Note that such response will always be crafted as `302 Found` and there's + /// no way to get intermediate redirects, so you will get target redirect as + /// if the original server returned it. + probe, + /// Same as [probe] but using `HEAD` method and therefore no cancel is needed. + probeHead; +} diff --git a/pubspec.yaml b/pubspec.yaml index d6665fc..3620005 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,8 +21,8 @@ dependency_overrides: # git: https://github.com/Zekfad/fetch_api dev_dependencies: - build_runner: ^2.3.3 - build_web_compilers: ^3.2.7 + build_runner: ^2.4.0 + build_web_compilers: ^4.0.0 http_client_conformance_tests: git: url: https://github.com/dart-lang/http diff --git a/test/client_conformance_test.dart b/test/client_conformance_test.dart index 17c8afc..e1e395a 100644 --- a/test/client_conformance_test.dart +++ b/test/client_conformance_test.dart @@ -15,4 +15,43 @@ void main() { redirectAlwaysAllowed: true, ); }); + + group('client conformance tests with probe mode', () { + testAll( + () => FetchClient( + mode: RequestMode.cors, + redirectPolicy: RedirectPolicy.probe, + ), + canStreamRequestBody: false, + canStreamResponseBody: true, + redirectAlwaysAllowed: true, + ); + }); + + group('client conformance tests with probeHead mode', () { + testAll( + () => FetchClient( + mode: RequestMode.cors, + redirectPolicy: RedirectPolicy.probeHead, + ), + canStreamRequestBody: false, + canStreamResponseBody: true, + redirectAlwaysAllowed: true, + ); + }); + + // Fails with ERR_H2_OR_QUIC_REQUIRED + // That means server must support request streaming is some special form + // or something. + // group('client conformance tests with streaming mode', () { + // testAll( + // () => FetchClient( + // mode: RequestMode.cors, + // streamRequests: true, + // ), + // canStreamRequestBody: true, + // canStreamResponseBody: true, + // redirectAlwaysAllowed: true, + // ); + // }); }