feat(shorebird_code_push_protocol): add version-distribution API surface (#3798)

This commit is contained in:
Mac
2026-05-22 13:21:45 -06:00
committed by GitHub
parent fed568644b
commit 344ebfbffd
6 changed files with 254 additions and 0 deletions
@@ -20,6 +20,18 @@ T parseFromJson<T>(
}
}
/// 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<String, dynamic> 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<T>(List<T>? a, List<T>? b) {
final deepEquals = const DeepCollectionEquality().equals;
@@ -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<String, dynamic>;
@@ -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<String, dynamic>` to a [GetVersionDistributionResponse].
factory GetVersionDistributionResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetVersionDistributionResponse',
json,
() => GetVersionDistributionResponse(
entries: (json['entries'] as List)
.map<VersionDistributionEntry>(
(e) =>
VersionDistributionEntry.fromJson(e as Map<String, dynamic>),
)
.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<String, dynamic>? 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<VersionDistributionEntry> 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<String, dynamic>`.
Map<String, dynamic> 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;
}
}
@@ -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<String, dynamic>` to a [VersionDistributionEntry].
factory VersionDistributionEntry.fromJson(Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic>`.
Map<String, dynamic> 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;
}
}
@@ -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>[
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(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -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(<String, dynamic>{}),
throwsFormatException,
);
});
});
}