Skip to content

Commit

Permalink
feat: redirect policy and request streaming
Browse files Browse the repository at this point in the history
  • Loading branch information
Zekfad committed Feb 2, 2023
1 parent 7adf81e commit b22738d
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 15 deletions.
16 changes: 16 additions & 0 deletions example/fetch_client_redirect_modes.dart
Original file line number Diff line number Diff line change
@@ -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']);
}
48 changes: 48 additions & 0 deletions example/fetch_client_stream_request.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<void>.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));
}
1 change: 1 addition & 0 deletions lib/fetch_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
108 changes: 95 additions & 13 deletions lib/src/fetch_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ 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.
/// It does support streaming and can handle non 200 responses.
///
/// 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({
Expand All @@ -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
Expand All @@ -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 = <void Function()>[];

var _closed = false;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -123,6 +161,50 @@ class FetchClient extends BaseClient {
);
}

/// Makes probe request and returns "redirect" response.
Future<FetchResponse> _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.
Expand Down
28 changes: 28 additions & 0 deletions lib/src/redirect_policy.dart
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/client_conformance_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
// );
// });
}

0 comments on commit b22738d

Please sign in to comment.