diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index ee8bf11a1..edfc586db 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -5,7 +5,9 @@ See the [Migration Guide][] for the complete breaking changes list.** ## Unreleased -*None.* +- Graceful handling of responses with nonzero `Content-Length`, `Content-Type` json, but empty body + - Empty responses are now transformed to `null` + ## 5.6.0 diff --git a/dio/lib/src/transformers/fused_transformer.dart b/dio/lib/src/transformers/fused_transformer.dart index 7ce571a90..1ad559b75 100644 --- a/dio/lib/src/transformers/fused_transformer.dart +++ b/dio/lib/src/transformers/fused_transformer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -7,6 +8,7 @@ import '../headers.dart'; import '../options.dart'; import '../transformer.dart'; import 'util/consolidate_bytes.dart'; +import 'util/transform_empty_to_null.dart'; /// A [Transformer] that has a fast path for decoding UTF8-encoded JSON. /// If the response is utf8-encoded JSON and no custom decoder is specified in the [RequestOptions], this transformer @@ -118,11 +120,6 @@ class FusedTransformer extends Transformer { // of the response or the length of the eagerly decoded response bytes final int contentLength; - // Successful HEAD requests don't have a response body, even if the content-length header - // present. - final mightNotHaveResponseBodyDespiteContentLength = - options.method == 'HEAD'; - // The eagerly decoded response bytes // which is set if the content length is not specified and // null otherwise (we'll feed the stream directly to the decoder in that case) @@ -131,8 +128,7 @@ class FusedTransformer extends Transformer { // If the content length is not specified, we need to consolidate the stream // and count the bytes to determine if we should use an isolate // otherwise we use the content length header - if (!hasContentLengthHeader || - mightNotHaveResponseBodyDespiteContentLength) { + if (!hasContentLengthHeader) { responseBytes = await consolidateBytes(responseBody.stream); contentLength = responseBytes.length; } else { @@ -153,13 +149,7 @@ class FusedTransformer extends Transformer { responseBytes ?? await consolidateBytes(responseBody.stream), ); } else { - if (!hasContentLengthHeader || contentLength == 0) { - // This path is for backwards compatibility. - // If content-type indicates a json response, - // but the body is empty, null is returned. - // _utf8JsonDecoder.bind(responseBody.stream) would throw if the body is empty. - // So we need to check if the body is empty and return null in that case - responseBytes ??= await consolidateBytes(responseBody.stream); + if (responseBytes != null) { if (responseBytes.isEmpty) { return null; } @@ -168,7 +158,11 @@ class FusedTransformer extends Transformer { assert(responseBytes == null); // The content length is specified and we can feed the stream directly to the decoder, // without eagerly decoding the response bytes first. - final decodedStream = _utf8JsonDecoder.bind(responseBody.stream); + // If the response is empty, return null; + // This is done by the DefaultNullIfEmptyStreamTransformer + final streamWithNullFallback = responseBody.stream + .transform(const DefaultNullIfEmptyStreamTransformer()); + final decodedStream = _utf8JsonDecoder.bind(streamWithNullFallback); final decoded = await decodedStream.toList(); if (decoded.isEmpty) { return null; diff --git a/dio/lib/src/transformers/util/transform_empty_to_null.dart b/dio/lib/src/transformers/util/transform_empty_to_null.dart new file mode 100644 index 000000000..a61d16625 --- /dev/null +++ b/dio/lib/src/transformers/util/transform_empty_to_null.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'dart:typed_data'; + +/// A [StreamTransformer] that replaces an empty stream of Uint8List with a default value +/// - the utf8-encoded string "null". +/// Feeding an empty stream to a JSON decoder will throw an exception, so this transformer +/// is used to prevent that; the JSON decoder will instead return null. +class DefaultNullIfEmptyStreamTransformer + extends StreamTransformerBase { + const DefaultNullIfEmptyStreamTransformer(); + + @override + Stream bind(Stream stream) { + return Stream.eventTransformed( + stream, + (sink) => _DefaultIfEmptyStreamSink(sink), + ); + } +} + +class _DefaultIfEmptyStreamSink implements EventSink { + _DefaultIfEmptyStreamSink(this._outputSink); + + /// Hard-coded constant for replacement value, "null" + static final Uint8List _nullUtf8Value = + Uint8List.fromList(const [110, 117, 108, 108]); + + final EventSink _outputSink; + bool _hasData = false; + + @override + void add(Uint8List data) { + _hasData = _hasData || data.isNotEmpty; + _outputSink.add(data); + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + if (!_hasData) { + _outputSink.add(_nullUtf8Value); + } + + _outputSink.close(); + } +} diff --git a/dio/test/transformer_test.dart b/dio/test/transformer_test.dart index 0175531f7..eec01e6e0 100644 --- a/dio/test/transformer_test.dart +++ b/dio/test/transformer_test.dart @@ -107,6 +107,30 @@ void main() { expect(response, {'foo': 'bar'}); }); + test('transformResponse transforms json array', () async { + final transformer = FusedTransformer(); + const jsonString = '[{"foo": "bar"}]'; + final response = await transformer.transformResponse( + RequestOptions(responseType: ResponseType.json), + ResponseBody.fromString( + jsonString, + 200, + headers: { + Headers.contentTypeHeader: ['application/json'], + Headers.contentLengthHeader: [ + utf8.encode(jsonString).length.toString(), + ], + }, + ), + ); + expect( + response, + [ + {'foo': 'bar'}, + ], + ); + }); + test('transforms json in background isolate', () async { final transformer = FusedTransformer(contentLengthIsolateThreshold: 0); final jsonString = '{"foo": "bar"}'; @@ -299,6 +323,25 @@ void main() { expect(response, null); }, ); + + test( + 'can handle status 304 responses with content-length but empty body', + () async { + final transformer = FusedTransformer(); + final response = await transformer.transformResponse( + RequestOptions(responseType: ResponseType.json), + ResponseBody( + Stream.value(Uint8List(0)), + 304, + headers: { + Headers.contentTypeHeader: ['application/json'], + Headers.contentLengthHeader: ['123'], + }, + ), + ); + expect(response, null); + }, + ); }); group('consolidate bytes', () {