diff --git a/packages/shorebird_code_push_client/lib/src/code_push_client.dart b/packages/shorebird_code_push_client/lib/src/code_push_client.dart index 58484feb..a6abd26a 100644 --- a/packages/shorebird_code_push_client/lib/src/code_push_client.dart +++ b/packages/shorebird_code_push_client/lib/src/code_push_client.dart @@ -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,20 +92,36 @@ class CodePushClient { http.Client? httpClient, Uri? hostedUri, Map? customHeaders, + @visibleForTesting + Duration uploadRetryBaseDelay = const Duration(seconds: 1), }) : _httpClient = _CodePushHttpClient(httpClient ?? http.Client(), { ...standardHeaders, ...?customHeaders, }), + _uploadRetryBaseDelay = uploadRetryBaseDelay, hostedUri = hostedUri ?? Uri.https('api.shorebird.dev'); /// The standard headers applied to all requests. + @visibleForTesting static const standardHeaders = {'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; @@ -160,17 +177,12 @@ class CodePushClient { json.decode(body) as Map, ); - 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]. @@ -219,17 +231,152 @@ class CodePushClient { json.decode(body) as Map, ); - 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 _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 _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(); + + 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 _backoff(int failures) => + Future.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 _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(); + 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]. diff --git a/packages/shorebird_code_push_client/pubspec.yaml b/packages/shorebird_code_push_client/pubspec.yaml index e344bea8..571647ae 100644 --- a/packages/shorebird_code_push_client/pubspec.yaml +++ b/packages/shorebird_code_push_client/pubspec.yaml @@ -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 diff --git a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart index fec38a67..9813edef 100644 --- a/packages/shorebird_code_push_client/test/src/code_push_client_test.dart +++ b/packages/shorebird_code_push_client/test/src/code_push_client_test.dart @@ -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), @@ -456,6 +458,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(); + 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', () { @@ -803,6 +861,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(); + 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 Function()> actions, + ) { + when( + () => httpClient.send(any()), + ).thenAnswer((_) => actions.removeAt(0)()); + } + + Future upload(String artifactPath) => + codePushClient.createReleaseArtifact( + appId: appId, + artifactPath: artifactPath, + releaseId: releaseId, + arch: arch, + platform: platform, + hash: hash, + canSideload: canSideload, + podfileLockHash: null, + ); + + List capturedPuts() => + verify( + () => httpClient.send(captureAny()), + ).captured + .cast() + .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().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()), + ); + }); + + 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()), + ); + }); + + 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()), + ); + }); + + 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 = 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()), + ); + }); + }); }); group('createApp', () { diff --git a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart index 22e2aa71..de10e753 100644 --- a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart +++ b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart @@ -48,6 +48,7 @@ export 'package:shorebird_code_push_protocol/src/models/active_hour_entry.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'; diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart index ba8e102c..3da75b25 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/create_patch_artifact/create_patch_artifact_response.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` 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`. Map 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; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart index bd37eee5..99a3e18e 100644 --- a/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.dart +++ b/packages/shorebird_code_push_protocol/lib/src/messages/create_release_artifact/create_release_artifact_response.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_release_artifact_response} @@ -16,6 +17,7 @@ class CreateReleaseArtifactResponse { required this.hash, required this.size, required this.url, + this.uploadMethod, }); /// Converts a `Map` 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`. Map 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; } } diff --git a/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart b/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart new file mode 100644 index 00000000..9ccca14c --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/artifact_upload_method.dart @@ -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; +} diff --git a/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart index 999d478f..ab572f71 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/create_patch_artifact/create_patch_artifact_response_test.dart @@ -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)); + }); }); } diff --git a/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart b/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart index 6b4c5d24..f214466d 100644 --- a/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart +++ b/packages/shorebird_code_push_protocol/test/src/messages/create_release_artifact/create_release_artifact_response_test.dart @@ -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)); + }); }); } diff --git a/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart b/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart new file mode 100644 index 00000000..429f8a70 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/src/models/artifact_upload_method_test.dart @@ -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'); + }); + }); +}