Merge remote-tracking branch 'upstream/main'
Deploy Artifact Proxy Dev / ☁️ Artifact Proxy (push) Has been cancelled
Deploy Discord GCP Alerts / ☁️ Discord GCP Alerts (push) Has been cancelled
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled

# Conflicts:
#	packages/artifact_proxy/lib/src/artifact_proxy.dart
#	packages/scoped_deps/pubspec.yaml
#	packages/shorebird_cli/pubspec.yaml
#	packages/shorebird_code_push_client/lib/src/code_push_client.dart
This commit is contained in:
Tony
2026-08-03 01:58:32 +08:00
83 changed files with 2819 additions and 152 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ runs:
run: echo "package_name=${PACKAGE_PATH##*/}" >> $GITHUB_OUTPUT
- name: 📊 Upload Coverage
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
flags: ${{ steps.split.outputs.package_name }}
token: ${{ inputs.codecov_token }}
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🎯 Setup Dart
uses: dart-lang/setup-dart@v1
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- name: 🐳 Run Redis
@@ -65,7 +65,7 @@ jobs:
dart test --coverage=coverage && \
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --report-on=lib --check-ignore
- if: inputs.has_unit_tests
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
flags: ${{ inputs.package_name }}
working-directory: ${{ inputs.package_path }}
@@ -27,7 +27,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
@@ -21,7 +21,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
@@ -27,7 +27,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: recursive
+3 -3
View File
@@ -61,7 +61,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🖥️ Add Shorebird to PATH
shell: bash
@@ -98,7 +98,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: 🤖 AVD Cache
uses: actions/cache@v5
uses: actions/cache@v6
id: avd-cache
with:
path: |
@@ -143,7 +143,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ matrix.branch }}
+6 -6
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 👀 Check Licenses
uses: ./.github/actions/license_check
@@ -46,7 +46,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
name: Build Detection
@@ -154,7 +154,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🎯 Build ${{ matrix.package }}
uses: ./.github/actions/dart_package
@@ -178,7 +178,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🎯 Build ${{ matrix.package }}
uses: ./.github/actions/dart_package
@@ -203,7 +203,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🐳 Run Redis
run: |
@@ -231,7 +231,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🔎 Verify ${{ matrix.package }}
uses: ./.github/actions/verify_version
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: 📚 Git Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🐳 Run Redis
if: inputs.needs_redis
@@ -1,15 +0,0 @@
name: Publish scoped_deps
on:
push:
tags:
- "scoped_deps-v[0-9]+.[0-9]+.[0-9]+*"
jobs:
publish:
uses: ./.github/workflows/publish_dart_package.yaml
with:
working_directory: packages/scoped_deps
tag_prefix: scoped_deps-v
permissions:
id-token: write
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
shorebird_redis_client: ${{ steps.filter.outputs.shorebird_redis_client }}
stripe_api: ${{ steps.filter.outputs.stripe_api }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
# Full history so dorny/paths-filter can diff on push events.
fetch-depth: 0
@@ -221,7 +221,7 @@ jobs:
name: CSpell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
submodules: recursive
- uses: streetsidesoftware/cspell-action@v8
+60 -1
View File
@@ -1,9 +1,68 @@
# Release Notes
<!--
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya serde bipatch
cspell:words pubspec erickzanardo xcframeworks cupertino codesign codecov rkishan appbundle proto tlsv kingdomseed Peetee Aditya serde bipatch GLES lipo
-->
## 1.6.115 (July 28, 2026)
- 🐦 Flutter 3.44.8 / Dart 3.12.2 support
- Android: fix a startup crash on vendor-modified devices caused by the
accessibility bridge failing during initialization.
- iOS/macOS: fix framework builds failing under Xcode 27 due to a `lipo`
architecture verification error.
## 1.6.114 (July 20, 2026)
- 🐦 Flutter 3.44.7 / Dart 3.12.2 support
- Android: fix a file descriptor leak that could crash apps using external
textures (e.g. video) on some GPUs such as Arm Mali.
## 1.6.113 (July 10, 2026)
- 🐛 Fix `--shorebird-trace` build tracing being silently disabled even on
Flutter versions that support it, caused by probing plain `flutter build -h`
output where the flag is hidden.
## 1.6.112 (July 10, 2026)
- 🐦 Flutter 3.44.6 / Dart 3.12.2 support
- Linux: fix a crash when building asset bundles with native assets enabled
but without a native app build directory (missing `CMakeCache.txt`).
- Android: fix a crash when running instrumented tests.
## 1.6.111 (July 7, 2026)
- 🐦 Flutter 3.44.5 / Dart 3.12.2 support
- Android: fix `libapp.so` sometimes missing from the APK or app bundle when
building with flavors, or with older app templates combined with certain
plugins.
- Impeller: fix text shadows rendering in the wrong position, separate from
the text.
- Impeller (Vulkan): fix a crash during app shutdown or rotation on some
Android devices.
- Android: silence spurious Built-in Kotlin migration warnings when building
with AGP below 9.
- 🐛 Updater: report the currently running patch number instead of the last
booted patch.
## 1.6.110 (June 25, 2026)
- 🐦 Flutter 3.44.4 / Dart 3.12.2 support
- Linux: fix bounds checking in `FlAccessibleTextField`.
## 1.6.109 (June 22, 2026)
- 🐦 Flutter 3.44.3 / Dart 3.12.2 support
- Fix a potential buffer overflow in the animated PNG decoder when parsing
malformed fdAT chunks.
- Swift Package Manager: fix crashes from concurrent directory/file/symlink
creation when building iOS or macOS apps.
- Impeller (OpenGL ES): fix a texture resource leak by ensuring the
TextureGLES destructor releases all objects it holds, including its sync
fence.
- Print a trace when skipping flavor-specific and platform-specific assets.
## 1.6.108 (June 12, 2026)
- 🐦 Flutter 3.44.2 / Dart 3.12.2 support
+1 -1
View File
@@ -1 +1 @@
65b1e4368e98a85ea11e8ef2cffc286c3b2ad8b2
c15ef6379403a0a55531a058bdb2c8e55bc05c98
+1 -1
View File
@@ -21,4 +21,4 @@ dev_dependencies:
build_runner: ^2.15.0
json_serializable: ^6.13.2
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
+2 -2
View File
@@ -8,8 +8,8 @@ environment:
sdk: ">=3.9.0 <4.0.0"
dependencies:
meta: ^1.16.0
meta: ^1.18.3
dev_dependencies:
path: ^1.9.1
test: ^1.31.1
test: ^1.31.2
+1 -1
View File
@@ -17,4 +17,4 @@ dev_dependencies:
build_runner: ^2.15.0
json_serializable: ^6.13.2
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
@@ -15,4 +15,4 @@ dependencies:
dev_dependencies:
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
+2 -2
View File
@@ -12,7 +12,7 @@ dependencies:
collection: ^1.18.0
http: ^1.5.0
json_annotation: ^4.12.0
meta: ^1.7.0
meta: ^1.18.3
pointycastle: ^4.0.0
rsa_pkcs: ^2.0.0
ttl_cache: ^0.3.0
@@ -21,4 +21,4 @@ dev_dependencies:
build_runner: ^2.15.0
json_serializable: ^6.13.2
path: ^1.9.0
test: ^1.31.1
test: ^1.31.2
+1 -1
View File
@@ -13,4 +13,4 @@ dependencies:
resp_client: ^1.2.0
dev_dependencies:
test: ^1.31.1
test: ^1.31.2
+23
View File
@@ -1,7 +1,30 @@
<!-- cspell:words Angelov mrgnhnt -->
# Scoped Deps
A simple dependency injection library built on Zones.
## Status: vendored, no longer published by Shorebird
Felix Angelov wrote this package in 2023 (originally `package:scoped`) so that
our packages could share one dependency-injection mechanism. We published it to
pub.dev in 2024 in case it was useful to others, then never needed to change it
again.
In 2026, Morgan Hunt ([`mrgnhnt96/scoped_deps`][mrgnhnt_repo]) approached us
about taking over the pub.dev package and continuing to improve it, and we
accepted. **[`scoped_deps` on pub.dev][pub_package] is now his**, and its
versions have diverged from this directory.
Shorebird packages keep using this vendored copy, resolved through the Dart
workspace rather than from pub.dev. The code hasn't meaningfully changed in two
years, so there's nothing to gain from moving right now. At some point we should
look at where the pub.dev package has evolved to and consider deleting this copy
in favor of it.
[mrgnhnt_repo]: https://github.com/mrgnhnt96/scoped_deps
[pub_package]: https://pub.dev/packages/scoped_deps
## Quick Start
```dart
+5 -2
View File
@@ -1,16 +1,19 @@
# cspell:words mrgnhnt
name: scoped_deps
description: A simple Dart library for managing scoped dependencies built on top of Zones from dart:async.
version: 0.1.0+2
homepage: https://git.tonycloud.org/flutter/shorebird-workspace
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
# pub.dev/packages/scoped_deps is now owned by mrgnhnt96; see README.md.
publish_to: none
environment:
sdk: ">=3.9.0 <4.0.0"
dependencies:
meta: ^1.0.0
meta: ^1.18.3
dev_dependencies:
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
+2 -2
View File
@@ -13,5 +13,5 @@ environment:
sdk: ^3.9.0
dev_dependencies:
test: ^1.31.1
very_good_analysis: ^10.0.0
test: ^1.31.2
very_good_analysis: ^10.3.0
+3 -3
View File
@@ -17,11 +17,11 @@ environment:
dependencies:
args: ^2.6.0
meta: ^1.16.0
meta: ^1.18.3
path: ^1.9.0
pub_semver: ^2.1.0
yaml: ^3.1.0
dev_dependencies:
test: ^1.31.1
very_good_analysis: ^10.2.0
test: ^1.31.2
very_good_analysis: ^10.3.0
@@ -125,13 +125,20 @@ ${link(uri: Uri.parse(openShorebirdIssueUrl))}
/// Probes the command's help output and caches the result per [command]
/// so that subsequent calls for the same command are free.
/// Returns `false` if the help check fails for any reason.
///
/// The probe uses verbose help (`-h -v`): `--shorebird-trace` is registered
/// with `hide: !verboseHelp` in Flutter, so it does not appear in plain
/// `-h` output. Probing with `-h` alone therefore returned `false` even on
/// Flutter versions that fully support the flag, silently disabling build
/// tracing for everyone. `-h -v` lists hidden options so support is
/// detected correctly.
Future<bool> _supportsTraceFlag(String command) async {
if (_traceSupport.containsKey(command)) return _traceSupport[command]!;
try {
final result = await process.run(
'flutter',
['build', command, '-h'],
['build', command, '-h', '-v'],
runInShell: false,
);
final supported = result.stdout.toString().contains('--shorebird-trace');
@@ -129,6 +129,22 @@ If left checked, Xcode will rewrite the build number in the uploaded IPA, so the
throw ProcessExit(ExitCode.software.code);
}
// When code signing is requested (the default), `flutter build ipa` is
// expected to export a signed .ipa. Flutter treats the export step as
// optional and exits 0 even when it fails (e.g. no signing certificate),
// so we must verify the .ipa was actually produced. Otherwise we would
// report a successful release and point the user at an .ipa that does not
// exist. See https://github.com/shorebirdtech/shorebird/issues/3807.
if (codesign && artifactManager.getIpa() == null) {
logger.err(
'''
Unable to find generated IPA. This usually means that the IPA export step of "flutter build ipa" failed (for example, due to a missing or invalid code signing certificate). Review the build output above for the underlying error.
If you do not need a signed IPA (for example, you will sign the .xcarchive in Xcode), re-run this command with --no-codesign.''',
);
throw ProcessExit(ExitCode.software.code);
}
return xcarchiveDirectory;
}
+1 -1
View File
@@ -1,2 +1,2 @@
// Generated code. Do not modify.
const packageVersion = '1.6.108';
const packageVersion = '1.6.115';
+8 -8
View File
@@ -1,6 +1,6 @@
name: shorebird_cli
description: Command-line tool to interact with Shorebird's services.
version: 1.6.108
version: 1.6.115
repository: https://git.tonycloud.org/flutter/shorebird-workspace
resolution: workspace
@@ -20,24 +20,24 @@ dependencies:
crypto: ^3.0.6
dex:
path: ../dex
equatable: ^2.0.8
googleapis_auth: ^2.3.1
equatable: ^2.1.0
googleapis_auth: ^2.3.2
http: ^1.5.0
intl: ^0.20.2
intl: ^0.20.3
io: ^1.0.5
json_annotation: ^4.12.0
json_path: ^0.9.0
jwt:
path: ../jwt
mason_logger: ^0.3.5
meta: ^1.16.0
meta: ^1.18.3
open_aot_patch_tools:
path: ../open_aot_patch_tools
path: ^1.9.1
pem: ^2.0.5
platform: ^3.1.6
pointycastle: ^4.0.0
propertylistserialization: ^1.4.1
propertylistserialization: ^1.5.0
pub_semver: ^2.2.0
pubspec_parse: ^1.5.0
retry: ^3.1.2
@@ -50,7 +50,7 @@ dependencies:
path: ../shorebird_code_push_protocol
stream_transform: ^2.1.1
uuid: ^4.5.3
xml: ^6.5.0
xml: ^7.0.1
yaml: ^3.1.3
yaml_edit: ^2.2.4
@@ -60,7 +60,7 @@ dev_dependencies:
build_version: ^2.1.1
json_serializable: ^6.13.2
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
executables:
shorebird:
@@ -347,12 +347,78 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '-h'],
['build', 'appbundle', '-h', '-v'],
runInShell: false,
),
).called(1);
});
test(
'detects --shorebird-trace even though it is hidden from -h',
() async {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 7));
// Flutter registers --shorebird-trace with `hide: !verboseHelp`, so it
// only appears in verbose help. Model that: plain `-h` omits the flag,
// `-h -v` includes it. Probing without `-v` (the original bug) would
// miss it and silently disable tracing on every supported build.
when(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '-h'],
runInShell: false,
),
).thenAnswer(
(_) async => ShorebirdProcessResult(
exitCode: ExitCode.success.code,
stdout: '--release',
stderr: '',
),
);
when(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
(_) async => ShorebirdProcessResult(
exitCode: ExitCode.success.code,
stdout: '--release\n--shorebird-trace',
stderr: '',
),
);
await runWithOverrides(() async {
await builder.prepareBuildTrace(platform: 'android');
await builder.buildAppBundle();
});
final expectedTracePath = p.join(
projectRoot.path,
'build',
'shorebird',
'debug',
'build-trace-android.json',
);
verify(
() => shorebirdProcess.stream(
'flutter',
[
'build',
'appbundle',
'--release',
'--shorebird-trace=$expectedTracePath',
],
environment: any(named: 'environment'),
runInShell: false,
onStart: any(named: 'onStart'),
),
).called(1);
},
);
test('skips trace when help probe throws', () async {
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
@@ -360,7 +426,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
when(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '-h'],
['build', 'appbundle', '-h', '-v'],
runInShell: false,
),
).thenThrow(Exception('process failed'));
@@ -921,7 +987,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
when(
() => shorebirdProcess.run(
'flutter',
['build', 'aar', '-h'],
['build', 'aar', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
@@ -1123,7 +1189,7 @@ Either run `flutter pub get` manually, or follow the steps in ${cannotRunInVSCod
when(
() => shorebirdProcess.run(
'flutter',
['build', 'linux', '-h'],
['build', 'linux', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
@@ -1300,7 +1366,7 @@ Reason: Exited with code 70.'''),
when(
() => shorebirdProcess.run(
'flutter',
['build', 'macos', '-h'],
['build', 'macos', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
@@ -1789,7 +1855,7 @@ Reason: Exited with code 70.'''),
when(
() => shorebirdProcess.run(
'flutter',
['build', 'ios-framework', '-h'],
['build', 'ios-framework', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
@@ -2181,7 +2247,7 @@ Reason: Exited with code 70.'''),
when(
() => shorebirdProcess.run(
'flutter',
['build', 'windows', '-h'],
['build', 'windows', '-h', '-v'],
runInShell: false,
),
).thenAnswer(
@@ -423,6 +423,9 @@ $body
when(
() => artifactManager.getXcarchiveDirectory(),
).thenReturn(xcarchiveDirectory);
when(
() => artifactManager.getIpa(),
).thenReturn(File(p.join(Directory.systemTemp.path, 'app.ipa')));
when(
() => codeSigner.base64PublicKeyFromPem(any()),
@@ -660,6 +663,42 @@ $body
});
});
group('when codesigning and ipa not found after build', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(true);
when(() => artifactManager.getIpa()).thenReturn(null);
});
test('logs message and exits with code 70', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(that: contains('Unable to find generated IPA')),
),
).called(1);
});
});
group('when not codesigning and ipa not found after build', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(false);
when(() => artifactManager.getIpa()).thenReturn(null);
});
test('does not check for the ipa and returns xcarchive path', () async {
expect(
await runWithOverrides(iosReleaser.buildReleaseArtifacts),
equals(xcarchiveDirectory),
);
verifyNever(() => artifactManager.getIpa());
});
});
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_client/src/version.dart';
@@ -91,23 +92,39 @@ class CodePushClient {
http.Client? httpClient,
Uri? hostedUri,
Map<String, String>? customHeaders,
@visibleForTesting
Duration uploadRetryBaseDelay = const Duration(seconds: 1),
}) : _httpClient = _CodePushHttpClient(httpClient ?? http.Client(), {
...standardHeaders,
...?customHeaders,
}),
_uploadRetryBaseDelay = uploadRetryBaseDelay,
hostedUri = hostedUri ?? defaultHostedUri;
/// Default URL for the open self-hosted Shorebird server.
static final Uri defaultHostedUri = Uri.parse('http://localhost:8080');
/// The standard headers applied to all requests.
@visibleForTesting
static const standardHeaders = <String, String>{'x-version': packageVersion};
/// The default error message to use when an unknown error occurs.
@visibleForTesting
static const unknownErrorMessage = 'An unknown error occurred.';
/// The status GCS returns ("Resume Incomplete") between resumable chunks.
static const _resumeIncompleteStatus = 308;
/// The maximum number of consecutive failures tolerated while uploading a
/// single resumable session before the upload is abandoned.
static const _maxUploadFailures = 5;
final http.Client _httpClient;
/// The base delay for exponential backoff between resumable upload retries.
/// Doubles with each consecutive failure.
final Duration _uploadRetryBaseDelay;
/// The hosted uri for the Shorebird CodePush API.
final Uri hostedUri;
@@ -163,17 +180,12 @@ class CodePushClient {
json.decode(body) as Map<String, dynamic>,
);
final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url))
..files.add(file);
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw CodePushException(
message:
'''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''',
);
}
await _uploadArtifact(
artifactPath: artifactPath,
multipartFile: file,
url: decoded.url,
uploadMethod: decoded.uploadMethod,
);
}
/// Create a new artifact for a specific [releaseId].
@@ -222,17 +234,152 @@ class CodePushClient {
json.decode(body) as Map<String, dynamic>,
);
final uploadRequest = http.MultipartRequest('POST', Uri.parse(decoded.url))
..files.add(file);
await _uploadArtifact(
artifactPath: artifactPath,
multipartFile: file,
url: decoded.url,
uploadMethod: decoded.uploadMethod,
);
}
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw CodePushException(
message:
'''Failed to upload artifact (${uploadResponse.reasonPhrase} '${uploadResponse.statusCode})''',
/// Uploads an artifact's bytes to storage using the method the server
/// selected in the create response.
Future<void> _uploadArtifact({
required String artifactPath,
required http.MultipartFile multipartFile,
required String url,
required ArtifactUploadMethod? uploadMethod,
}) async {
if (uploadMethod == ArtifactUploadMethod.resumable) {
await _resumableUpload(
sessionUri: Uri.parse(url),
artifactPath: artifactPath,
);
return;
}
// Legacy single multipart POST. Remove once the server no longer returns
// ArtifactUploadMethod.multipart (i.e. all supported clients are new
// enough to receive a resumable session).
final uploadRequest = http.MultipartRequest('POST', Uri.parse(url))
..files.add(multipartFile);
final uploadResponse = await _httpClient.send(uploadRequest);
if (!uploadResponse.isSuccess) {
throw _uploadFailed(uploadResponse);
}
}
/// Uploads [artifactPath] to a GCS resumable session at [sessionUri] by
/// PUTing the bytes in fixed-size chunks with a `Content-Range` header,
/// resuming from the last byte GCS acknowledged if a chunk fails. The
/// session was initiated (and size-bound) server-side.
Future<void> _resumableUpload({
required Uri sessionUri,
required String artifactPath,
}) async {
// GCS requires chunk sizes to be a multiple of 256 KiB (except the last).
const chunkSize = 8 * 1024 * 1024;
final file = File(artifactPath);
final total = await file.length();
final raf = await file.open();
var failures = 0;
try {
var offset = 0;
while (offset < total) {
final end = offset + chunkSize < total ? offset + chunkSize : total;
await raf.setPosition(offset);
final chunk = await raf.read(end - offset);
final http.StreamedResponse response;
try {
response = await _httpClient.send(
http.Request('PUT', sessionUri)
..bodyBytes = chunk
..headers['content-range'] = 'bytes $offset-${end - 1}/$total',
);
} on Exception {
// Network failure mid-chunk: back off, ask GCS how far it got, and
// resume from there.
if (++failures > _maxUploadFailures) rethrow;
await _backoff(failures);
offset = await _queryResumeOffset(sessionUri, total);
continue;
}
final status = response.statusCode;
await response.stream.drain<void>();
if (status == HttpStatus.ok || status == HttpStatus.created) return;
if (status == _resumeIncompleteStatus) {
// A 308 reports GCS's stored byte count in the `range` header. Its
// absence means GCS has no bytes yet, so we must restart from 0
// (per the resumable upload status-check docs). Treat a lack of
// forward progress as a failure so a stuck session can't spin
// forever.
final next = _parseRangeEnd(response.headers['range']) ?? 0;
if (next > offset) {
failures = 0;
} else if (++failures > _maxUploadFailures) {
throw _uploadFailed(response);
} else {
await _backoff(failures);
}
offset = next;
} else if (status >= HttpStatus.internalServerError) {
// Transient server error (5xx): recover the same way as a mid-chunk
// network failure — back off, then resume from where GCS left off
// rather than re-sending bytes it has already persisted.
if (++failures > _maxUploadFailures) throw _uploadFailed(response);
await _backoff(failures);
offset = await _queryResumeOffset(sessionUri, total);
} else {
throw _uploadFailed(response);
}
}
} finally {
await raf.close();
}
}
/// Waits with exponential backoff before retrying a resumable upload,
/// doubling [_uploadRetryBaseDelay] with each consecutive [failures].
Future<void> _backoff(int failures) =>
Future<void>.delayed(_uploadRetryBaseDelay * (1 << (failures - 1)));
/// Queries a resumable [sessionUri] for the number of bytes GCS has received
/// so far, returning the offset to resume from.
Future<int> _queryResumeOffset(Uri sessionUri, int total) async {
final response = await _httpClient.send(
http.Request('PUT', sessionUri)
..headers['content-range'] = 'bytes */$total',
);
final status = response.statusCode;
await response.stream.drain<void>();
if (status == HttpStatus.ok || status == HttpStatus.created) return total;
if (status == _resumeIncompleteStatus) {
return _parseRangeEnd(response.headers['range']) ?? 0;
}
throw _uploadFailed(response);
}
/// Parses the next byte offset from a GCS `Range: bytes=0-X` header,
/// returning `X + 1`, or null if the header is absent/malformed.
int? _parseRangeEnd(String? range) {
if (range == null) return null;
final dash = range.lastIndexOf('-');
if (dash == -1) return null;
final last = int.tryParse(range.substring(dash + 1));
return last == null ? null : last + 1;
}
/// The exception thrown when an artifact upload request fails.
CodePushException _uploadFailed(http.BaseResponse response) {
final reason = response.reasonPhrase;
return CodePushException(
message: 'Failed to upload artifact ($reason ${response.statusCode})',
);
}
/// Create a new app with the provided [displayName].
@@ -12,6 +12,7 @@ environment:
dependencies:
collection: ^1.18.0
http: ^1.5.0
meta: ^1.18.3
path: ^1.9.0
shorebird_code_push_protocol:
path: ../shorebird_code_push_protocol
@@ -21,4 +22,4 @@ dev_dependencies:
build_verify: ^3.1.1
build_version: ^2.0.0
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
@@ -47,6 +47,8 @@ void main() {
codePushClient = CodePushClient(
httpClient: httpClient,
customHeaders: customHeaders,
// Disable backoff delays so retry paths run instantly under test.
uploadRetryBaseDelay: Duration.zero,
);
when(() => httpClient.send(any())).thenAnswer(
(_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
@@ -460,6 +462,62 @@ void main() {
),
);
});
test('uploads via a resumable session when the server '
'selects resumable', () async {
const artifactId = 42;
const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a';
final responses = [
http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreatePatchArtifactResponse(
id: artifactId,
patchId: patchId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
),
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
];
when(
() => httpClient.send(any()),
).thenAnswer((_) async => responses.removeAt(0));
final tempDir = Directory.systemTemp.createTempSync();
final fixture = File(path.join(tempDir.path, 'patch.txt'))
..writeAsBytesSync([1, 2, 3, 4, 5]);
await expectLater(
codePushClient.createPatchArtifact(
appId: appId,
artifactPath: fixture.path,
patchId: patchId,
arch: arch,
platform: platform,
hash: hash,
),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>();
expect(requests, hasLength(2));
final uploadRequest = requests.last;
expect(uploadRequest.method, equals('PUT'));
expect(uploadRequest.url, equals(Uri.parse(sessionUrl)));
expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5'));
});
});
group('createReleaseArtifact', () {
@@ -807,6 +865,314 @@ void main() {
),
);
});
test('uploads via a resumable session when the server '
'selects resumable', () async {
const artifactId = 42;
const sessionUrl = 'https://storage.googleapis.com/session?upload_id=a';
final responses = [
http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreateReleaseArtifactResponse(
id: artifactId,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
),
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
];
when(
() => httpClient.send(any()),
).thenAnswer((_) async => responses.removeAt(0));
final tempDir = Directory.systemTemp.createTempSync();
final fixture = File(path.join(tempDir.path, 'release.txt'))
..writeAsBytesSync([1, 2, 3, 4, 5]);
await expectLater(
codePushClient.createReleaseArtifact(
appId: appId,
artifactPath: fixture.path,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
canSideload: canSideload,
podfileLockHash: podfileLockHash,
),
completes,
);
final requests = verify(
() => httpClient.send(captureAny()),
).captured.cast<http.BaseRequest>();
expect(requests, hasLength(2));
final uploadRequest = requests.last;
expect(uploadRequest.method, equals('PUT'));
expect(uploadRequest.url, equals(Uri.parse(sessionUrl)));
expect(uploadRequest.headers['content-range'], equals('bytes 0-4/5'));
});
group('resumable upload driver', () {
const sessionUrl = 'https://storage.googleapis.com/session?id=abc';
http.StreamedResponse resumableMeta() => http.StreamedResponse(
Stream.value(
utf8.encode(
json.encode(
const CreateReleaseArtifactResponse(
id: 42,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
size: size,
url: sessionUrl,
uploadMethod: ArtifactUploadMethod.resumable,
),
),
),
),
HttpStatus.ok,
);
String fiveByteFixture() {
final dir = Directory.systemTemp.createTempSync();
return (File(
path.join(dir.path, 'release.txt'),
)..writeAsBytesSync([1, 2, 3, 4, 5])).path;
}
void stubActions(
List<Future<http.StreamedResponse> Function()> actions,
) {
when(
() => httpClient.send(any()),
).thenAnswer((_) => actions.removeAt(0)());
}
Future<void> upload(String artifactPath) =>
codePushClient.createReleaseArtifact(
appId: appId,
artifactPath: artifactPath,
releaseId: releaseId,
arch: arch,
platform: platform,
hash: hash,
canSideload: canSideload,
podfileLockHash: null,
);
List<http.BaseRequest> capturedPuts() =>
verify(
() => httpClient.send(captureAny()),
).captured
.cast<http.BaseRequest>()
.where((r) => r.method == 'PUT')
.toList();
test('continues to the next chunk on a 308 response', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-2'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
expect(puts, hasLength(2));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
expect(puts[1].headers['content-range'], 'bytes 3-4/5');
});
test('throws when a chunk returns an error status', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.badRequest,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(
isA<CodePushException>().having(
(e) => e.message,
'message',
contains('Failed to upload artifact'),
),
),
);
});
test(
'queries the offset and resumes after a network failure',
() async {
stubActions([
() async => resumableMeta(),
() async => throw http.ClientException('connection reset'),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
// failed chunk, status query, resumed chunk from offset 2.
expect(puts, hasLength(3));
expect(puts[1].headers['content-range'], 'bytes */5');
expect(puts[2].headers['content-range'], 'bytes 2-4/5');
},
);
test('throws when the status query returns an error', () async {
stubActions([
() async => resumableMeta(),
() async => throw http.ClientException('connection reset'),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.badRequest,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('restarts from the beginning on a 308 with no range', () async {
stubActions([
() async => resumableMeta(),
// 308 without a `range` header: GCS stored nothing, restart at 0.
() async => http.StreamedResponse(const Stream.empty(), 308),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
expect(puts, hasLength(2));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
// Restarts from offset 0 rather than advancing.
expect(puts[1].headers['content-range'], 'bytes 0-4/5');
});
test('throws when a 308 never makes forward progress', () async {
// A session stuck at offset 0 keeps returning a 308 with no range;
// the upload must give up rather than spin forever.
stubActions([
() async => resumableMeta(),
for (var i = 0; i < 6; i++)
() async => http.StreamedResponse(const Stream.empty(), 308),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('queries the offset and resumes after a 5xx', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
() async =>
http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
]);
await expectLater(upload(fiveByteFixture()), completes);
final puts = capturedPuts();
// failed chunk, status query, resumed chunk from offset 2.
expect(puts, hasLength(3));
expect(puts[0].headers['content-range'], 'bytes 0-4/5');
expect(puts[1].headers['content-range'], 'bytes */5');
expect(puts[2].headers['content-range'], 'bytes 2-4/5');
});
test('throws when a 5xx persists through the status query', () async {
stubActions([
() async => resumableMeta(),
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
// The status query also fails, so we surface the error.
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
]);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
test('throws after exhausting retries on repeated 5xx', () async {
// Each chunk PUT fails with a 5xx while the status query reports no
// forward progress, so failures accumulate until we give up.
final actions = <Future<http.StreamedResponse> Function()>[
() async => resumableMeta(),
];
for (var i = 0; i < 6; i++) {
actions
..add(
() async => http.StreamedResponse(
const Stream.empty(),
HttpStatus.serviceUnavailable,
),
)
..add(
() async => http.StreamedResponse(
const Stream.empty(),
308,
headers: const {'range': 'bytes=0-1'},
),
);
}
stubActions(actions);
await expectLater(
upload(fiveByteFixture()),
throwsA(isA<CodePushException>()),
);
});
});
});
group('createApp', () {
@@ -24,13 +24,16 @@ export 'package:shorebird_code_push_protocol/src/messages/create_release_artifac
export 'package:shorebird_code_push_protocol/src/messages/create_user/create_user_request.dart';
export 'package:shorebird_code_push_protocol/src/messages/error_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_active_hours/get_active_hours_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_activity_heatmap/get_activity_heatmap_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_apps/get_apps_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_gcp_download_speed_test_url/get_gcp_download_speed_test_url200_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_gcp_upload_speed_test_url/get_gcp_upload_speed_test_url200_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_new_devices/get_new_devices_response.dart';
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_patch_metric_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,11 +47,21 @@ export 'package:shorebird_code_push_protocol/src/messages/update_app_collaborato
export 'package:shorebird_code_push_protocol/src/messages/update_patch/update_patch_request.dart';
export 'package:shorebird_code_push_protocol/src/messages/update_release/update_release_request.dart';
export 'package:shorebird_code_push_protocol/src/models/active_hour_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/activity_heatmap_cell.dart';
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/artifact_upload_method.dart';
export 'package:shorebird_code_push_protocol/src/models/channel.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_downloads_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_installs_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_app_patch_installs_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_patch_adoption_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_release_patch_downloads_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_release_patch_downloads_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_release_patch_installs_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_release_patch_installs_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
@@ -62,6 +75,10 @@ export 'package:shorebird_code_push_protocol/src/models/patch_adoption_entry.dar
export 'package:shorebird_code_push_protocol/src/models/patch_adoption_point.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/patch_metric_breakdown_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_metric_current_window.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/patch_metric_window.dart';
export 'package:shorebird_code_push_protocol/src/models/pending_release.dart';
export 'package:shorebird_code_push_protocol/src/models/private_user.dart';
export 'package:shorebird_code_push_protocol/src/models/public_user.dart';
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template create_patch_artifact_response}
@@ -16,6 +17,7 @@ class CreatePatchArtifactResponse {
required this.hash,
required this.size,
required this.url,
this.uploadMethod,
});
/// Converts a `Map<String, dynamic>` to a [CreatePatchArtifactResponse].
@@ -31,6 +33,9 @@ class CreatePatchArtifactResponse {
hash: json['hash'] as String,
size: json['size'] as int,
url: json['url'] as String,
uploadMethod: ArtifactUploadMethod.maybeFromJson(
json['upload_method'] as String?,
),
),
);
}
@@ -64,9 +69,16 @@ class CreatePatchArtifactResponse {
/// The size of the artifact in bytes.
final int size;
/// The upload URL for the artifact.
/// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod
/// .multipart], or a resumable session URI for [ArtifactUploadMethod
/// .resumable]).
final String url;
/// How the client should upload the artifact bytes to [url]. Null on
/// responses from older servers, which always implied
/// [ArtifactUploadMethod.multipart].
final ArtifactUploadMethod? uploadMethod;
/// Converts a [CreatePatchArtifactResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
@@ -77,6 +89,7 @@ class CreatePatchArtifactResponse {
'hash': hash,
'size': size,
'url': url,
'upload_method': uploadMethod?.toJson(),
};
}
@@ -89,6 +102,7 @@ class CreatePatchArtifactResponse {
hash,
size,
url,
uploadMethod,
]);
@override
@@ -101,6 +115,7 @@ class CreatePatchArtifactResponse {
platform == other.platform &&
hash == other.hash &&
size == other.size &&
url == other.url;
url == other.url &&
uploadMethod == other.uploadMethod;
}
}
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/artifact_upload_method.dart';
import 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
/// {@template create_release_artifact_response}
@@ -16,6 +17,7 @@ class CreateReleaseArtifactResponse {
required this.hash,
required this.size,
required this.url,
this.uploadMethod,
});
/// Converts a `Map<String, dynamic>` to a [CreateReleaseArtifactResponse].
@@ -31,6 +33,9 @@ class CreateReleaseArtifactResponse {
hash: json['hash'] as String,
size: json['size'] as int,
url: json['url'] as String,
uploadMethod: ArtifactUploadMethod.maybeFromJson(
json['upload_method'] as String?,
),
),
);
}
@@ -64,9 +69,16 @@ class CreateReleaseArtifactResponse {
/// The size of the artifact in bytes.
final int size;
/// The upload URL for the artifact.
/// The upload URL for the artifact (a signed URL for [ArtifactUploadMethod
/// .multipart], or a resumable session URI for [ArtifactUploadMethod
/// .resumable]).
final String url;
/// How the client should upload the artifact bytes to [url]. Null on
/// responses from older servers, which always implied
/// [ArtifactUploadMethod.multipart].
final ArtifactUploadMethod? uploadMethod;
/// Converts a [CreateReleaseArtifactResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
@@ -77,6 +89,7 @@ class CreateReleaseArtifactResponse {
'hash': hash,
'size': size,
'url': url,
'upload_method': uploadMethod?.toJson(),
};
}
@@ -89,6 +102,7 @@ class CreateReleaseArtifactResponse {
hash,
size,
url,
uploadMethod,
]);
@override
@@ -101,6 +115,7 @@ class CreateReleaseArtifactResponse {
platform == other.platform &&
hash == other.hash &&
size == other.size &&
url == other.url;
url == other.url &&
uploadMethod == other.uploadMethod;
}
}
@@ -0,0 +1,100 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/activity_heatmap_cell.dart';
/// {@template get_activity_heatmap_response}
/// The response body for GET /apps/{appId}/metrics/activity-heatmap. A 7×24
/// grid of average active devices per UTC weekday-hour, powering the
/// insights activity heatmap.
/// {@endtemplate}
@immutable
class GetActivityHeatmapResponse {
/// {@macro get_activity_heatmap_response}
const GetActivityHeatmapResponse({
required this.cells,
required this.busiestDayOfWeekUtc,
required this.busiestHourUtc,
required this.lookbackDays,
required this.asOf,
});
/// Converts a `Map<String, dynamic>` to a [GetActivityHeatmapResponse].
factory GetActivityHeatmapResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetActivityHeatmapResponse',
json,
() => GetActivityHeatmapResponse(
cells: (json['cells'] as List)
.map<ActivityHeatmapCell>(
(e) => ActivityHeatmapCell.fromJson(e as Map<String, dynamic>),
)
.toList(),
busiestDayOfWeekUtc:
checkedKey(json, 'busiest_day_of_week_utc') as int?,
busiestHourUtc: checkedKey(json, 'busiest_hour_utc') as int?,
lookbackDays: json['lookback_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 GetActivityHeatmapResponse? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return GetActivityHeatmapResponse.fromJson(json);
}
/// 168 entries (7 weekdays × 24 hours), zero-filled, ordered by
/// day_of_week_utc (17) then hour_utc (023) ascending.
final List<ActivityHeatmapCell> cells;
/// UTC day-of-week (17) of the cell with the highest average active
/// devices, for peak-relative coloring and labeling. Null when there is
/// no data.
final int? busiestDayOfWeekUtc;
/// UTC hour (023) of the busiest cell. Null when there is no data.
final int? busiestHourUtc;
/// Number of days of history the heatmap is computed over.
final int lookbackDays;
/// 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 [GetActivityHeatmapResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'cells': cells.map((e) => e.toJson()).toList(),
'busiest_day_of_week_utc': busiestDayOfWeekUtc,
'busiest_hour_utc': busiestHourUtc,
'lookback_days': lookbackDays,
'as_of': asOf.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
listHash(cells),
busiestDayOfWeekUtc,
busiestHourUtc,
lookbackDays,
asOf,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetActivityHeatmapResponse &&
listsEqual(cells, other.cells) &&
busiestDayOfWeekUtc == other.busiestDayOfWeekUtc &&
busiestHourUtc == other.busiestHourUtc &&
lookbackDays == other.lookbackDays &&
asOf == other.asOf;
}
}
@@ -0,0 +1,88 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template get_new_devices_response}
/// The response body for GET /apps/{appId}/metrics/new-devices.
/// {@endtemplate}
@immutable
class GetNewDevicesResponse {
/// {@macro get_new_devices_response}
const GetNewDevicesResponse({
required this.current,
required this.previous,
required this.windowDays,
required this.asOf,
});
/// Converts a `Map<String, dynamic>` to a [GetNewDevicesResponse].
factory GetNewDevicesResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetNewDevicesResponse',
json,
() => GetNewDevicesResponse(
current: json['current'] as int,
previous: checkedKey(json, 'previous') as int?,
windowDays: json['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 GetNewDevicesResponse? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return GetNewDevicesResponse.fromJson(json);
}
/// Devices first seen in the window `[as_of window_days, as_of)`.
/// An exact (non-HLL) count.
final int current;
/// Devices first seen in the equal-length window immediately
/// preceding the current one, or null when that window would begin
/// before the metrics data floor (comparing against
/// partially-recorded history would show a misleading delta).
final int? previous;
/// The window length in days. Hardcoded server-side in v1; echoed
/// so clients label the metric from the response rather than
/// assuming a length.
final int windowDays;
/// 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 [GetNewDevicesResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'current': current,
'previous': previous,
'window_days': windowDays,
'as_of': asOf.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
current,
previous,
windowDays,
asOf,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetNewDevicesResponse &&
current == other.current &&
previous == other.previous &&
windowDays == other.windowDays &&
asOf == other.asOf;
}
}
@@ -0,0 +1,104 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_current_window.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_window.dart';
/// {@template get_patch_metric_response}
/// The response body for the patch-installs and patch-downloads metric
/// endpoints (app- and release-scoped): a current/previous envelope.
/// `previous` covers the equal-length window immediately preceding
/// `current`; period-over-period deltas are client display logic over the
/// two totals. `previous` is omitted only when the prior window predates
/// the data floor (no comparison data exists). When it reaches past the
/// plan's metrics-history horizon, `previous` is still present with its
/// total the delta renders but without a `time_series` (no
/// prior-window overlay): granular history is the resolution the horizon
/// gates, the scalar comparison is not.
/// {@endtemplate}
@immutable
class GetPatchMetricResponse {
/// {@macro get_patch_metric_response}
const GetPatchMetricResponse({
required this.asOf,
required this.granularity,
required this.current,
this.previous,
});
/// Converts a `Map<String, dynamic>` to a [GetPatchMetricResponse].
factory GetPatchMetricResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetPatchMetricResponse',
json,
() => GetPatchMetricResponse(
asOf: DateTime.parse(json['as_of'] as String),
granularity: checkedKey(json, 'granularity') as String?,
current: PatchMetricCurrentWindow.fromJson(
json['current'] as Map<String, dynamic>,
),
previous: PatchMetricWindow.maybeFromJson(
json['previous'] as Map<String, dynamic>?,
),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static GetPatchMetricResponse? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return GetPatchMetricResponse.fromJson(json);
}
/// 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 job and may lag by up to ~1 hour.
final DateTime asOf;
/// The time-series bucket resolution (`hour`, `day`, or `week`), or
/// null when no time series was requested. Applies to both windows.
final String? granularity;
/// The `current` window of the patch-metric envelope: the base window atom
/// plus the optional `breakdown`. Only `current` carries a breakdown no
/// chart renders a previous-window breakdown, so the asymmetry is declared
/// in the contract rather than left as an optional-but-never-populated
/// field.
final PatchMetricCurrentWindow current;
/// One window of the patch-metric envelope: the summed count over the
/// window's effective range, with a per-bucket series when a `granularity`
/// was requested. This base atom is the full shape of `previous`;
/// `current` extends it (see PatchMetricCurrentWindow).
final PatchMetricWindow? previous;
/// Converts a [GetPatchMetricResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'as_of': asOf.toIso8601String(),
'granularity': granularity,
'current': current.toJson(),
'previous': previous?.toJson(),
};
}
@override
int get hashCode => Object.hashAll([
asOf,
granularity,
current,
previous,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetPatchMetricResponse &&
asOf == other.asOf &&
granularity == other.granularity &&
current == other.current &&
previous == other.previous;
}
}
@@ -15,7 +15,6 @@ class PatchCheckRequest {
required this.appId,
required this.channel,
this.patchNumber,
this.patchHash,
this.clientId,
this.currentPatchNumber,
});
@@ -28,7 +27,6 @@ class PatchCheckRequest {
() => PatchCheckRequest(
releaseVersion: json['release_version'] as String,
patchNumber: json['patch_number'] as int?,
patchHash: json['patch_hash'] as String?,
platform: ReleasePlatform.fromJson(json['platform'] as String),
arch: json['arch'] as String,
appId: json['app_id'] as String,
@@ -56,9 +54,6 @@ class PatchCheckRequest {
/// number. If omitted, the server returns the latest available.
final int? patchNumber;
/// The current patch hash of the app.
final String? patchHash;
/// A platform to which a Shorebird release can be deployed.
final ReleasePlatform platform;
@@ -85,7 +80,6 @@ class PatchCheckRequest {
return {
'release_version': releaseVersion,
'patch_number': patchNumber,
'patch_hash': patchHash,
'platform': platform.toJson(),
'arch': arch,
'app_id': appId,
@@ -99,7 +93,6 @@ class PatchCheckRequest {
int get hashCode => Object.hashAll([
releaseVersion,
patchNumber,
patchHash,
platform,
arch,
appId,
@@ -114,7 +107,6 @@ class PatchCheckRequest {
return other is PatchCheckRequest &&
releaseVersion == other.releaseVersion &&
patchNumber == other.patchNumber &&
patchHash == other.patchHash &&
platform == other.platform &&
arch == other.arch &&
appId == other.appId &&
@@ -0,0 +1,76 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template activity_heatmap_cell}
/// Average number of distinct active devices during one (UTC day-of-week,
/// UTC hour-of-day) cell, averaged across every occurrence of that weekday
/// in the lookback window (occurrences with no activity count as zero).
/// {@endtemplate}
@immutable
class ActivityHeatmapCell {
/// {@macro activity_heatmap_cell}
const ActivityHeatmapCell({
required this.dayOfWeekUtc,
required this.hourUtc,
required this.averageActiveDevices,
});
/// Converts a `Map<String, dynamic>` to an [ActivityHeatmapCell].
factory ActivityHeatmapCell.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'ActivityHeatmapCell',
json,
() => ActivityHeatmapCell(
dayOfWeekUtc: json['day_of_week_utc'] as int,
hourUtc: json['hour_utc'] as int,
averageActiveDevices: (json['average_active_devices'] as num)
.toDouble(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static ActivityHeatmapCell? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return ActivityHeatmapCell.fromJson(json);
}
/// Day of week in UTC, 17 where 1 = Sunday and 7 = Saturday.
final int dayOfWeekUtc;
/// Hour of day in UTC, 023.
final int hourUtc;
/// Mean distinct active devices seen during this UTC weekday-hour,
/// averaged over the weekday's occurrences in the window with implicit
/// zeros included.
final double averageActiveDevices;
/// Converts an [ActivityHeatmapCell] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'day_of_week_utc': dayOfWeekUtc,
'hour_utc': hourUtc,
'average_active_devices': averageActiveDevices,
};
}
@override
int get hashCode => Object.hashAll([
dayOfWeekUtc,
hourUtc,
averageActiveDevices,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActivityHeatmapCell &&
dayOfWeekUtc == other.dayOfWeekUtc &&
hourUtc == other.hourUtc &&
averageActiveDevices == other.averageActiveDevices;
}
}
@@ -0,0 +1,44 @@
/// How a client should upload an artifact's bytes to storage.
///
/// Returned by the create-artifact endpoints so the client knows how to use the
/// `url` it was handed. Absent (null) on responses from older servers, which
/// always implied [multipart].
enum ArtifactUploadMethod {
/// Legacy single `multipart/form-data` POST of the file to a signed URL.
multipart._('multipart'),
/// Resumable upload: PUT the bytes (chunked, with `Content-Range`) to a
/// server-initiated GCS resumable session URI given in `url`. The session is
/// size-bound at initiation, so GCS rejects an oversized upload.
resumable._('resumable');
const ArtifactUploadMethod._(this.value);
/// Creates an [ArtifactUploadMethod] from a json value.
factory ArtifactUploadMethod.fromJson(String json) {
return ArtifactUploadMethod.values.firstWhere(
(value) => value.value == json,
orElse: () =>
throw FormatException('Unknown ArtifactUploadMethod value: $json'),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static ArtifactUploadMethod? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return ArtifactUploadMethod.fromJson(json);
}
/// The wire value of the enum, 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,37 @@
enum GetAppPatchDownloadsParameter2 {
hour._('hour'),
day._('day'),
week._('week');
const GetAppPatchDownloadsParameter2._(this.value);
/// Creates a GetAppPatchDownloadsParameter2 from a json value.
factory GetAppPatchDownloadsParameter2.fromJson(String json) {
return GetAppPatchDownloadsParameter2.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetAppPatchDownloadsParameter2 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetAppPatchDownloadsParameter2? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetAppPatchDownloadsParameter2.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,35 @@
enum GetAppPatchDownloadsParameter3 {
release._('release');
const GetAppPatchDownloadsParameter3._(this.value);
/// Creates a GetAppPatchDownloadsParameter3 from a json value.
factory GetAppPatchDownloadsParameter3.fromJson(String json) {
return GetAppPatchDownloadsParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetAppPatchDownloadsParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetAppPatchDownloadsParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetAppPatchDownloadsParameter3.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,37 @@
enum GetAppPatchInstallsParameter2 {
hour._('hour'),
day._('day'),
week._('week');
const GetAppPatchInstallsParameter2._(this.value);
/// Creates a GetAppPatchInstallsParameter2 from a json value.
factory GetAppPatchInstallsParameter2.fromJson(String json) {
return GetAppPatchInstallsParameter2.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetAppPatchInstallsParameter2 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetAppPatchInstallsParameter2? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetAppPatchInstallsParameter2.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,35 @@
enum GetAppPatchInstallsParameter3 {
release._('release');
const GetAppPatchInstallsParameter3._(this.value);
/// Creates a GetAppPatchInstallsParameter3 from a json value.
factory GetAppPatchInstallsParameter3.fromJson(String json) {
return GetAppPatchInstallsParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetAppPatchInstallsParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetAppPatchInstallsParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetAppPatchInstallsParameter3.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,37 @@
enum GetReleasePatchDownloadsParameter2 {
hour._('hour'),
day._('day'),
week._('week');
const GetReleasePatchDownloadsParameter2._(this.value);
/// Creates a GetReleasePatchDownloadsParameter2 from a json value.
factory GetReleasePatchDownloadsParameter2.fromJson(String json) {
return GetReleasePatchDownloadsParameter2.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetReleasePatchDownloadsParameter2 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetReleasePatchDownloadsParameter2? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetReleasePatchDownloadsParameter2.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,35 @@
enum GetReleasePatchDownloadsParameter3 {
patch._('patch');
const GetReleasePatchDownloadsParameter3._(this.value);
/// Creates a GetReleasePatchDownloadsParameter3 from a json value.
factory GetReleasePatchDownloadsParameter3.fromJson(String json) {
return GetReleasePatchDownloadsParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetReleasePatchDownloadsParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetReleasePatchDownloadsParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetReleasePatchDownloadsParameter3.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,37 @@
enum GetReleasePatchInstallsParameter2 {
hour._('hour'),
day._('day'),
week._('week');
const GetReleasePatchInstallsParameter2._(this.value);
/// Creates a GetReleasePatchInstallsParameter2 from a json value.
factory GetReleasePatchInstallsParameter2.fromJson(String json) {
return GetReleasePatchInstallsParameter2.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetReleasePatchInstallsParameter2 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetReleasePatchInstallsParameter2? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetReleasePatchInstallsParameter2.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,35 @@
enum GetReleasePatchInstallsParameter3 {
patch._('patch');
const GetReleasePatchInstallsParameter3._(this.value);
/// Creates a GetReleasePatchInstallsParameter3 from a json value.
factory GetReleasePatchInstallsParameter3.fromJson(String json) {
return GetReleasePatchInstallsParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetReleasePatchInstallsParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetReleasePatchInstallsParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetReleasePatchInstallsParameter3.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;
}
@@ -1,5 +1,6 @@
enum GetUniqueUsersParameter3 {
platform._('platform');
platform._('platform'),
releaseVersion._('release_version');
const GetUniqueUsersParameter3._(this.value);
@@ -0,0 +1,90 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart';
/// {@template patch_metric_breakdown_entry}
/// A patch metric for one value of the `group_by` dimension (one release
/// at app scope, or one patch at release scope), optionally with its own
/// time series.
/// {@endtemplate}
@immutable
class PatchMetricBreakdownEntry {
/// {@macro patch_metric_breakdown_entry}
const PatchMetricBreakdownEntry({
required this.groupBy,
required this.groupValue,
required this.count,
this.timeSeries,
});
/// Converts a `Map<String, dynamic>` to a [PatchMetricBreakdownEntry].
factory PatchMetricBreakdownEntry.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchMetricBreakdownEntry',
json,
() => PatchMetricBreakdownEntry(
groupBy: json['group_by'] as String,
groupValue: json['group_value'] as String,
count: json['count'] as int,
timeSeries: (json['time_series'] as List?)
?.map<PatchMetricTimeSeriesEntry>(
(e) => PatchMetricTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchMetricBreakdownEntry? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchMetricBreakdownEntry.fromJson(json);
}
/// The dimension this entry breaks down by ("release" or "patch").
final String groupBy;
/// The value within `group_by`: the release version, or the patch
/// number as a string.
final String groupValue;
/// The summed count for this group over the window.
final int count;
/// Per-bucket series for this group, present only when a `granularity`
/// was requested; otherwise null.
final List<PatchMetricTimeSeriesEntry>? timeSeries;
/// Converts a [PatchMetricBreakdownEntry] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'group_by': groupBy,
'group_value': groupValue,
'count': count,
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
groupBy,
groupValue,
count,
listHash(timeSeries),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchMetricBreakdownEntry &&
groupBy == other.groupBy &&
groupValue == other.groupValue &&
count == other.count &&
listsEqual(timeSeries, other.timeSeries);
}
}
@@ -0,0 +1,108 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/metrics_range.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_breakdown_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart';
/// {@template patch_metric_current_window}
/// The `current` window of the patch-metric envelope: the base window atom
/// plus the optional `breakdown`. Only `current` carries a breakdown no
/// chart renders a previous-window breakdown, so the asymmetry is declared
/// in the contract rather than left as an optional-but-never-populated
/// field.
/// {@endtemplate}
@immutable
class PatchMetricCurrentWindow {
/// {@macro patch_metric_current_window}
const PatchMetricCurrentWindow({
required this.count,
required this.range,
this.timeSeries,
this.breakdown,
});
/// Converts a `Map<String, dynamic>` to a [PatchMetricCurrentWindow].
factory PatchMetricCurrentWindow.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchMetricCurrentWindow',
json,
() => PatchMetricCurrentWindow(
count: json['count'] as int,
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
timeSeries: (json['time_series'] as List?)
?.map<PatchMetricTimeSeriesEntry>(
(e) => PatchMetricTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
breakdown: (json['breakdown'] as List?)
?.map<PatchMetricBreakdownEntry>(
(e) =>
PatchMetricBreakdownEntry.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchMetricCurrentWindow? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchMetricCurrentWindow.fromJson(json);
}
/// The summed count (installs or downloads) over this window. Unlike
/// the HLL-based metrics, per-bucket `time_series` values sum to this
/// total.
final int count;
/// The effective (post-default, post-clamp) window a metrics response
/// or one window of a metrics envelope covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
final MetricsRange range;
/// Per-bucket series for this window, present only when a
/// `granularity` was requested; otherwise null. On `previous`, also
/// null when the prior window reaches past the plan's metrics-history
/// horizon (the total is still present only the granular overlay is
/// withheld). Empty buckets are omitted gap-fill against this
/// window's `range`.
final List<PatchMetricTimeSeriesEntry>? timeSeries;
/// Per-group counts for this window, present only when a
/// `group_by` was requested; otherwise null.
final List<PatchMetricBreakdownEntry>? breakdown;
/// Converts a [PatchMetricCurrentWindow] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'count': count,
'range': range.toJson(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
'breakdown': breakdown?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
count,
range,
listHash(timeSeries),
listHash(breakdown),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchMetricCurrentWindow &&
count == other.count &&
range == other.range &&
listsEqual(timeSeries, other.timeSeries) &&
listsEqual(breakdown, other.breakdown);
}
}
@@ -0,0 +1,64 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template patch_metric_time_series_entry}
/// One bucket of a patch-metric time series: the summed count (installs or
/// downloads, per the endpoint) in the bucket starting at `period`.
/// {@endtemplate}
@immutable
class PatchMetricTimeSeriesEntry {
/// {@macro patch_metric_time_series_entry}
const PatchMetricTimeSeriesEntry({
required this.period,
required this.count,
});
/// Converts a `Map<String, dynamic>` to a [PatchMetricTimeSeriesEntry].
factory PatchMetricTimeSeriesEntry.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchMetricTimeSeriesEntry',
json,
() => PatchMetricTimeSeriesEntry(
period: DateTime.parse(json['period'] as String),
count: json['count'] as int,
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchMetricTimeSeriesEntry? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchMetricTimeSeriesEntry.fromJson(json);
}
/// The bucket start (UTC).
final DateTime period;
/// The summed count in this bucket.
final int count;
/// Converts a [PatchMetricTimeSeriesEntry] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'period': period.toIso8601String(),
'count': count,
};
}
@override
int get hashCode => Object.hashAll([
period,
count,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchMetricTimeSeriesEntry &&
period == other.period &&
count == other.count;
}
}
@@ -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/metrics_range.dart';
import 'package:shorebird_code_push_protocol/src/models/patch_metric_time_series_entry.dart';
/// {@template patch_metric_window}
/// One window of the patch-metric envelope: the summed count over the
/// window's effective range, with a per-bucket series when a `granularity`
/// was requested. This base atom is the full shape of `previous`;
/// `current` extends it (see PatchMetricCurrentWindow).
/// {@endtemplate}
@immutable
class PatchMetricWindow {
/// {@macro patch_metric_window}
const PatchMetricWindow({
required this.count,
required this.range,
this.timeSeries,
});
/// Converts a `Map<String, dynamic>` to a [PatchMetricWindow].
factory PatchMetricWindow.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'PatchMetricWindow',
json,
() => PatchMetricWindow(
count: json['count'] as int,
range: MetricsRange.fromJson(json['range'] as Map<String, dynamic>),
timeSeries: (json['time_series'] as List?)
?.map<PatchMetricTimeSeriesEntry>(
(e) => PatchMetricTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static PatchMetricWindow? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return PatchMetricWindow.fromJson(json);
}
/// The summed count (installs or downloads) over this window. Unlike
/// the HLL-based metrics, per-bucket `time_series` values sum to this
/// total.
final int count;
/// The effective (post-default, post-clamp) window a metrics response
/// or one window of a metrics envelope covers. Always echoed by the
/// server; clients must treat it as authoritative rather than reusing
/// the requested range.
final MetricsRange range;
/// Per-bucket series for this window, present only when a
/// `granularity` was requested; otherwise null. On `previous`, also
/// null when the prior window reaches past the plan's metrics-history
/// horizon (the total is still present only the granular overlay is
/// withheld). Empty buckets are omitted gap-fill against this
/// window's `range`.
final List<PatchMetricTimeSeriesEntry>? timeSeries;
/// Converts a [PatchMetricWindow] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'count': count,
'range': range.toJson(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
count,
range,
listHash(timeSeries),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PatchMetricWindow &&
count == other.count &&
range == other.range &&
listsEqual(timeSeries, other.timeSeries);
}
}
@@ -48,7 +48,10 @@ class UniqueUsersBreakdownEntry {
/// The dimension this entry breaks down by (e.g. "platform").
final String groupBy;
/// The value within `group_by` (e.g. "android").
/// The value within `group_by` (e.g. "android"). For
/// `group_by=release_version`, the empty string is the
/// unknown-version group devices whose client is too old to
/// report a release version.
final String groupValue;
/// Distinct active devices for this group over the window (an HLL
@@ -11,11 +11,11 @@ environment:
dependencies:
collection: ^1.19.1
meta: ^1.16.0
meta: ^1.18.3
dev_dependencies:
mocktail: ^1.0.5
# Only used by `tool/gen.dart` to regenerate lib/src/ from the OpenAPI
# spec.
space_gen: ^1.2.2
test: ^1.31.1
space_gen: ^1.3.0
test: ^1.31.2
@@ -0,0 +1,39 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetActivityHeatmapResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetActivityHeatmapResponse(
cells: <ActivityHeatmapCell>[
ActivityHeatmapCell(
dayOfWeekUtc: 0,
hourUtc: 0,
averageActiveDevices: 0,
),
],
busiestDayOfWeekUtc: 0,
busiestHourUtc: 0,
lookbackDays: 0,
asOf: DateTime.utc(2024),
);
final parsed = GetActivityHeatmapResponse.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetActivityHeatmapResponse.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetActivityHeatmapResponse.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('GetNewDevicesResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetNewDevicesResponse(
current: 0,
previous: 0,
windowDays: 0,
asOf: DateTime.utc(2024),
);
final parsed = GetNewDevicesResponse.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetNewDevicesResponse.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetNewDevicesResponse.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,35 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetPatchMetricResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetPatchMetricResponse(
asOf: DateTime.utc(2024),
granularity: 'example',
current: PatchMetricCurrentWindow(
count: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
),
);
final parsed = GetPatchMetricResponse.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetPatchMetricResponse.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetPatchMetricResponse.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('ActivityHeatmapCell', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = ActivityHeatmapCell(
dayOfWeekUtc: 0,
hourUtc: 0,
averageActiveDevices: 0,
);
final parsed = ActivityHeatmapCell.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(ActivityHeatmapCell.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => ActivityHeatmapCell.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetAppPatchDownloadsParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetAppPatchDownloadsParameter2.values.first;
final parsed = GetAppPatchDownloadsParameter2.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetAppPatchDownloadsParameter2.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetAppPatchDownloadsParameter2.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetAppPatchDownloadsParameter2.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetAppPatchDownloadsParameter2.values) {
expect(
GetAppPatchDownloadsParameter2.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetAppPatchDownloadsParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetAppPatchDownloadsParameter3.values.first;
final parsed = GetAppPatchDownloadsParameter3.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetAppPatchDownloadsParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetAppPatchDownloadsParameter3.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetAppPatchDownloadsParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetAppPatchDownloadsParameter3.values) {
expect(
GetAppPatchDownloadsParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetAppPatchInstallsParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetAppPatchInstallsParameter2.values.first;
final parsed = GetAppPatchInstallsParameter2.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetAppPatchInstallsParameter2.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetAppPatchInstallsParameter2.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetAppPatchInstallsParameter2.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetAppPatchInstallsParameter2.values) {
expect(
GetAppPatchInstallsParameter2.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetAppPatchInstallsParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetAppPatchInstallsParameter3.values.first;
final parsed = GetAppPatchInstallsParameter3.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetAppPatchInstallsParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetAppPatchInstallsParameter3.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetAppPatchInstallsParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetAppPatchInstallsParameter3.values) {
expect(
GetAppPatchInstallsParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetReleasePatchDownloadsParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetReleasePatchDownloadsParameter2.values.first;
final parsed = GetReleasePatchDownloadsParameter2.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetReleasePatchDownloadsParameter2.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetReleasePatchDownloadsParameter2.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetReleasePatchDownloadsParameter2.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetReleasePatchDownloadsParameter2.values) {
expect(
GetReleasePatchDownloadsParameter2.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetReleasePatchDownloadsParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetReleasePatchDownloadsParameter3.values.first;
final parsed = GetReleasePatchDownloadsParameter3.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetReleasePatchDownloadsParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetReleasePatchDownloadsParameter3.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetReleasePatchDownloadsParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetReleasePatchDownloadsParameter3.values) {
expect(
GetReleasePatchDownloadsParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetReleasePatchInstallsParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetReleasePatchInstallsParameter2.values.first;
final parsed = GetReleasePatchInstallsParameter2.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetReleasePatchInstallsParameter2.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetReleasePatchInstallsParameter2.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetReleasePatchInstallsParameter2.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetReleasePatchInstallsParameter2.values) {
expect(
GetReleasePatchInstallsParameter2.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,44 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetReleasePatchInstallsParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetReleasePatchInstallsParameter3.values.first;
final parsed = GetReleasePatchInstallsParameter3.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetReleasePatchInstallsParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetReleasePatchInstallsParameter3.maybeFromJson(
'__invalid_enum_value__',
),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetReleasePatchInstallsParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetReleasePatchInstallsParameter3.values) {
expect(
GetReleasePatchInstallsParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,31 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchMetricBreakdownEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchMetricBreakdownEntry(
groupBy: 'example',
groupValue: 'example',
count: 0,
);
final parsed = PatchMetricBreakdownEntry.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchMetricBreakdownEntry.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchMetricBreakdownEntry.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,31 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchMetricCurrentWindow', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchMetricCurrentWindow(
count: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
);
final parsed = PatchMetricCurrentWindow.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchMetricCurrentWindow.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchMetricCurrentWindow.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('PatchMetricTimeSeriesEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchMetricTimeSeriesEntry(
period: DateTime.utc(2024),
count: 0,
);
final parsed = PatchMetricTimeSeriesEntry.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchMetricTimeSeriesEntry.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchMetricTimeSeriesEntry.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,31 @@
// GENERATED do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('PatchMetricWindow', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = PatchMetricWindow(
count: 0,
range: MetricsRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
);
final parsed = PatchMetricWindow.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(PatchMetricWindow.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => PatchMetricWindow.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -18,5 +18,21 @@ void main() {
equals(request.toJson()),
);
});
test('can be (de)serialized with a resumable upload method', () {
const response = CreatePatchArtifactResponse(
id: 42,
patchId: 1,
arch: 'arm64',
platform: ReleasePlatform.android,
hash: '1234',
size: 9876,
url: 'https://example.com',
uploadMethod: ArtifactUploadMethod.resumable,
);
final decoded = CreatePatchArtifactResponse.fromJson(response.toJson());
expect(decoded.toJson(), equals(response.toJson()));
expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable));
});
});
}
@@ -18,5 +18,21 @@ void main() {
equals(response.toJson()),
);
});
test('can be (de)serialized with a resumable upload method', () {
const response = CreateReleaseArtifactResponse(
id: 42,
releaseId: 1,
arch: 'arm64',
platform: ReleasePlatform.android,
hash: '1234',
size: 9876,
url: 'https://example.com',
uploadMethod: ArtifactUploadMethod.resumable,
);
final decoded = CreateReleaseArtifactResponse.fromJson(response.toJson());
expect(decoded.toJson(), equals(response.toJson()));
expect(decoded.uploadMethod, equals(ArtifactUploadMethod.resumable));
});
});
}
@@ -6,7 +6,6 @@ void main() {
const request = PatchCheckRequest(
releaseVersion: '1',
patchNumber: 2,
patchHash: '3',
platform: ReleasePlatform.android,
arch: 'arm64',
appId: 'app_123',
@@ -26,7 +25,6 @@ void main() {
const copy = PatchCheckRequest(
releaseVersion: '1',
patchNumber: 2,
patchHash: '3',
platform: ReleasePlatform.android,
arch: 'arm64',
appId: 'app_123',
@@ -37,7 +35,6 @@ void main() {
const different = PatchCheckRequest(
releaseVersion: '1',
patchNumber: 2,
patchHash: '3',
platform: ReleasePlatform.android,
arch: 'arm64',
appId: 'app_123',
@@ -0,0 +1,28 @@
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group(ArtifactUploadMethod, () {
test('round-trips through json', () {
for (final method in ArtifactUploadMethod.values) {
expect(ArtifactUploadMethod.fromJson(method.toJson()), equals(method));
}
});
test('maybeFromJson returns null for null', () {
expect(ArtifactUploadMethod.maybeFromJson(null), isNull);
});
test('fromJson throws on an unknown value', () {
expect(
() => ArtifactUploadMethod.fromJson('nope'),
throwsFormatException,
);
});
test('toString returns the wire value', () {
expect(ArtifactUploadMethod.multipart.toString(), 'multipart');
expect(ArtifactUploadMethod.resumable.toString(), 'resumable');
});
});
}
+19 -7
View File
@@ -36,7 +36,10 @@ class StripeApi {
final response = await _client.get(uri, headers: _authHeaders);
if (response.statusCode != HttpStatus.ok) {
throw Exception('Failed to retrieve customer with id $customerId');
throw StripeApiException.fromResponse(
response,
message: 'Failed to retrieve customer with id $customerId',
);
}
return StripeCustomer.fromJson(
@@ -55,8 +58,9 @@ class StripeApi {
final response = await _client.get(uri, headers: _authHeaders);
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Failed to retrieve subscription with id $subscriptionId',
throw StripeApiException.fromResponse(
response,
message: 'Failed to retrieve subscription with id $subscriptionId',
);
}
@@ -96,10 +100,14 @@ class StripeApi {
);
if (response.statusCode != HttpStatus.ok) {
throw Exception('''
throw StripeApiException.fromResponse(
response,
message:
'''
Failed to report $value for customer $customerId. Error:
${response.body}
''');
''',
);
}
}
@@ -146,10 +154,14 @@ ${response.body}
final response = await _client.get(uri, headers: _authHeaders);
if (response.statusCode != HttpStatus.ok) {
throw Exception('''
throw StripeApiException.fromResponse(
response,
message:
'''
Failed to get paged response from $path with params $queryParameters. Error:
${response.body}
''');
''',
);
}
final pagedResponse = PagedResponse.fromJson(
@@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// {@template stripe_api_exception}
/// An exception thrown when a request to the Stripe API returns a non-success
/// response.
///
/// Carries the HTTP [statusCode] of the failed response so callers can
/// distinguish, for example, a missing resource (`404`) from rate limiting
/// (`429`). When the response body is a Stripe error object, the
/// machine-readable [code] (e.g. `resource_missing`, `rate_limit`) is also
/// surfaced.
///
/// See https://docs.stripe.com/api/errors.
/// {@endtemplate}
class StripeApiException implements Exception {
/// {@macro stripe_api_exception}
StripeApiException({
required this.statusCode,
required this.message,
this.code,
});
/// Builds a [StripeApiException] from a failed [response].
///
/// [message] is a human-readable description supplied by the caller and is
/// preserved verbatim. The Stripe error [code] is parsed from the response
/// body when it is a JSON error object.
///
/// Never throws: a non-JSON or unexpected body (e.g. an HTML gateway error)
/// simply leaves [code] null.
factory StripeApiException.fromResponse(
http.Response response, {
required String message,
}) {
String? code;
try {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final error = decoded['error'];
if (error is Map<String, dynamic> && error['code'] is String) {
code = error['code'] as String;
}
}
} on Object catch (_) {
// Non-JSON or unexpected body; the status code is still meaningful.
}
return StripeApiException(
statusCode: response.statusCode,
message: message,
code: code,
);
}
/// The HTTP status code of the failed response.
final int statusCode;
/// The Stripe machine-readable error code (e.g. `resource_missing`), when the
/// response body was a Stripe error object; otherwise null.
final String? code;
/// A human-readable description of the failure.
final String message;
@override
String toString() =>
'StripeApiException($statusCode${code == null ? '' : ', $code'}): '
'$message';
}
+1
View File
@@ -1,2 +1,3 @@
export 'src/models/models.dart';
export 'src/stripe_api.dart';
export 'src/stripe_api_exception.dart';
+1 -1
View File
@@ -17,4 +17,4 @@ dev_dependencies:
build_runner: ^2.15.0
json_serializable: ^6.13.2
mocktail: ^1.0.5
test: ^1.31.1
test: ^1.31.2
@@ -130,6 +130,32 @@ void main() {
).called(1);
});
test('throws StripeApiException carrying status and code on 404', () {
when(
() => httpClient.get(uri, headers: any(named: 'headers')),
).thenAnswer(
(_) async => http.Response(
jsonEncode({
'error': {
'type': 'invalid_request_error',
'code': 'resource_missing',
'message': "No such customer: 'cus_123'",
},
}),
HttpStatus.notFound,
),
);
expect(
() => stripeApi.fetchCustomer(customerId: 'cus_123'),
throwsA(
isA<StripeApiException>()
.having((e) => e.statusCode, 'statusCode', HttpStatus.notFound)
.having((e) => e.code, 'code', 'resource_missing'),
),
);
});
test('returns a customer on successful request', () async {
when(
() => httpClient.get(uri, headers: any(named: 'headers')),
@@ -171,6 +197,36 @@ void main() {
).called(1);
});
test('throws StripeApiException carrying status 429 on rate limit', () {
when(
() => httpClient.get(uri, headers: any(named: 'headers')),
).thenAnswer(
(_) async => http.Response(
jsonEncode({
'error': {
'type': 'api_error',
'code': 'rate_limit',
'message': 'Too many requests',
},
}),
HttpStatus.tooManyRequests,
),
);
expect(
() => stripeApi.fetchSubscription(subscriptionId: subscriptionId),
throwsA(
isA<StripeApiException>()
.having(
(e) => e.statusCode,
'statusCode',
HttpStatus.tooManyRequests,
)
.having((e) => e.code, 'code', 'rate_limit'),
),
);
});
test('returns a subscription on successful request', () async {
when(
() => httpClient.get(uri, headers: any(named: 'headers')),
@@ -409,4 +465,58 @@ void main() {
});
});
});
group(StripeApiException, () {
test('fromResponse parses the Stripe error code from a JSON body', () {
final exception = StripeApiException.fromResponse(
http.Response(
jsonEncode({
'error': {
'code': 'resource_missing',
'message': 'No such customer',
},
}),
HttpStatus.notFound,
),
message: 'fallback',
);
expect(exception.statusCode, HttpStatus.notFound);
expect(exception.code, 'resource_missing');
expect(exception.message, 'fallback');
});
test('fromResponse leaves code null on a non-JSON body', () {
final exception = StripeApiException.fromResponse(
http.Response('<html>502 Bad Gateway</html>', HttpStatus.badGateway),
message: 'fallback',
);
expect(exception.statusCode, HttpStatus.badGateway);
expect(exception.code, isNull);
expect(exception.message, 'fallback');
});
test('fromResponse leaves code null when the body has no error object', () {
final exception = StripeApiException.fromResponse(
http.Response(jsonEncode({'ok': true}), HttpStatus.badRequest),
message: 'fallback',
);
expect(exception.code, isNull);
});
test('toString includes the status code and error code', () {
final exception = StripeApiException(
statusCode: HttpStatus.notFound,
message: 'No such customer',
code: 'resource_missing',
);
expect(
exception.toString(),
'StripeApiException(404, resource_missing): No such customer',
);
});
});
}
+35 -35
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d718c5c58904f9937290fd5dbf2d6a0e02456867706bfb6cd7b81d394e738d5"
sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725"
url: "https://pub.dev"
source: hosted
version: "98.0.0"
version: "103.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "6141ad5d092d1e1d13929c0504658bbeccc1703505830d7c26e859908f5efc88"
sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e"
url: "https://pub.dev"
source: hosted
version: "12.0.0"
version: "13.3.0"
archive:
dependency: transitive
description:
@@ -61,10 +61,10 @@ packages:
dependency: transitive
description:
name: build
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.5"
version: "4.0.6"
build_config:
dependency: transitive
description:
@@ -197,10 +197,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60"
url: "https://pub.dev"
source: hosted
version: "3.1.8"
version: "3.1.9"
decimal:
dependency: transitive
description:
@@ -213,10 +213,10 @@ packages:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
version: "2.1.0"
ffi:
dependency: transitive
description:
@@ -277,10 +277,10 @@ packages:
dependency: transitive
description:
name: googleapis_auth
sha256: "2a8895c3885197f96bb2fd91ee0ae77b53ff3874c7b1f1eadb6566248e880958"
sha256: "1417d8846663df5e7b77ca56591c5edd442c66ffc9c01ab036e138a21a148e86"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.3.2"
graphs:
dependency: transitive
description:
@@ -333,10 +333,10 @@ packages:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
version: "0.20.3"
io:
dependency: transitive
description:
@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.2"
version: "1.18.3"
mime:
dependency: transitive
description:
@@ -517,10 +517,10 @@ packages:
dependency: transitive
description:
name: propertylistserialization
sha256: "645aaac37e73735286087fc81b27c7eabaa81b6146881bdf84090ba56f4e15ad"
sha256: "8ebe5e73b912c080916895bff96af9dd026cf87c1867447894f84e4c9e51b4b8"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.5.0"
pub_semver:
dependency: transitive
description:
@@ -637,18 +637,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.2"
version: "4.2.3"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f"
sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae"
url: "https://pub.dev"
source: hosted
version: "1.3.11"
version: "1.3.12"
source_map_stack_trace:
dependency: transitive
description:
@@ -677,10 +677,10 @@ packages:
dependency: transitive
description:
name: space_gen
sha256: "3aea8da8ffba5802110322093ef4e921e297d9b55ffa073a74b6cb38fd225ee1"
sha256: fb609d3e1d992e8da943990d0bae08bb0b5a05a20c95b66feee1f2a30c0ad60b
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.0"
stack_trace:
dependency: transitive
description:
@@ -725,26 +725,26 @@ packages:
dependency: transitive
description:
name: test
sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
url: "https://pub.dev"
source: hosted
version: "1.31.1"
version: "1.31.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.13"
test_core:
dependency: transitive
description:
name: test_core
sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
url: "https://pub.dev"
source: hosted
version: "0.6.18"
version: "0.6.19"
ttl_cache:
dependency: transitive
description:
@@ -781,10 +781,10 @@ packages:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: d1cb1d66a5aae2c702d68caca6c8347306d35e728fd94555fa21fa0448a972e0
sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
version: "10.3.0"
vm_service:
dependency: transitive
description:
@@ -845,10 +845,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
yaml:
dependency: transitive
description:
@@ -866,4 +866,4 @@ packages:
source: hosted
version: "2.2.4"
sdks:
dart: ">=3.11.0 <4.0.0"
dart: ">=3.12.0 <4.0.0"
+1 -1
View File
@@ -19,4 +19,4 @@ workspace:
- packages/stripe_api
dev_dependencies:
very_good_analysis: ^10.2.0
very_good_analysis: ^10.3.0