diff --git a/CHANGELOG.md b/CHANGELOG.md index baef3d55..6834722d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 1.9.5 + +- `APIServer`: + - `_resolveBodyImpl`: + - Log errors while encoding payload to JSON. + - Catch `OutOfMemoryError` and log. + - Return `apiResponse.asError` on errors. + - `_jsonEncodePayload`: + - Now uses `AutoGZipSink` and `Json.encodeToSink` to stream JSON encoding with automatic GZip compression based on output size. + +- Added `AutoGZipSink`, `GZipSink` and `BytesSink` and `BytesBuffer`. + +- `Json`: + - Added `encodeToSink`. + +- reflection_factory: ^2.5.2 +- swiss_knife: ^3.3.3 + +- test: ^1.26.3 + ## 1.9.4 - Main updates (see `v1.9.4-beta.*` for more): diff --git a/lib/src/bones_api_base.dart b/lib/src/bones_api_base.dart index 745daa53..82fc8516 100644 --- a/lib/src/bones_api_base.dart +++ b/lib/src/bones_api_base.dart @@ -42,7 +42,7 @@ typedef APILogger = void Function(APIRoot apiRoot, String type, String? message, /// Bones API Library class. class BonesAPI { // ignore: constant_identifier_names - static const String VERSION = '1.9.4'; + static const String VERSION = '1.9.5'; static bool _boot = false; diff --git a/lib/src/bones_api_server.dart b/lib/src/bones_api_server.dart index d1c84c78..10c3d328 100644 --- a/lib/src/bones_api_server.dart +++ b/lib/src/bones_api_server.dart @@ -32,6 +32,7 @@ import 'bones_api_utils.dart'; import 'bones_api_utils_httpclient.dart'; import 'bones_api_utils_isolate.dart'; import 'bones_api_utils_json.dart'; +import 'bones_api_utils_sink.dart'; final _log = logging.Logger('APIServer'); @@ -1749,39 +1750,81 @@ class APIServer extends _APIServerBase { final jsonEncodeInit = DateTime.now(); try { - var s = _jsonEncodePayload(apiResponse, payload); + // Note: `jsonBytes` may be compressed with GZip. + var jsonBytes = _jsonEncodePayload(apiResponse, payload); var jsonEncodeTime = DateTime.now().difference(jsonEncodeInit); - apiResponse.setMetric('API-response-json', duration: jsonEncodeTime); + apiResponse.setMetric('API-Response-JSON', duration: jsonEncodeTime); apiResponse.payloadMimeType ??= 'application/json'; - return s; - } catch (e) { - var s = payload.toString(); - apiResponse.payloadMimeType ??= - resolveBestTextMimeType(s, apiResponse.payloadFileExtension); - return s; + return jsonBytes; + } on OutOfMemoryError catch (e, s) { + var msg = "`OutOfMemoryError` while encoding payload to JSON!"; + _log.severe(msg, e, s); + return apiResponse.asError(error: '** $msg\n$e', stackTrace: s); + } catch (e, s) { + var msg = "ERROR while encoding payload to JSON!"; + _log.severe(msg, e, s); + return apiResponse.asError(error: '** $msg\n$e', stackTrace: s); } } - static String _jsonEncodePayload(APIResponse apiResponse, payload) { + /// Encodes the given [payload] to JSON and writes it to an [AutoGZipSink]. + /// + /// Returns either raw JSON bytes or GZip-compressed JSON bytes. + static Uint8List _jsonEncodePayload( + APIResponse apiResponse, payload) { final apiRequest = apiResponse.apiRequest; + final encodeInit = DateTime.now(); + final bytesSink = AutoGZipSink(); + if (apiRequest != null) { final routeHandler = apiRequest.routeHandler; - if (routeHandler != null) { var accessRules = routeHandler.entityAccessRules; - if (!accessRules.isInnocuous) { - return Json.encode(payload, + Json.encodeToSink(payload, bytesSink, toEncodableProvider: (o) => accessRules.toJsonEncodable( apiRequest, Json.defaultToEncodableJsonProvider(), o)); + + return _resolveJsonEncodePayloadBytes( + apiResponse, bytesSink, encodeInit); } } } - return Json.encode(payload, toEncodable: ReflectionFactory.toJsonEncodable); + Json.encodeToSink(payload, bytesSink, + toEncodable: ReflectionFactory.toJsonEncodable); + + return _resolveJsonEncodePayloadBytes(apiResponse, bytesSink, encodeInit); + } + + /// Finalizes the JSON encoding and returns the resulting bytes. + /// + /// Closes the [bytesSink] and collects the output. + /// If GZip was applied, adds headers to [apiResponse]. + /// See [_defineGZipEncodedHeaders]. + static Uint8List _resolveJsonEncodePayloadBytes( + APIResponse apiResponse, + AutoGZipSink bytesSink, + DateTime encodeInit) { + bytesSink.close(); + + final bytes = bytesSink.toBytes(); + + if (bytesSink.isGzip) { + var inputLength = bytesSink.inputLength; + var compressionCapacity = bytesSink.capacity; + + _defineGZipEncodedHeaders(apiResponse.headers, + bodyLength: inputLength, + compressedBodyLength: bytes.length, + compressionCapacity: compressionCapacity, + gzipInit: encodeInit); + } + + return bytes; } static final RegExp _htmlTag = RegExp(r'<\w+.*?>'); @@ -1938,9 +1981,17 @@ class APIServer extends _APIServerBase { return def; } - static String resolveServerTiming(Map metrics) { + static String resolveServerTiming(Map metrics, + [String? serverTiming]) { var s = StringBuffer(); + if (serverTiming != null) { + serverTiming = serverTiming.trim(); + if (serverTiming.isNotEmpty) { + s.write(serverTiming); + } + } + for (var e in metrics.entries) { var metric = e.value; @@ -2615,6 +2666,49 @@ final class APIServerWorker extends _APIServerBase { return apiResponse.fileResponse; } + var retPayload = APIServer.resolveBody(apiResponse.payload, apiResponse); + + final serverResponseDelay = this.serverResponseDelay; + if (serverResponseDelay != null && !serverResponseDelay.isNegative) { + final retPayloadOrig = retPayload; + + _log.info( + "[DEV] Response #${apiRequest.id} delayed in ${serverResponseDelay.toStringUnit()}: ${apiRequest.requestedUri}"); + + retPayload = Future.delayed(serverResponseDelay, () async { + var payload = await retPayloadOrig; + return payload; + }); + } + + return retPayload.resolveMapped((payload) { + if (payload is APIResponse) { + var apiResponse2 = payload; + + return APIServer.resolveBody(apiResponse2.payload, apiResponse2) + .resolveMapped((payload2) { + var headers = _resolveAPIResponseHeaders(apiResponse, apiRequest); + + var response = _sendAPIResponse( + request, apiRequest, apiResponse2, headers, payload2); + + apiResponse.disposeAsync(); + + return _processResponse(apiRequest, apiResponse2, request, response); + }); + } else { + var headers = _resolveAPIResponseHeaders(apiResponse, apiRequest); + + var response = _sendAPIResponse( + request, apiRequest, apiResponse, headers, payload); + + return _processResponse(apiRequest, apiResponse, request, response); + } + }); + } + + Map _resolveAPIResponseHeaders( + APIResponse apiResponse, APIRequest apiRequest) { var headers = {}; if (!apiResponse.hasCORS) { @@ -2666,41 +2760,7 @@ final class APIServerWorker extends _APIServerBase { // headers['X-APIToken'] = apiRequest.credential?.token ?? '?'; - var retPayload = APIServer.resolveBody(apiResponse.payload, apiResponse); - - final serverResponseDelay = this.serverResponseDelay; - if (serverResponseDelay != null && !serverResponseDelay.isNegative) { - final retPayloadOrig = retPayload; - - _log.info( - "[DEV] Response #${apiRequest.id} delayed in ${serverResponseDelay.toStringUnit()}: ${apiRequest.requestedUri}"); - - retPayload = Future.delayed(serverResponseDelay, () async { - var payload = await retPayloadOrig; - return payload; - }); - } - - return retPayload.resolveMapped((payload) { - if (payload is APIResponse) { - var apiResponse2 = payload; - - return APIServer.resolveBody(apiResponse2.payload, apiResponse2) - .resolveMapped((payload2) { - var response = _sendAPIResponse( - request, apiRequest, apiResponse2, headers, payload2); - - apiResponse.disposeAsync(); - - return _processResponse(apiRequest, apiResponse2, request, response); - }); - } else { - var response = _sendAPIResponse( - request, apiRequest, apiResponse, headers, payload); - - return _processResponse(apiRequest, apiResponse, request, response); - } - }); + return headers; } FutureOr _processResponse(APIRequest apiRequest, @@ -2777,7 +2837,10 @@ final class APIServerWorker extends _APIServerBase { ? CombinedMapView([apiRequest.metrics, apiResponse.metrics]) : apiResponse.metrics; - headers['server-timing'] = APIServer.resolveServerTiming(allMetrics); + headers['server-timing'] = APIServer.resolveServerTiming( + allMetrics, + headers['server-timing']?.toString(), + ); if (cookieless) { headers.remove(HttpHeaders.setCookieHeader); @@ -2917,3 +2980,64 @@ extension _APIRequestExtension on APIRequest { return Request(method.name, requestedUri); } } + +/// Adds GZip-related headers to the given [headers] map. +/// +/// Sets the `Content-Encoding` and `Content-Length` headers to indicate +/// GZip compression and its size. Optionally adds: +/// +/// - `X-Compression-Ratio`: with detailed compression info +/// - `Server-Timing`: duration of the compression step (if [gzipInit] is provided) +/// +/// Parameters: +/// - [bodyLength]: original uncompressed body size in bytes +/// - [compressedBodyLength]: resulting GZip-compressed size +/// - [compressionCapacity]: internal capacity allocated for compression +/// - [addCompressionRatioHeader]: whether to include `X-Compression-Ratio` +/// - [addServerTiming]: whether to append timing info to `Server-Timing` +/// - [gzipInit]: start time of GZip encoding, required for timing +/// - [serverTimingEntryName]: label used in the `Server-Timing` header +void _defineGZipEncodedHeaders(Map headers, + {required int bodyLength, + required int compressedBodyLength, + required int compressionCapacity, + bool addCompressionRatioHeader = true, + bool addServerTiming = true, + DateTime? gzipInit, + String serverTimingEntryName = 'obj->json->gzip'}) { + headers[HttpHeaders.contentEncodingHeader] = 'gzip'; + headers[HttpHeaders.contentLengthHeader] = compressedBodyLength.toString(); + + if (addCompressionRatioHeader) { + var compressionRatio = compressedBodyLength / bodyLength; + + var compressionRatioStr = '$compressionRatio'; + if (compressionRatioStr.length > 6) { + compressionRatioStr = compressionRatio.toStringAsFixed(4); + } + + headers['X-Compression-Ratio'] = + '$compressionRatioStr ($compressedBodyLength/$bodyLength/$compressionCapacity)'; + } + + if (addServerTiming && gzipInit != null) { + const headerServerTiming = 'server-timing'; + + var gzipTime = DateTime.now().difference(gzipInit); + var dur = gzipTime.inMicroseconds / 1000; + + var serverTiming2 = StringBuffer(); + + var serverTiming = headers[headerServerTiming]?.toString(); + if (serverTiming != null && serverTiming.isNotEmpty) { + serverTiming2.write(serverTiming); + serverTiming2.write(','); + } + + serverTiming2.write(serverTimingEntryName); + serverTiming2.write(';dur='); + serverTiming2.write(dur); + + headers[headerServerTiming] = serverTiming2.toString(); + } +} diff --git a/lib/src/bones_api_utils_json.dart b/lib/src/bones_api_utils_json.dart index 10568038..fa0189e4 100644 --- a/lib/src/bones_api_utils_json.dart +++ b/lib/src/bones_api_utils_json.dart @@ -154,6 +154,32 @@ class Json { pretty: pretty, autoResetEntityCache: autoResetEntityCache); } + /// Sames as [encode] but outputs to [sink]. + static void encodeToSink(Object? o, Sink> sink, + {bool pretty = false, + JsonFieldMatcher? maskField, + String maskText = '***', + JsonFieldMatcher? removeField, + bool removeNullFields = false, + ToEncodableJsonProvider? toEncodableProvider, + ToEncodable? toEncodable, + EntityHandlerProvider? entityHandlerProvider, + EntityCache? entityCache, + bool? autoResetEntityCache}) { + var jsonEncoder = _buildJsonEncoder( + maskField, + maskText, + removeField, + removeNullFields, + toEncodableProvider, + toEncodable, + entityHandlerProvider, + entityCache); + + return jsonEncoder.encodeToSink(o, sink, + pretty: pretty, autoResetEntityCache: autoResetEntityCache); + } + static final JsonEncoder defaultEncoder = JsonEncoder( toEncodableProvider: (o) => _jsonEncodableProvider(o, null), entityCache: JsonEntityCacheSimple(), diff --git a/lib/src/bones_api_utils_sink.dart b/lib/src/bones_api_utils_sink.dart new file mode 100644 index 00000000..aca7d147 --- /dev/null +++ b/lib/src/bones_api_utils_sink.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +/// Automatically switches to GZip compression when input exceeds [minGZipLength]. +class AutoGZipSink extends ByteConversionSinkBuffered { + /// Inner sink that may switch between plain and gzip modes. + ByteConversionSinkBuffered _sink; + + /// Minimum input size to trigger GZip compression. + final int minGZipLength; + + AutoGZipSink({this.minGZipLength = 512, int capacity = 1024}) + : _sink = BytesSink(capacity: capacity); + + @override + int get inputLength => _sink.inputLength; + + bool _gzip = false; + + /// Returns `true` if GZip compression is active. + bool get isGzip => _gzip; + + /// Switches to GZip compression. Keeps previously added data. + void switchToGzip() { + if (_gzip) return; + var prevBytes = _sink.toBytes(copy: true); + _sink.reset(); + _sink = GZipSink(bytesSink: _sink); + _gzip = true; + _sink.add(prevBytes); + } + + @override + void add(List chunk) { + if (!_gzip && _sink.length + chunk.length >= minGZipLength) { + switchToGzip(); + } + _sink.add(chunk); + } + + @override + void close() => _sink.close(); + + @override + Uint8List toBytes({bool copy = false}) => _sink.toBytes(copy: copy); + + @override + int get capacity => _sink.capacity; + + @override + int get length => _sink.length; + + /// Resets internal state and disables GZip if active. + @override + void reset() { + if (!_gzip) { + _sink.reset(); + } else { + var gzipSink = _sink as GZipSink; + gzipSink.close(); // Avoid memory leak + _sink = gzipSink._bytesSink; + _sink.reset(); + _gzip = false; + } + } +} + +/// Wraps a sink to apply GZip compression using [ZLibEncoder]. +class GZipSink extends ByteConversionSinkBuffered { + final ByteConversionSinkBuffered _bytesSink; + late ByteConversionSink _gzipSink; + final int level; + + GZipSink({ + this.level = 4, + ByteConversionSinkBuffered? bytesSink, + int capacity = 1024 * 4, + }) : _bytesSink = bytesSink ?? BytesSink(capacity: capacity) { + _gzipSink = _createGZipEncoder(level); + } + + ByteConversionSink _createGZipEncoder(int level) => ZLibEncoder( + gzip: true, + level: level, + windowBits: ZLibOption.defaultWindowBits, + memLevel: ZLibOption.defaultMemLevel, + strategy: ZLibOption.strategyDefault, + dictionary: null, + raw: false, + ).startChunkedConversion(_bytesSink); + + @override + int get length => _bytesSink.length; + + @override + int get capacity => _bytesSink.capacity; + + int _inputLength = 0; + + @override + int get inputLength => _inputLength; + + @override + void add(List chunk) { + _inputLength += chunk.length; + _gzipSink.add(chunk); + } + + @override + void close() => _gzipSink.close(); + + @override + Uint8List toBytes({bool copy = false}) => _bytesSink.toBytes(); + + /// Resets the compressor and underlying buffer. + @override + void reset() { + _gzipSink.close(); + _bytesSink.reset(); + _gzipSink = _createGZipEncoder(level); + _inputLength = 0; + } +} + +/// Buffer that accumulates bytes and supports reset and conversion to [Uint8List]. +class BytesSink extends BytesBuffer implements ByteConversionSinkBuffered { + BytesSink({super.capacity}); + + @override + int get inputLength => length; + + @override + void close() {} + + @override + void addSlice(List chunk, int start, int end, bool isLast) { + if (start != 0 || end != chunk.length) { + chunk = chunk.sublist(start, end); + } + add(chunk); + if (isLast) close(); + } +} + +/// Base class for sinks with tracking and reset support. +abstract class ByteConversionSinkBuffered extends ByteConversionSink { + int get length; + int get capacity; + int get inputLength; + + Uint8List toBytes({bool copy = false}); + + void reset(); +} + +/// A growing byte buffer with auto-resize and slice access. +class BytesBuffer { + Uint8List _buffer; + int _length = 0; + + BytesBuffer({int capacity = 1204 * 4}) : _buffer = Uint8List(capacity); + + int get length => _length; + + int get capacity => _buffer.length; + + /// Returns buffer contents. May return internal buffer if fully used and [copy] is false. + Uint8List toBytes({bool copy = false}) { + if (!copy && _length == _buffer.length) { + return _buffer; + } + return Uint8List.sublistView(_buffer, 0, _length); + } + + /// Adds [bytes] to buffer, resizing if needed. + void add(List bytes) { + final chunkLength = bytes.length; + final requiredLength = _length + chunkLength; + if (requiredLength > _buffer.length) { + _increaseCapacity(requiredLength); + } + _buffer.setRange(_length, _length + chunkLength, bytes); + _length += chunkLength; + } + + void _increaseCapacity(int requiredLength) { + final newLength = computeNewLength(_buffer.length, requiredLength); + assert(newLength >= requiredLength); + final newBuffer = Uint8List(newLength); + newBuffer.setRange(0, _length, _buffer); + _buffer = newBuffer; + } + + /// Calculates next buffer size based on usage pattern. + int computeNewLength(int prevLength, int requiredLength) { + assert(prevLength < requiredLength); + + if (requiredLength < 1024 * 1024 * 8) { + var newLength = prevLength * 2; + while (newLength < requiredLength) { + newLength *= 2; + } + return newLength; + } else if (requiredLength < 1024 * 1024 * 32) { + var newLength = (prevLength * 1.5).toInt(); + while (newLength < requiredLength) { + newLength = (newLength * 1.5).toInt(); + } + return newLength; + } else { + var newLength = (prevLength * 1.25).toInt(); + while (newLength < requiredLength) { + newLength = (newLength * 1.25).toInt(); + } + return newLength; + } + } + + /// Clears the buffer content without releasing memory. + void reset() { + _length = 0; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 0e80b8f4..4ce7b102 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bones_api description: Bones_API - A powerful API backend framework for Dart. It comes with a built-in HTTP Server, route handler, entity handler, SQL translator, and DB adapters. -version: 1.9.4 +version: 1.9.5 homepage: https://github.com/Colossus-Services/bones_api environment: @@ -12,9 +12,9 @@ executables: dependencies: async_extension: ^1.2.15 async_events: ^1.3.0 - reflection_factory: ^2.5.1 + reflection_factory: ^2.5.2 statistics: ^1.2.0 - swiss_knife: ^3.3.0 + swiss_knife: ^3.3.3 data_serializer: ^1.2.1 shared_map: ^1.1.9 graph_explorer: ^1.0.2 @@ -55,7 +55,7 @@ dev_dependencies: lints: ^5.1.1 build_runner: ^2.4.15 build_verify: ^3.1.1 - test: ^1.26.2 + test: ^1.26.3 pubspec: ^2.3.0 dependency_validator: ^4.1.3 coverage: ^1.15.0 diff --git a/test/bones_api_test.reflection.g.dart b/test/bones_api_test.reflection.g.dart index 6ee7c5e0..888537bf 100644 --- a/test/bones_api_test.reflection.g.dart +++ b/test/bones_api_test.reflection.g.dart @@ -1,6 +1,6 @@ // // GENERATED CODE - DO NOT MODIFY BY HAND! -// BUILDER: reflection_factory/2.5.1 +// BUILDER: reflection_factory/2.5.2 // BUILD COMMAND: dart run build_runner build // @@ -22,7 +22,7 @@ typedef __TI = TypeInfo; typedef __PR = ParameterReflection; mixin __ReflectionMixin { - static final Version _version = Version.parse('2.5.1'); + static final Version _version = Version.parse('2.5.2'); Version get reflectionFactoryVersion => _version; diff --git a/test/bones_api_test_entities.reflection.g.dart b/test/bones_api_test_entities.reflection.g.dart index 8bfd14fd..fea23953 100644 --- a/test/bones_api_test_entities.reflection.g.dart +++ b/test/bones_api_test_entities.reflection.g.dart @@ -1,6 +1,6 @@ // // GENERATED CODE - DO NOT MODIFY BY HAND! -// BUILDER: reflection_factory/2.5.1 +// BUILDER: reflection_factory/2.5.2 // BUILD COMMAND: dart run build_runner build // @@ -22,7 +22,7 @@ typedef __TI = TypeInfo; typedef __PR = ParameterReflection; mixin __ReflectionMixin { - static final Version _version = Version.parse('2.5.1'); + static final Version _version = Version.parse('2.5.2'); Version get reflectionFactoryVersion => _version; diff --git a/test/bones_api_test_modules.reflection.g.dart b/test/bones_api_test_modules.reflection.g.dart index 1c3e6f2a..cc113d41 100644 --- a/test/bones_api_test_modules.reflection.g.dart +++ b/test/bones_api_test_modules.reflection.g.dart @@ -1,6 +1,6 @@ // // GENERATED CODE - DO NOT MODIFY BY HAND! -// BUILDER: reflection_factory/2.5.1 +// BUILDER: reflection_factory/2.5.2 // BUILD COMMAND: dart run build_runner build // @@ -22,7 +22,7 @@ typedef __TI = TypeInfo; typedef __PR = ParameterReflection; mixin __ReflectionMixin { - static final Version _version = Version.parse('2.5.1'); + static final Version _version = Version.parse('2.5.2'); Version get reflectionFactoryVersion => _version; diff --git a/test/bones_api_test_utils_test.reflection.g.dart b/test/bones_api_test_utils_test.reflection.g.dart index 24a6412f..4aeec2d7 100644 --- a/test/bones_api_test_utils_test.reflection.g.dart +++ b/test/bones_api_test_utils_test.reflection.g.dart @@ -1,6 +1,6 @@ // // GENERATED CODE - DO NOT MODIFY BY HAND! -// BUILDER: reflection_factory/2.5.1 +// BUILDER: reflection_factory/2.5.2 // BUILD COMMAND: dart run build_runner build // @@ -22,7 +22,7 @@ typedef __TI = TypeInfo; typedef __PR = ParameterReflection; mixin __ReflectionMixin { - static final Version _version = Version.parse('2.5.1'); + static final Version _version = Version.parse('2.5.2'); Version get reflectionFactoryVersion => _version; diff --git a/test/bones_api_utils_sink_test.dart b/test/bones_api_utils_sink_test.dart new file mode 100644 index 00000000..726ac963 --- /dev/null +++ b/test/bones_api_utils_sink_test.dart @@ -0,0 +1,269 @@ +@TestOn('vm') +import 'dart:io'; + +import 'package:bones_api/src/bones_api_utils_sink.dart'; +import 'package:test/test.dart'; + +void main() { + group('BytesBuffer', () { + test('basic', () { + var bs = BytesBuffer(); + + expect(bs.length, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.length, equals(3)); + expect(bs.toBytes(), equals([1, 2, 3])); + + bs.add([4, 5, 6, 7, 8, 9, 10]); + expect(bs.length, equals(10)); + expect(bs.toBytes(), equals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); + + bs.reset(); + expect(bs.length, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([10, 20, 30]); + expect(bs.length, equals(3)); + expect(bs.toBytes(), equals([10, 20, 30])); + }); + }); + + group('BytesSink', () { + test('basic', () { + var bs = BytesSink(capacity: 8); + + expect(bs.capacity, equals(8)); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.length, equals(3)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals([1, 2, 3])); + + bs.add([4, 5, 6, 7, 8, 9, 10]); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(10)); + expect(bs.toBytes(), equals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); + + bs.addSlice([100, 101, 102], 1, 3, false); + expect(bs.capacity, equals(16)); + expect(bs.length, equals(12)); + expect(bs.inputLength, equals(12)); + expect(bs.toBytes(), equals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 101, 102])); + + bs.reset(); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([10, 20, 30]); + expect(bs.length, equals(3)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals([10, 20, 30])); + }); + }); + + group('GZipSink', () { + test('basic', () { + var gzipHeader = _getGZipHeader(); + + var bs = GZipSink(capacity: 8); + + expect(bs.capacity, equals(8)); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.capacity, equals(16)); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.add([4, 5, 6, 7, 8, 9, 10]); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(10)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.addSlice([100, 101, 102], 1, 3, false); + expect(bs.capacity, equals(16)); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(12)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.close(); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(32)); + expect(bs.inputLength, equals(12)); + print(bs.toBytes()); + expect( + bs.toBytes(), + equals([ + ...gzipHeader, + 99, + 100, + 98, + 102, + 97, + 101, + 99, + 231, + 224, + 228, + 74, + 77, + 3, + 0, + 58, + 8, + 70, + 196, + 12, + 0, + 0, + 0 + ])); + + bs.reset(); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.close(); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(23)); + expect(bs.inputLength, equals(3)); + expect( + bs.toBytes(), + equals([ + ...gzipHeader, + 99, + 100, + 98, + 6, + 0, + 29, + 128, + 188, + 85, + 3, + 0, + 0, + 0 + ])); + }); + }); + + group('AutoGZipSink', () { + test('basic', () { + var gzipHeader = _getGZipHeader(); + + var bs = AutoGZipSink(minGZipLength: 10, capacity: 8); + + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(8)); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(8)); + expect(bs.length, equals(3)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals([1, 2, 3])); + + bs.add([4, 5, 6, 7, 8]); + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(8)); + expect(bs.length, equals(8)); + expect(bs.inputLength, equals(8)); + expect(bs.toBytes(), equals([1, 2, 3, 4, 5, 6, 7, 8])); + + bs.add([9, 10]); + expect(bs.isGzip, isTrue); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(10)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.addSlice([100, 101, 102], 1, 3, false); + expect(bs.isGzip, isTrue); + expect(bs.capacity, equals(16)); + expect(bs.length, equals(10)); + expect(bs.inputLength, equals(12)); + expect(bs.toBytes(), equals(gzipHeader)); + + bs.close(); + expect(bs.isGzip, isTrue); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(32)); + expect(bs.inputLength, equals(12)); + print(bs.toBytes()); + expect( + bs.toBytes(), + equals([ + ...gzipHeader, + 99, + 100, + 98, + 102, + 97, + 101, + 99, + 231, + 224, + 228, + 74, + 77, + 3, + 0, + 58, + 8, + 70, + 196, + 12, + 0, + 0, + 0 + ])); + + bs.reset(); + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(0)); + expect(bs.inputLength, equals(0)); + expect(bs.toBytes(), equals([])); + + bs.add([1, 2, 3]); + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(3)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals([1, 2, 3])); + + bs.close(); + expect(bs.isGzip, isFalse); + expect(bs.capacity, equals(32)); + expect(bs.length, equals(3)); + expect(bs.inputLength, equals(3)); + expect(bs.toBytes(), equals([1, 2, 3])); + }); + }); +} + +List _getGZipHeader() { + //[31, 139, 8, 0, 0, 0, 0, 0, 0, 19] + var compressed = gzip.encode([1, 2, 3]); + return compressed.sublist(0, 10); +}