feat: resumable artifact uploads with server-enforced size limit (#3832)
This commit is contained in:
@@ -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<String, String>? 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 = <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;
|
||||
|
||||
@@ -160,17 +177,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].
|
||||
@@ -219,17 +231,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
|
||||
|
||||
@@ -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<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', () {
|
||||
@@ -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<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', () {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+17
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -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,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;
|
||||
}
|
||||
+16
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+16
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+28
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user