diff --git a/packages/shorebird_cli/lib/src/commands/apps/create_apps_command.dart b/packages/shorebird_cli/lib/src/commands/apps/create_apps_command.dart index aca2a236..d577e658 100644 --- a/packages/shorebird_cli/lib/src/commands/apps/create_apps_command.dart +++ b/packages/shorebird_cli/lib/src/commands/apps/create_apps_command.dart @@ -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; } diff --git a/packages/shorebird_cli/lib/src/commands/apps/delete_apps_command.dart b/packages/shorebird_cli/lib/src/commands/apps/delete_apps_command.dart index e3c81bfc..714a9c0c 100644 --- a/packages/shorebird_cli/lib/src/commands/apps/delete_apps_command.dart +++ b/packages/shorebird_cli/lib/src/commands/apps/delete_apps_command.dart @@ -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; } diff --git a/packages/shorebird_cli/lib/src/commands/apps/list_apps_command.dart b/packages/shorebird_cli/lib/src/commands/apps/list_apps_command.dart index 9dba9f47..7800c93a 100644 --- a/packages/shorebird_cli/lib/src/commands/apps/list_apps_command.dart +++ b/packages/shorebird_cli/lib/src/commands/apps/list_apps_command.dart @@ -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; } diff --git a/packages/shorebird_cli/lib/src/commands/publish_command.dart b/packages/shorebird_cli/lib/src/commands/publish_command.dart index 1fe0a757..b5a5705d 100644 --- a/packages/shorebird_cli/lib/src/commands/publish_command.dart +++ b/packages/shorebird_cli/lib/src/commands/publish_command.dart @@ -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; } diff --git a/packages/shorebird_cli/test/src/commands/apps/create_apps_command_test.dart b/packages/shorebird_cli/test/src/commands/apps/create_apps_command_test.dart index 315ae709..d6b13a35 100644 --- a/packages/shorebird_cli/test/src/commands/apps/create_apps_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/apps/create_apps_command_test.dart @@ -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); }); }); } diff --git a/packages/shorebird_cli/test/src/commands/apps/delete_apps_command_test.dart b/packages/shorebird_cli/test/src/commands/apps/delete_apps_command_test.dart index 24165887..88eb9249 100644 --- a/packages/shorebird_cli/test/src/commands/apps/delete_apps_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/apps/delete_apps_command_test.dart @@ -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); }); }); } diff --git a/packages/shorebird_cli/test/src/commands/publish_command_test.dart b/packages/shorebird_cli/test/src/commands/publish_command_test.dart index adae89f6..7e98e5ea 100644 --- a/packages/shorebird_cli/test/src/commands/publish_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/publish_command_test.dart @@ -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); }); 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 fabf6627..71e22141 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 @@ -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( + [], + (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; + error = ErrorResponse.fromJson(body); + } catch (_) { + throw const CodePushException(message: unknownErrorMessage); + } + return CodePushException(message: error.message, details: error.details); + } } 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 6ab2091a..7e4b59a6 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 @@ -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()), + throwsA( + isA().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().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()), + throwsA( + isA().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().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()), + throwsA( + isA().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().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().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()), + throwsA( + isA().having( + (e) => e.message, + 'message', + errorResponse.message, + ), + ), ); }); @@ -268,3 +419,13 @@ void main() { }); }); } + +extension on ErrorResponse { + Map toJson() { + return { + 'code': code, + 'message': message, + 'details': details, + }; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/error_response.dart b/packages/shorebird_code_push_protocol/lib/src/models/error_response.dart new file mode 100644 index 00000000..20046384 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/error_response.dart @@ -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 json) => + _$ErrorResponseFromJson(json); + + /// Converts a [ErrorResponse] to [Map]. + Map 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; +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/error_response.g.dart b/packages/shorebird_code_push_protocol/lib/src/models/error_response.g.dart new file mode 100644 index 00000000..1bb616b3 --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/error_response.g.dart @@ -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 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 _$ErrorResponseToJson(ErrorResponse instance) => + { + 'code': instance.code, + 'message': instance.message, + 'details': instance.details, + }; diff --git a/packages/shorebird_code_push_protocol/lib/src/models/models.dart b/packages/shorebird_code_push_protocol/lib/src/models/models.dart index 06b55763..be672ab6 100644 --- a/packages/shorebird_code_push_protocol/lib/src/models/models.dart +++ b/packages/shorebird_code_push_protocol/lib/src/models/models.dart @@ -1,3 +1,4 @@ export 'app.dart'; +export 'error_response.dart'; export 'patch.dart'; export 'user.dart'; diff --git a/packages/shorebird_code_push_protocol/test/src/models/error_response_test.dart b/packages/shorebird_code_push_protocol/test/src/models/error_response_test.dart new file mode 100644 index 00000000..dc42887c --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/src/models/error_response_test.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()), + ); + }); + }); +}