feat(shorebird_code_push_protocol): add patch-adoption API surface (#3809)

This commit is contained in:
Mac
2026-06-05 11:30:35 -06:00
committed by GitHub
parent 8ca4a3036d
commit 70b89d40ec
12 changed files with 597 additions and 0 deletions
@@ -1,5 +1,22 @@
import 'package:collection/collection.dart';
/// Parse a nullable string as a DateTime.
DateTime? maybeParseDateTime(String? value) {
if (value == null) {
return null;
}
return DateTime.parse(value);
}
/// Parse a nullable RFC 3339 full-date string (`YYYY-MM-DD`) as a DateTime.
/// Time and timezone components are zero.
DateTime? maybeParseDate(String? value) {
if (value == null) {
return null;
}
return DateTime.parse(value);
}
/// Runs [build] to construct a `fromJson`-parsed value of type [T],
/// converting any `TypeError` (e.g. an unexpected null or a cast
/// failure on a required field) into a `FormatException` that names
@@ -29,6 +29,7 @@ export 'package:shorebird_code_push_protocol/src/messages/get_gcp_upload_speed_t
export 'package:shorebird_code_push_protocol/src/messages/get_organization_apps/get_organization_apps_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_organization_users/get_organization_users_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_organizations_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_patch_adoption/get_patch_adoption_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_release/get_release_response.dart';
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';
@@ -44,12 +45,16 @@ export 'package:shorebird_code_push_protocol/src/models/app.dart';
export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.dart';
export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/channel.dart';
export 'package:shorebird_code_push_protocol/src/models/get_patch_adoption_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
export 'package:shorebird_code_push_protocol/src/models/organization.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_membership.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_type.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_user.dart';
export 'package:shorebird_code_push_protocol/src/models/patch.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_point.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_range.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_artifact.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_check_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/pending_release.dart';
@@ -0,0 +1,109 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_adoption_range.dart';
/// {@template get_patch_adoption_response}
/// The response body for GET /apps/{appId}/metrics/patch-adoption. Covers
/// exactly one release.
/// {@endtemplate}
@immutable
class GetPatchAdoptionResponse {
/// {@macro get_patch_adoption_response}
const GetPatchAdoptionResponse({
required this.releaseVersion,
required this.isLatest,
required this.granularity,
required this.range,
required this.asOf,
required this.patches,
});
/// Converts a `Map<String, dynamic>` to a [GetPatchAdoptionResponse].
factory GetPatchAdoptionResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetPatchAdoptionResponse',
json,
() => GetPatchAdoptionResponse(
releaseVersion: json['release_version'] as String,
isLatest: json['is_latest'] as bool,
granularity: checkedKey(json, 'granularity') as String?,
range: PatchAdoptionRange.fromJson(
json['range'] as Map<String, dynamic>,
),
asOf: DateTime.parse(json['as_of'] as String),
patches: (json['patches'] as List)
.map<PatchAdoptionEntry>(
(e) => PatchAdoptionEntry.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static GetPatchAdoptionResponse? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return GetPatchAdoptionResponse.fromJson(json);
}
/// The release version this response is for.
final String releaseVersion;
/// True when the release was resolved via the "latest release by
/// creation date" default (no `release_version` was supplied).
final bool isLatest;
/// The bucket resolution (`hour`, `day`, `week`, or `month`), or null
/// when each patch carries a single full-window value.
final String? granularity;
/// The effective (post-clamp) window the response covers.
final PatchAdoptionRange range;
/// 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;
/// One entry per patch of the release.
final List<PatchAdoptionEntry> patches;
/// Converts a [GetPatchAdoptionResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'release_version': releaseVersion,
'is_latest': isLatest,
'granularity': granularity,
'range': range.toJson(),
'as_of': asOf.toIso8601String(),
'patches': patches.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
releaseVersion,
isLatest,
granularity,
range,
asOf,
listHash(patches),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetPatchAdoptionResponse &&
releaseVersion == other.releaseVersion &&
isLatest == other.isLatest &&
granularity == other.granularity &&
range == other.range &&
asOf == other.asOf &&
listsEqual(patches, other.patches);
}
}
@@ -0,0 +1,38 @@
enum GetPatchAdoptionParameter3 {
hour._('hour'),
day._('day'),
week._('week'),
month._('month');
const GetPatchAdoptionParameter3._(this.value);
/// Creates a GetPatchAdoptionParameter3 from a json value.
factory GetPatchAdoptionParameter3.fromJson(String json) {
return GetPatchAdoptionParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetPatchAdoptionParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetPatchAdoptionParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetPatchAdoptionParameter3.fromJson(json);
}
/// The value of the enum. This is the exact value
/// from the OpenAPI spec and will be used for network transport.
final String value;
/// Converts the enum to its json value.
String toJson() => value;
/// Returns the string form of the enum.
@override
String toString() => value;
}
@@ -0,0 +1,92 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_adoption_point.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template patch_adoption_entry}
/// Cumulative adoption for one patch of the release. Values are
/// cumulative — "patch `>= patch_number`" — so they are monotonic
/// non-increasing in `patch_number`.
/// {@endtemplate}
@immutable
class PatchAdoptionEntry {
/// {@macro patch_adoption_entry}
const PatchAdoptionEntry({
required this.patchNumber,
required this.targetPlatforms,
required this.isRolledBack,
required this.series,
});
/// Converts a `Map<String, dynamic>` to a [PatchAdoptionEntry].
factory PatchAdoptionEntry.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchAdoptionEntry',
json,
() => PatchAdoptionEntry(
patchNumber: json['patch_number'] as int,
targetPlatforms: (json['target_platforms'] as List)
.map<ReleasePlatform>((e) => ReleasePlatform.fromJson(e as String))
.toList(),
isRolledBack: json['is_rolled_back'] as bool,
series: (json['series'] as List)
.map<PatchAdoptionPoint>(
(e) => PatchAdoptionPoint.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchAdoptionEntry? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchAdoptionEntry.fromJson(json);
}
/// The patch number these cumulative values are anchored at.
final int patchNumber;
/// The platform(s) the patch was built for (from its artifacts). The
/// denominator counts only devices on these platforms.
final List<ReleasePlatform> targetPlatforms;
/// Whether the patch has been rolled back.
final bool isRolledBack;
/// The adoption series. Exactly one point (`period: null`) when no
/// granularity was requested; otherwise one point per bucket, ordered
/// by `period` ascending.
final List<PatchAdoptionPoint> series;
/// Converts a [PatchAdoptionEntry] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'patch_number': patchNumber,
'target_platforms': targetPlatforms.map((e) => e.toJson()).toList(),
'is_rolled_back': isRolledBack,
'series': series.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
patchNumber,
listHash(targetPlatforms),
isRolledBack,
listHash(series),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchAdoptionEntry &&
patchNumber == other.patchNumber &&
listsEqual(targetPlatforms, other.targetPlatforms) &&
isRolledBack == other.isRolledBack &&
listsEqual(series, other.series);
}
}
@@ -0,0 +1,86 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template patch_adoption_point}
/// One point in a patch's adoption series: the cumulative distinct
/// devices on `patch >= patch_number` (`devices`) over the patch's target
/// (`target`), and their ratio (`adoption_pct`), for one bucket.
/// {@endtemplate}
@immutable
class PatchAdoptionPoint {
/// {@macro patch_adoption_point}
const PatchAdoptionPoint({
required this.period,
required this.devices,
required this.target,
required this.adoptionPct,
});
/// Converts a `Map<String, dynamic>` to a [PatchAdoptionPoint].
factory PatchAdoptionPoint.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchAdoptionPoint',
json,
() => PatchAdoptionPoint(
period: maybeParseDateTime(checkedKey(json, 'period') as String?),
devices: json['devices'] as int,
target: json['target'] as int,
adoptionPct: (json['adoption_pct'] as num).toDouble(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchAdoptionPoint? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchAdoptionPoint.fromJson(json);
}
/// The bucket start (UTC), or null when the response is a single
/// full-window value (no granularity requested).
final DateTime? period;
/// Distinct devices running patch `>= patch_number` on the patch's
/// target platform(s) within this bucket (an HLL count).
final int devices;
/// Distinct devices on the release whose platform the patch targets —
/// the patch's reachable denominator — within this bucket.
final int target;
/// `devices / target`, in [0.0, 1.0] (0 when `target` is 0). Server
/// is the source of truth; clients should render this value rather
/// than recomputing it.
final double adoptionPct;
/// Converts a [PatchAdoptionPoint] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'period': period?.toIso8601String(),
'devices': devices,
'target': target,
'adoption_pct': adoptionPct,
};
}
@override
int get hashCode => Object.hashAll([
period,
devices,
target,
adoptionPct,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchAdoptionPoint &&
period == other.period &&
devices == other.devices &&
target == other.target &&
adoptionPct == other.adoptionPct;
}
}
@@ -0,0 +1,63 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template patch_adoption_range}
/// The effective (post-clamp) window the response covers.
/// {@endtemplate}
@immutable
class PatchAdoptionRange {
/// {@macro patch_adoption_range}
const PatchAdoptionRange({
required this.start,
required this.end,
});
/// Converts a `Map<String, dynamic>` to a [PatchAdoptionRange].
factory PatchAdoptionRange.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchAdoptionRange',
json,
() => PatchAdoptionRange(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchAdoptionRange? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchAdoptionRange.fromJson(json);
}
/// Window start (UTC, inclusive).
final DateTime start;
/// Window end (UTC, exclusive).
final DateTime end;
/// Converts a [PatchAdoptionRange] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'start': start.toIso8601String(),
'end': end.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
start,
end,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchAdoptionRange &&
start == other.start &&
end == other.end;
}
}
@@ -0,0 +1,49 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetPatchAdoptionResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetPatchAdoptionResponse(
releaseVersion: 'example',
isLatest: false,
granularity: 'example',
range: PatchAdoptionRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
asOf: DateTime.utc(2024),
patches: <PatchAdoptionEntry>[
PatchAdoptionEntry(
patchNumber: 0,
targetPlatforms: <ReleasePlatform>[ReleasePlatform.values.first],
isRolledBack: false,
series: <PatchAdoptionPoint>[
PatchAdoptionPoint(
period: DateTime.utc(2024),
devices: 0,
target: 0,
adoptionPct: 0,
),
],
),
],
);
final parsed = GetPatchAdoptionResponse.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetPatchAdoptionResponse.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetPatchAdoptionResponse.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,43 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetPatchAdoptionParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetPatchAdoptionParameter3.values.first;
final parsed = GetPatchAdoptionParameter3.maybeFromJson(
instance.toJson(),
)!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetPatchAdoptionParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() =>
GetPatchAdoptionParameter3.maybeFromJson('__invalid_enum_value__'),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetPatchAdoptionParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetPatchAdoptionParameter3.values) {
expect(
GetPatchAdoptionParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,37 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchAdoptionEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchAdoptionEntry(
patchNumber: 0,
targetPlatforms: <ReleasePlatform>[ReleasePlatform.values.first],
isRolledBack: false,
series: <PatchAdoptionPoint>[
PatchAdoptionPoint(
period: DateTime.utc(2024),
devices: 0,
target: 0,
adoptionPct: 0,
),
],
);
final parsed = PatchAdoptionEntry.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchAdoptionEntry.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchAdoptionEntry.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,30 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchAdoptionPoint', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchAdoptionPoint(
period: DateTime.utc(2024),
devices: 0,
target: 0,
adoptionPct: 0,
);
final parsed = PatchAdoptionPoint.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchAdoptionPoint.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchAdoptionPoint.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,28 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchAdoptionRange', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchAdoptionRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
);
final parsed = PatchAdoptionRange.maybeFromJson(instance.toJson())!;
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchAdoptionRange.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchAdoptionRange.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}