feat(shorebird_code_push_protocol): add version-distribution API surface (#3798)
This commit is contained in:
@@ -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>;
|
||||
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+29
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user