diff --git a/packages/shorebird_code_push_protocol/lib/model_helpers.dart b/packages/shorebird_code_push_protocol/lib/model_helpers.dart index 7975a441..c70775f3 100644 --- a/packages/shorebird_code_push_protocol/lib/model_helpers.dart +++ b/packages/shorebird_code_push_protocol/lib/model_helpers.dart @@ -20,6 +20,18 @@ T parseFromJson( } } +/// Reads a key that is required to be present in [json] but whose value +/// may legitimately be null (OpenAPI 3.1 `type: [T, "null"]` combined +/// with `required`). A plain `json[key] as T?` cast would otherwise +/// accept a missing key as a null value, silently violating `required`. +/// Throws [FormatException] when the key is absent. +dynamic checkedKey(Map json, String key) { + if (!json.containsKey(key)) { + throw FormatException("Missing required key '$key'", json); + } + return json[key]; +} + /// Check if two nullable lists are deeply equal. bool listsEqual(List? a, List? b) { final deepEquals = const DeepCollectionEquality().equals; diff --git a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart index 5c7b7c3a..63364d83 100644 --- a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart +++ b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart @@ -33,6 +33,7 @@ export 'package:shorebird_code_push_protocol/src/messages/get_release/get_releas export 'package:shorebird_code_push_protocol/src/messages/get_release_artifacts/get_release_artifacts_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_release_patches/get_release_patches_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_releases/get_releases_response.dart'; +export 'package:shorebird_code_push_protocol/src/messages/get_version_distribution/get_version_distribution_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/patch_check/patch_check_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/patch_check/patch_check_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/promote_patch/promote_patch_request.dart'; @@ -61,6 +62,7 @@ export 'package:shorebird_code_push_protocol/src/models/release_patch.dart'; export 'package:shorebird_code_push_protocol/src/models/release_platform.dart'; export 'package:shorebird_code_push_protocol/src/models/release_status.dart'; export 'package:shorebird_code_push_protocol/src/models/role.dart'; +export 'package:shorebird_code_push_protocol/src/models/version_distribution_entry.dart'; /// Parsed JSON data. typedef Json = Map; diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_version_distribution/get_version_distribution_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_version_distribution/get_version_distribution_response.dart new file mode 100644 index 00000000..8773ea59 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_version_distribution/get_version_distribution_response.dart @@ -0,0 +1,95 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/version_distribution_entry.dart'; + +/// {@template get_version_distribution_response} +/// The response body for GET /apps/{appId}/metrics/version-distribution. +/// {@endtemplate} +@immutable +class GetVersionDistributionResponse { + /// {@macro get_version_distribution_response} + const GetVersionDistributionResponse({ + required this.entries, + required this.totalDevices, + required this.activeWindowDays, + required this.asOf, + }); + + /// Converts a `Map` to a [GetVersionDistributionResponse]. + factory GetVersionDistributionResponse.fromJson(Map json) { + return parseFromJson( + 'GetVersionDistributionResponse', + json, + () => GetVersionDistributionResponse( + entries: (json['entries'] as List) + .map( + (e) => + VersionDistributionEntry.fromJson(e as Map), + ) + .toList(), + totalDevices: json['total_devices'] as int, + activeWindowDays: json['active_window_days'] as int, + asOf: DateTime.parse(json['as_of'] as String), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static GetVersionDistributionResponse? maybeFromJson( + Map? json, + ) { + if (json == null) { + return null; + } + return GetVersionDistributionResponse.fromJson(json); + } + + /// One entry per release version, sorted by `device_count` + /// descending, then `release_version` ascending with NULLs last. + final List entries; + + /// Sum of `device_count` across all entries. Convenience for + /// clients; matches the server-side sum used to compute + /// `percentage`. + final int totalDevices; + + /// The active-device window in days that bounds the query. + /// Hardcoded server-side in v1; tier-gated per-caller windows + /// land in a follow-up. + final int activeWindowDays; + + /// Server's UTC timestamp at the moment the response was + /// constructed. Not a freshness indicator for the underlying + /// data, which is refreshed by an hourly scheduled query and + /// may lag by up to ~1 hour. + final DateTime asOf; + + /// Converts a [GetVersionDistributionResponse] to a `Map`. + Map toJson() { + return { + 'entries': entries.map((e) => e.toJson()).toList(), + 'total_devices': totalDevices, + 'active_window_days': activeWindowDays, + 'as_of': asOf.toIso8601String(), + }; + } + + @override + int get hashCode => Object.hashAll([ + listHash(entries), + totalDevices, + activeWindowDays, + asOf, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GetVersionDistributionResponse && + listsEqual(entries, other.entries) && + totalDevices == other.totalDevices && + activeWindowDays == other.activeWindowDays && + asOf == other.asOf; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/version_distribution_entry.dart b/packages/shorebird_code_push_protocol/lib/src/models/version_distribution_entry.dart new file mode 100644 index 00000000..297aed0a --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/version_distribution_entry.dart @@ -0,0 +1,78 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; + +/// {@template version_distribution_entry} +/// One bucket in a version-distribution chart: the exact count of +/// currently-active devices on a given release version. A null +/// `release_version` represents devices whose client did not emit one +/// (typically very old Flutter clients). +/// {@endtemplate} +@immutable +class VersionDistributionEntry { + /// {@macro version_distribution_entry} + const VersionDistributionEntry({ + required this.releaseVersion, + required this.deviceCount, + required this.percentage, + }); + + /// Converts a `Map` to a [VersionDistributionEntry]. + factory VersionDistributionEntry.fromJson(Map json) { + return parseFromJson( + 'VersionDistributionEntry', + json, + () => VersionDistributionEntry( + releaseVersion: checkedKey(json, 'release_version') as String?, + deviceCount: json['device_count'] as int, + percentage: (json['percentage'] as num).toDouble(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static VersionDistributionEntry? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return VersionDistributionEntry.fromJson(json); + } + + /// The release version for this bucket, or null for devices whose + /// client did not emit one. + final String? releaseVersion; + + /// Number of currently-active devices on this release version + /// within the active-device window. + final int deviceCount; + + /// Fractional share of currently-active devices on this release + /// version, in [0.0, 1.0]. Server is the source of truth; clients + /// should render this value rather than recomputing it. + final double percentage; + + /// Converts a [VersionDistributionEntry] to a `Map`. + Map toJson() { + return { + 'release_version': releaseVersion, + 'device_count': deviceCount, + 'percentage': percentage, + }; + } + + @override + int get hashCode => Object.hashAll([ + releaseVersion, + deviceCount, + percentage, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is VersionDistributionEntry && + releaseVersion == other.releaseVersion && + deviceCount == other.deviceCount && + percentage == other.percentage; + } +} diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_version_distribution/get_version_distribution_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_version_distribution/get_version_distribution_response_test.dart new file mode 100644 index 00000000..2f2c2124 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_version_distribution/get_version_distribution_response_test.dart @@ -0,0 +1,38 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetVersionDistributionResponse', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetVersionDistributionResponse( + entries: const [ + VersionDistributionEntry( + releaseVersion: 'example', + deviceCount: 0, + percentage: 0, + ), + ], + totalDevices: 0, + activeWindowDays: 0, + asOf: DateTime.utc(2024), + ); + final parsed = GetVersionDistributionResponse.maybeFromJson( + instance.toJson(), + )!; + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetVersionDistributionResponse.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetVersionDistributionResponse.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/version_distribution_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/version_distribution_entry_test.dart new file mode 100644 index 00000000..b3cc092d --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/version_distribution_entry_test.dart @@ -0,0 +1,29 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('VersionDistributionEntry', () { + test('round-trips via maybeFromJson/toJson', () { + const instance = VersionDistributionEntry( + releaseVersion: 'example', + deviceCount: 0, + percentage: 0, + ); + final parsed = VersionDistributionEntry.maybeFromJson(instance.toJson())!; + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(VersionDistributionEntry.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => VersionDistributionEntry.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +}