From 50d8ed04d120c7e6d766a2beaf6095c977b24ace Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 25 Aug 2026 15:40:43 +0200 Subject: [PATCH] Add StreamDateTimeConverter --- packages/stream_core/CHANGELOG.md | 1 + packages/stream_core/lib/src/api.dart | 1 + .../src/api/stream_datetime_converter.dart | 27 ++++ .../api/stream_datetime_converter_test.dart | 136 ++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 packages/stream_core/lib/src/api/stream_datetime_converter.dart create mode 100644 packages/stream_core/test/api/stream_datetime_converter_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index dad8ab25..f05d1b20 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -16,6 +16,7 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class +- Added `StreamDateTimeConverter`, a `JsonConverter` for the API's `DateTime` fields. Accepts either an RFC3339 string (v1) or epoch nanoseconds (v2) when deserializing, and always serializes to RFC3339. Values are normalized to UTC with microsecond precision ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/api.dart b/packages/stream_core/lib/src/api.dart index 56e7ec85..8e26ae17 100644 --- a/packages/stream_core/lib/src/api.dart +++ b/packages/stream_core/lib/src/api.dart @@ -6,5 +6,6 @@ export 'api/interceptors/headers_interceptor.dart'; export 'api/interceptors/logging_interceptor.dart'; export 'api/stream_core_dio_error.dart'; export 'api/stream_core_http_client.dart'; +export 'api/stream_datetime_converter.dart'; export 'api/system_environment.dart'; export 'api/system_environment_manager.dart'; diff --git a/packages/stream_core/lib/src/api/stream_datetime_converter.dart b/packages/stream_core/lib/src/api/stream_datetime_converter.dart new file mode 100644 index 00000000..02783691 --- /dev/null +++ b/packages/stream_core/lib/src/api/stream_datetime_converter.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// A [JsonConverter] for the API's [DateTime] fields. +/// +/// Responses carry an RFC3339 string (v1) or epoch nanoseconds (v2), so +/// [fromJson] accepts either; requests are always RFC3339. Precision is +/// microseconds, the finest unit [DateTime] supports. +class StreamDateTimeConverter implements JsonConverter { + const StreamDateTimeConverter(); + + @override + DateTime fromJson(Object json) { + if (json is String) { + return DateTime.parse(json).toUtc(); + } + + if (json is num) { + // Epoch nanoseconds -> microseconds. + return DateTime.fromMicrosecondsSinceEpoch(json ~/ 1000, isUtc: true); + } + + throw FormatException('Unsupported DateTime JSON type: ${json.runtimeType}', json); + } + + @override + String toJson(DateTime object) => object.toUtc().toIso8601String(); +} diff --git a/packages/stream_core/test/api/stream_datetime_converter_test.dart b/packages/stream_core/test/api/stream_datetime_converter_test.dart new file mode 100644 index 00000000..6f6f1c5d --- /dev/null +++ b/packages/stream_core/test/api/stream_datetime_converter_test.dart @@ -0,0 +1,136 @@ +// Epoch nanoseconds are inherently larger than a JS number can hold exactly, +// so the v2 timestamps below are spelled out as literals regardless. +// ignore_for_file: avoid_js_rounded_ints + +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + const converter = StreamDateTimeConverter(); + + group('fromJson', () { + test('parses an RFC3339 string in UTC', () { + final result = converter.fromJson('2024-01-15T10:30:00Z'); + + expect(result, DateTime.utc(2024, 1, 15, 10, 30)); + expect(result.isUtc, isTrue); + }); + + test('normalizes an RFC3339 string with an offset to UTC', () { + final result = converter.fromJson('2024-01-15T10:30:00+02:00'); + + expect(result, DateTime.utc(2024, 1, 15, 8, 30)); + expect(result.isUtc, isTrue); + }); + + test('normalizes an RFC3339 string with a negative offset to UTC', () { + final result = converter.fromJson('2024-01-15T10:30:00-05:30'); + + expect(result, DateTime.utc(2024, 1, 15, 16)); + expect(result.isUtc, isTrue); + }); + + test('converts a zoneless string from local time to UTC', () { + final result = converter.fromJson('2024-01-15T10:30:00'); + + expect(result, DateTime(2024, 1, 15, 10, 30).toUtc()); + expect(result.isUtc, isTrue); + }); + + test('keeps microsecond precision when parsing a string', () { + final result = converter.fromJson('2024-01-15T10:30:00.123456Z'); + + expect(result.microsecondsSinceEpoch, DateTime.utc(2024, 1, 15, 10, 30).microsecondsSinceEpoch + 123456); + }); + + test('parses a date-only string as local midnight converted to UTC', () { + final result = converter.fromJson('2024-01-15'); + + expect(result, DateTime(2024, 1, 15).toUtc()); + expect(result.isUtc, isTrue); + }); + + test('converts epoch nanoseconds to a UTC DateTime', () { + final result = converter.fromJson(1705314600000000000); + + expect(result, DateTime.utc(2024, 1, 15, 10, 30)); + expect(result.isUtc, isTrue); + }); + + test('keeps microsecond precision when converting nanoseconds', () { + final result = converter.fromJson(1705314600123456000); + + expect(result.microsecondsSinceEpoch, DateTime.utc(2024, 1, 15, 10, 30).microsecondsSinceEpoch + 123456); + }); + + test('truncates sub-microsecond nanoseconds', () { + final result = converter.fromJson(1705314600000001999); + + expect(result.microsecondsSinceEpoch, DateTime.utc(2024, 1, 15, 10, 30).microsecondsSinceEpoch + 1); + }); + + test('converts zero nanoseconds to the epoch', () { + final result = converter.fromJson(0); + + expect(result, DateTime.utc(1970)); + expect(result.isUtc, isTrue); + }); + + test('converts negative nanoseconds to a pre-epoch DateTime', () { + final result = converter.fromJson(-1000000000); + + expect(result, DateTime.utc(1969, 12, 31, 23, 59, 59)); + expect(result.isUtc, isTrue); + }); + + test('accepts a double as well as an int', () { + final result = converter.fromJson(1500.0); + + expect(result, DateTime.fromMicrosecondsSinceEpoch(1, isUtc: true)); + expect(result.isUtc, isTrue); + }); + + test('throws a FormatException on an unparsable string', () { + expect(() => converter.fromJson('not a date'), throwsFormatException); + }); + + test('throws a FormatException on an unsupported JSON type', () { + expect(() => converter.fromJson(true), throwsFormatException); + expect(() => converter.fromJson({}), throwsFormatException); + expect(() => converter.fromJson([]), throwsFormatException); + }); + }); + + group('toJson', () { + test('serializes a UTC DateTime as RFC3339', () { + final result = converter.toJson(DateTime.utc(2024, 1, 15, 10, 30)); + + expect(result, '2024-01-15T10:30:00.000Z'); + }); + + test('normalizes a local DateTime to UTC', () { + final local = DateTime(2024, 1, 15, 10, 30); + + final result = converter.toJson(local); + + expect(result, endsWith('Z')); + expect(DateTime.parse(result), local.toUtc()); + }); + + test('keeps microsecond precision', () { + final result = converter.toJson(DateTime.utc(2024, 1, 15, 10, 30, 0, 123, 456)); + + expect(result, '2024-01-15T10:30:00.123456Z'); + }); + }); + + group('round trip', () { + test('a string survives fromJson then toJson', () { + expect(converter.toJson(converter.fromJson('2024-01-15T10:30:00.123456Z')), '2024-01-15T10:30:00.123456Z'); + }); + + test('nanoseconds survive fromJson then toJson', () { + expect(converter.toJson(converter.fromJson(1705314600123456000)), '2024-01-15T10:30:00.123456Z'); + }); + }); +}