feat(shorebird_cli): improve error reporting (#102)

This commit is contained in:
Felix Angelov
2023-03-21 15:08:18 -05:00
committed by GitHub
parent e36ae596a3
commit 795d3a74ec
13 changed files with 302 additions and 31 deletions
@@ -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()),
);
});
});
}