feat(shorebird_cli): improve error reporting (#102)
This commit is contained in:
@@ -63,7 +63,7 @@ Defaults to the app_id in "shorebird.yaml".''',
|
||||
try {
|
||||
await client.createApp(appId: appId);
|
||||
} catch (error) {
|
||||
logger.err('Unable to create app\n$error');
|
||||
logger.err('$error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Defaults to the app_id in "shorebird.yaml".''',
|
||||
try {
|
||||
await client.deleteApp(appId: appId);
|
||||
} catch (error) {
|
||||
logger.err('Unable to delete app\n$error');
|
||||
logger.err('$error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class ListAppsCommand extends ShorebirdCommand with ShorebirdConfigMixin {
|
||||
try {
|
||||
apps = await client.getApps();
|
||||
} catch (error) {
|
||||
logger.err('Unable to get apps: $error');
|
||||
logger.err('$error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class PublishCommand extends ShorebirdCommand with ShorebirdConfigMixin {
|
||||
channel: 'stable',
|
||||
);
|
||||
} catch (error) {
|
||||
logger.err('Failed to deploy: $error');
|
||||
logger.err('$error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,15 +77,12 @@ void main() {
|
||||
});
|
||||
|
||||
test('returns software error when app creation fails', () async {
|
||||
final error = Exception('oops');
|
||||
when(() => argResults['app-id']).thenReturn(appId);
|
||||
when(
|
||||
() => codePushClient.createApp(appId: appId),
|
||||
).thenThrow(Exception());
|
||||
when(() => codePushClient.createApp(appId: appId)).thenThrow(error);
|
||||
final result = await command.run();
|
||||
expect(result, ExitCode.software.code);
|
||||
verify(
|
||||
() => logger.err(any(that: contains('Unable to create app'))),
|
||||
).called(1);
|
||||
verify(() => logger.err('$error')).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,16 +90,13 @@ void main() {
|
||||
});
|
||||
|
||||
test('returns software error when app deletion fails', () async {
|
||||
final error = Exception('oops');
|
||||
when(() => logger.confirm(any())).thenReturn(true);
|
||||
when(() => argResults['app-id']).thenReturn(appId);
|
||||
when(
|
||||
() => codePushClient.deleteApp(appId: appId),
|
||||
).thenThrow(Exception());
|
||||
when(() => codePushClient.deleteApp(appId: appId)).thenThrow(error);
|
||||
final result = await command.run();
|
||||
expect(result, ExitCode.software.code);
|
||||
verify(
|
||||
() => logger.err(any(that: contains('Unable to delete app'))),
|
||||
).called(1);
|
||||
verify(() => logger.err('$error')).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ flutter:
|
||||
command.run,
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
verify(() => logger.err('Failed to deploy: $error')).called(1);
|
||||
verify(() => logger.err(error)).called(1);
|
||||
expect(exitCode, ExitCode.software.code);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,23 @@ import 'dart:typed_data';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
|
||||
/// {@template code_push_exception}
|
||||
/// Base class for all CodePush exceptions.
|
||||
/// {@endtemplate}
|
||||
class CodePushException implements Exception {
|
||||
/// {@macro code_push_exception}
|
||||
const CodePushException({required this.message, this.details});
|
||||
|
||||
/// The message associated with the exception.
|
||||
final String message;
|
||||
|
||||
/// The details associated with the exception.
|
||||
final String? details;
|
||||
|
||||
@override
|
||||
String toString() => '$message${details != null ? '\n$details' : ''}';
|
||||
}
|
||||
|
||||
/// {@template code_push_client}
|
||||
/// Dart client for the Shorebird CodePush API.
|
||||
/// {@endtemplate}
|
||||
@@ -18,6 +35,9 @@ class CodePushClient {
|
||||
_httpClient = httpClient ?? http.Client(),
|
||||
hostedUri = hostedUri ?? Uri.https('api.shorebird.dev');
|
||||
|
||||
/// The default error message to use when an unknown error occurs.
|
||||
static const unknownErrorMessage = 'An unknown error occurred.';
|
||||
|
||||
final String _apiKey;
|
||||
final http.Client _httpClient;
|
||||
|
||||
@@ -35,7 +55,7 @@ class CodePushClient {
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.created) {
|
||||
throw Exception('${response.statusCode} ${response.reasonPhrase}');
|
||||
throw _parseErrorResponse(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +81,11 @@ class CodePushClient {
|
||||
final response = await _httpClient.send(request);
|
||||
|
||||
if (response.statusCode != HttpStatus.created) {
|
||||
throw Exception('${response.statusCode} ${response.reasonPhrase}');
|
||||
final body = await response.stream.fold(
|
||||
<int>[],
|
||||
(previous, element) => previous..addAll(element),
|
||||
);
|
||||
throw _parseErrorResponse(utf8.decode(body));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +97,7 @@ class CodePushClient {
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.noContent) {
|
||||
throw Exception('${response.statusCode} ${response.reasonPhrase}');
|
||||
throw _parseErrorResponse(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +126,7 @@ class CodePushClient {
|
||||
);
|
||||
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception('${response.statusCode} ${response.reasonPhrase}');
|
||||
throw _parseErrorResponse(response.body);
|
||||
}
|
||||
|
||||
final apps = json.decode(response.body) as List;
|
||||
@@ -113,4 +137,15 @@ class CodePushClient {
|
||||
|
||||
/// Closes the client.
|
||||
void close() => _httpClient.close();
|
||||
|
||||
CodePushException _parseErrorResponse(String response) {
|
||||
final ErrorResponse error;
|
||||
try {
|
||||
final body = json.decode(response) as Map<String, dynamic>;
|
||||
error = ErrorResponse.fromJson(body);
|
||||
} catch (_) {
|
||||
throw const CodePushException(message: unknownErrorMessage);
|
||||
}
|
||||
return CodePushException(message: error.message, details: error.details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ void main() {
|
||||
group('CodePushClient', () {
|
||||
const apiKey = 'api-key';
|
||||
const appId = 'shorebird-example';
|
||||
const errorResponse = ErrorResponse(
|
||||
code: 'test_code',
|
||||
message: 'test message',
|
||||
details: 'test details',
|
||||
);
|
||||
|
||||
late http.Client httpClient;
|
||||
late CodePushClient codePushClient;
|
||||
@@ -37,8 +42,21 @@ void main() {
|
||||
expect(CodePushClient(apiKey: apiKey), isNotNull);
|
||||
});
|
||||
|
||||
group('CodePushException', () {
|
||||
test('toString is correct', () {
|
||||
const exceptionWithDetails = CodePushException(
|
||||
message: 'message',
|
||||
details: 'details',
|
||||
);
|
||||
const exceptionWithoutDetails = CodePushException(message: 'message');
|
||||
|
||||
expect(exceptionWithDetails.toString(), 'message\ndetails');
|
||||
expect(exceptionWithoutDetails.toString(), 'message');
|
||||
});
|
||||
});
|
||||
|
||||
group('createApp', () {
|
||||
test('throws an exception if the http request fails', () async {
|
||||
test('throws an exception if the http request fails (unknown)', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
@@ -49,7 +67,39 @@ void main() {
|
||||
|
||||
expect(
|
||||
codePushClient.createApp(appId: appId),
|
||||
throwsA(isA<Exception>()),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
CodePushClient.unknownErrorMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an exception if the http request fails', () async {
|
||||
when(
|
||||
() => httpClient.post(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
body: any(named: 'body'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
json.encode(errorResponse.toJson()),
|
||||
HttpStatus.failedDependency,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
codePushClient.createApp(appId: appId),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
errorResponse.message,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,7 +130,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('createPatch', () {
|
||||
test('throws an exception if the http request fails', () async {
|
||||
test('throws an exception if the http request fails (unknown)', () async {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
final fixture = File(path.join(tempDir.path, 'release.txt'))
|
||||
..createSync();
|
||||
@@ -88,7 +138,7 @@ void main() {
|
||||
when(() => httpClient.send(any())).thenAnswer((_) async {
|
||||
return http.StreamedResponse(
|
||||
Stream.empty(),
|
||||
400,
|
||||
HttpStatus.failedDependency,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,7 +149,42 @@ void main() {
|
||||
appId: 'shorebird-example',
|
||||
channel: 'stable',
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
CodePushClient.unknownErrorMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an exception if the http request fails', () async {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
final fixture = File(path.join(tempDir.path, 'release.txt'))
|
||||
..createSync();
|
||||
|
||||
when(() => httpClient.send(any())).thenAnswer((_) async {
|
||||
return http.StreamedResponse(
|
||||
Stream.value(utf8.encode(json.encode(errorResponse.toJson()))),
|
||||
HttpStatus.failedDependency,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
codePushClient.createPatch(
|
||||
artifactPath: fixture.path,
|
||||
releaseVersion: '1.0.0',
|
||||
appId: 'shorebird-example',
|
||||
channel: 'stable',
|
||||
),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
errorResponse.message,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -133,14 +218,44 @@ void main() {
|
||||
});
|
||||
|
||||
group('deleteApp', () {
|
||||
test('throws an exception if the http request fails', () async {
|
||||
test('throws an exception if the http request fails (unknown)', () async {
|
||||
when(
|
||||
() => httpClient.delete(any(), headers: any(named: 'headers')),
|
||||
).thenAnswer((_) async => http.Response('', HttpStatus.badRequest));
|
||||
).thenAnswer(
|
||||
(_) async => http.Response('', HttpStatus.failedDependency),
|
||||
);
|
||||
|
||||
expect(
|
||||
codePushClient.deleteApp(appId: appId),
|
||||
throwsA(isA<Exception>()),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
CodePushClient.unknownErrorMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an exception if the http request fails', () async {
|
||||
when(
|
||||
() => httpClient.delete(any(), headers: any(named: 'headers')),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
json.encode(errorResponse.toJson()),
|
||||
HttpStatus.failedDependency,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
codePushClient.deleteApp(appId: appId),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
errorResponse.message,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -212,17 +327,53 @@ void main() {
|
||||
});
|
||||
|
||||
group('getApps', () {
|
||||
test('throws an exception if the http request fails (unknown)', () async {
|
||||
when(
|
||||
() => httpClient.get(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
'',
|
||||
HttpStatus.failedDependency,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
codePushClient.getApps(),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
CodePushClient.unknownErrorMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an exception if the http request fails', () async {
|
||||
when(
|
||||
() => httpClient.get(
|
||||
any(),
|
||||
headers: any(named: 'headers'),
|
||||
),
|
||||
).thenAnswer((_) async => http.Response('', HttpStatus.badRequest));
|
||||
).thenAnswer(
|
||||
(_) async => http.Response(
|
||||
json.encode(errorResponse.toJson()),
|
||||
HttpStatus.failedDependency,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
codePushClient.getApps(),
|
||||
throwsA(isA<Exception>()),
|
||||
throwsA(
|
||||
isA<CodePushException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
errorResponse.message,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -268,3 +419,13 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extension on ErrorResponse {
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'code': code,
|
||||
'message': message,
|
||||
'details': details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'error_response.g.dart';
|
||||
|
||||
/// {@template error_response}
|
||||
/// Standard error response body from the Shorebird Code Push API.
|
||||
/// {@endtemplate}
|
||||
@JsonSerializable()
|
||||
class ErrorResponse {
|
||||
/// {@macro error_response}
|
||||
const ErrorResponse({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
/// Converts a [Map] to [ErrorResponse].
|
||||
factory ErrorResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$ErrorResponseFromJson(json);
|
||||
|
||||
/// Converts a [ErrorResponse] to [Map].
|
||||
Map<String, dynamic> toJson() => _$ErrorResponseToJson(this);
|
||||
|
||||
/// The unique error code.
|
||||
final String code;
|
||||
|
||||
/// Human-readable error message.
|
||||
final String message;
|
||||
|
||||
/// Optional details associated with the error.
|
||||
final String? details;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars
|
||||
|
||||
part of 'error_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ErrorResponse _$ErrorResponseFromJson(Map<String, dynamic> json) =>
|
||||
$checkedCreate(
|
||||
'ErrorResponse',
|
||||
json,
|
||||
($checkedConvert) {
|
||||
final val = ErrorResponse(
|
||||
code: $checkedConvert('code', (v) => v as String),
|
||||
message: $checkedConvert('message', (v) => v as String),
|
||||
details: $checkedConvert('details', (v) => v as String?),
|
||||
);
|
||||
return val;
|
||||
},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ErrorResponseToJson(ErrorResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'code': instance.code,
|
||||
'message': instance.message,
|
||||
'details': instance.details,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'app.dart';
|
||||
export 'error_response.dart';
|
||||
export 'patch.dart';
|
||||
export 'user.dart';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('ErrorResponse', () {
|
||||
test('can be (de)serialized', () {
|
||||
const response = ErrorResponse(
|
||||
code: 'code',
|
||||
message: 'message',
|
||||
details: 'details',
|
||||
);
|
||||
expect(
|
||||
ErrorResponse.fromJson(response.toJson()).toJson(),
|
||||
equals(response.toJson()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user