Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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
- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else
- Added `DisconnectionSource.connectTimeout` and `authenticationFailed`, and `isReconnectable`, whether a connection closed for that reason is worth opening again
- Added `DisconnectionSource.cause`, the error that closed the connection, or `null` when the source carries none
Expand Down
1 change: 1 addition & 0 deletions packages/stream_core/lib/src/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
27 changes: 27 additions & 0 deletions packages/stream_core/lib/src/api/stream_datetime_converter.dart
Original file line number Diff line number Diff line change
@@ -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<DateTime, Object> {
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();
}
136 changes: 136 additions & 0 deletions packages/stream_core/test/api/stream_datetime_converter_test.dart
Original file line number Diff line number Diff line change
@@ -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(<String, Object?>{}), throwsFormatException);
expect(() => converter.fromJson(<Object?>[]), 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');
});
});
}
Loading