From bd5481314280e5a5ef4929fb37a123f8b650da3c Mon Sep 17 00:00:00 2001 From: Ethan Lee <125412902+ethan-tbd@users.noreply.github.com> Date: Thu, 6 Jun 2024 18:02:27 -0700 Subject: [PATCH] feat: add custom exceptions (#38) --- lib/src/http_client.dart | 3 + .../exceptions/http_exceptions.dart | 41 +++ .../exceptions/token_exceptions.dart | 83 ++++++ .../exceptions/validation_exceptions.dart | 56 ++++ lib/src/http_client/tbdex_http_client.dart | 275 +++++++++++++----- test/http_client/tbdex_http_client_test.dart | 19 +- 6 files changed, 388 insertions(+), 89 deletions(-) create mode 100644 lib/src/http_client/exceptions/http_exceptions.dart create mode 100644 lib/src/http_client/exceptions/token_exceptions.dart create mode 100644 lib/src/http_client/exceptions/validation_exceptions.dart diff --git a/lib/src/http_client.dart b/lib/src/http_client.dart index 9dea7e5..c0c5026 100644 --- a/lib/src/http_client.dart +++ b/lib/src/http_client.dart @@ -1,3 +1,6 @@ +export './http_client/exceptions/http_exceptions.dart'; +export './http_client/exceptions/token_exceptions.dart'; +export './http_client/exceptions/validation_exceptions.dart'; export './http_client/models/create_exchange_request.dart'; export './http_client/models/exchange.dart'; export './http_client/models/get_offerings_filter.dart'; diff --git a/lib/src/http_client/exceptions/http_exceptions.dart b/lib/src/http_client/exceptions/http_exceptions.dart new file mode 100644 index 0000000..28b0cf5 --- /dev/null +++ b/lib/src/http_client/exceptions/http_exceptions.dart @@ -0,0 +1,41 @@ +class RequestError implements Exception { + final String message; + final String? url; + final Exception? cause; + + RequestError({ + required this.message, + this.url, + this.cause, + }); + + String get errorType => 'RequestError'; + + @override + String toString() => [ + '$errorType: $message', + if (url != null) 'Url: $url', + if (cause != null) 'Caused by: $cause', + ].join('\n'); +} + +class ResponseError implements Exception { + final String message; + final int? status; + final String? body; + + ResponseError({ + required this.message, + this.status, + this.body, + }); + + String get errorType => 'ResponseError'; + + @override + String toString() => [ + '$errorType: $message', + if (status != null) 'Status: $status', + if (body != null) 'Body: $body', + ].join('\n'); +} diff --git a/lib/src/http_client/exceptions/token_exceptions.dart b/lib/src/http_client/exceptions/token_exceptions.dart new file mode 100644 index 0000000..f5b0ad0 --- /dev/null +++ b/lib/src/http_client/exceptions/token_exceptions.dart @@ -0,0 +1,83 @@ +// TODO(ethan-tbd): move to web5-dart when kcc is added to web5-dart +class _TbdexTokenError implements Exception { + final String message; + final Exception? cause; + + _TbdexTokenError({ + required this.message, + this.cause, + }); + + String get errorType => 'TbdexValidationError'; + + @override + String toString() => [ + '$errorType: $message', + if (cause != null) 'Caused by: $cause', + ].join('\n'); +} + +class RequestTokenError extends _TbdexTokenError { + RequestTokenError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'RequestTokenError'; +} + +class RequestTokenSigningError extends RequestTokenError { + RequestTokenSigningError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'RequestTokenSigningError'; +} + +class RequestTokenVerificationError extends RequestTokenError { + RequestTokenVerificationError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'RequestTokenVerificationError'; +} + +class RequestTokenMissingClaimsError extends RequestTokenError { + RequestTokenMissingClaimsError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'RequestTokenMissingClaimsError'; +} + +class RequestTokenAudienceMismatchError extends RequestTokenError { + RequestTokenAudienceMismatchError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'RequestTokenAudienceMismatchError'; +} diff --git a/lib/src/http_client/exceptions/validation_exceptions.dart b/lib/src/http_client/exceptions/validation_exceptions.dart new file mode 100644 index 0000000..53f202d --- /dev/null +++ b/lib/src/http_client/exceptions/validation_exceptions.dart @@ -0,0 +1,56 @@ +class _TbdexValidationError implements Exception { + final String message; + final Exception? cause; + + _TbdexValidationError({ + required this.message, + this.cause, + }); + + String get errorType => 'TbdexValidationError'; + + @override + String toString() => [ + '$errorType: $message', + if (cause != null) 'Caused by: $cause', + ].join('\n'); +} + +class ValidationError extends _TbdexValidationError { + ValidationError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'ValidationError'; +} + +class InvalidDidError extends ValidationError { + InvalidDidError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'InvalidDidError'; +} + +class MissingServiceEndpointError extends ValidationError { + MissingServiceEndpointError({ + required String message, + Exception? cause, + }) : super( + message: message, + cause: cause, + ); + + @override + String get errorType => 'MissingServiceEndpointError'; +} diff --git a/lib/src/http_client/tbdex_http_client.dart b/lib/src/http_client/tbdex_http_client.dart index abadc2f..a9d94df 100644 --- a/lib/src/http_client/tbdex_http_client.dart +++ b/lib/src/http_client/tbdex_http_client.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; - +import 'package:tbdex/src/http_client/exceptions/http_exceptions.dart'; +import 'package:tbdex/src/http_client/exceptions/token_exceptions.dart'; +import 'package:tbdex/src/http_client/exceptions/validation_exceptions.dart'; import 'package:tbdex/src/http_client/models/create_exchange_request.dart'; import 'package:tbdex/src/http_client/models/exchange.dart'; import 'package:tbdex/src/http_client/models/get_offerings_filter.dart'; @@ -28,54 +30,92 @@ class TbdexHttpClient { _client = client; } - static Future> getExchange( + static Future getExchange( BearerDid did, String pfiDid, String exchangeId, ) async { final requestToken = await _generateRequestToken(did, pfiDid); + final headers = {'Authorization': 'Bearer $requestToken'}; + final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid); final url = Uri.parse('$pfiServiceEndpoint/exchanges/$exchangeId'); - final response = await _client.get( - url, - headers: { - 'Authorization': 'Bearer $requestToken', - }, - ); + http.Response response; + try { + response = await _client.get(url, headers: headers); + + if (response.statusCode != 200) { + throw ResponseError( + message: 'failed to get exchange', + status: response.statusCode, + body: response.body, + ); + } + } on Exception catch (e) { + throw RequestError( + message: 'failed to send get exchange request', + url: url.toString(), + cause: e, + ); + } - return response.statusCode >= 400 - ? TbdexResponse(statusCode: response.statusCode) - : TbdexResponse( - data: Parser.parseExchange(response.body), - statusCode: response.statusCode, - ); + Exchange exchange; + try { + exchange = Parser.parseExchange(response.body); + } on Exception catch (e) { + throw ValidationError( + message: 'failed to parse exchange', + cause: e, + ); + } + + return exchange; } - static Future?>> listExchanges( + static Future?> listExchanges( BearerDid did, String pfiDid, ) async { final requestToken = await _generateRequestToken(did, pfiDid); + final headers = {'Authorization': 'Bearer $requestToken'}; + final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid); final url = Uri.parse('$pfiServiceEndpoint/exchanges/'); - final response = await _client.get( - url, - headers: { - 'Authorization': 'Bearer $requestToken', - }, - ); + http.Response response; + try { + response = await _client.get(url, headers: headers); + + if (response.statusCode != 200) { + throw ResponseError( + message: 'failed to list exchange', + status: response.statusCode, + body: response.body, + ); + } + } on Exception catch (e) { + throw RequestError( + message: 'failed to send list exchange request', + url: url.toString(), + cause: e, + ); + } + + List exchanges; + try { + exchanges = Parser.parseExchanges(response.body); + } on Exception catch (e) { + throw ValidationError( + message: 'failed to parse exchange ids', + cause: e, + ); + } - return response.statusCode >= 400 - ? TbdexResponse(statusCode: response.statusCode) - : TbdexResponse( - data: Parser.parseExchanges(response.body), - statusCode: response.statusCode, - ); + return exchanges; } - static Future?>> listOfferings( + static Future?> listOfferings( String pfiDid, { GetOfferingsFilter? filter, }) async { @@ -84,81 +124,155 @@ class TbdexHttpClient { queryParameters: filter?.toJson(), ); - final response = await _client.get(url); + http.Response response; + try { + response = await _client.get(url); + + if (response.statusCode != 200) { + throw ResponseError( + message: 'failed to list offerings', + status: response.statusCode, + body: response.body, + ); + } + } on Exception catch (e) { + throw RequestError( + message: 'failed to send list offerings request', + url: url.toString(), + cause: e, + ); + } + + List offerings; + try { + offerings = Parser.parseOfferings(response.body); + } on Exception catch (e) { + throw ValidationError( + message: 'failed to parse offerings', + cause: e, + ); + } - return response.statusCode >= 400 - ? TbdexResponse(statusCode: response.statusCode) - : TbdexResponse( - data: Parser.parseOfferings(response.body), - statusCode: response.statusCode, - ); + return offerings; } - static Future> createExchange( + static Future createExchange( Rfq rfq, { String? replyTo, }) async { - Validator.validateMessage(rfq); + try { + Validator.validateMessage(rfq); + } on Exception catch (e) { + throw ValidationError(message: 'invalid rfq message', cause: e); + } + final pfiDid = rfq.metadata.to; final body = jsonEncode(CreateExchangeRequest(rfq: rfq, replyTo: replyTo)); - return _submitMessage(pfiDid, body); + await _submitMessage(pfiDid, body); } - static Future> submitOrder(Order order) async { - Validator.validateMessage(order); + static Future submitOrder(Order order) async { + try { + Validator.validateMessage(order); + } on Exception catch (e) { + throw ValidationError(message: 'invalid order message', cause: e); + } + final pfiDid = order.metadata.to; final exchangeId = order.metadata.exchangeId; final body = jsonEncode(SubmitOrderRequest(order: order)); - return _submitMessage(pfiDid, body, exchangeId: exchangeId); + await _submitMessage(pfiDid, body, exchangeId: exchangeId); } - static Future> submitClose(Close close) async { - Validator.validateMessage(close); + static Future submitClose(Close close) async { + try { + Validator.validateMessage(close); + } on Exception catch (e) { + throw ValidationError(message: 'invalid close message', cause: e); + } + final pfiDid = close.metadata.to; final exchangeId = close.metadata.exchangeId; final body = jsonEncode(SubmitCloseRequest(close: close)); - return _submitMessage(pfiDid, body, exchangeId: exchangeId); + await _submitMessage(pfiDid, body, exchangeId: exchangeId); } - static Future> _submitMessage( + static Future _submitMessage( String pfiDid, String requestBody, { String? exchangeId, }) async { + final headers = {'Content-Type': _jsonHeader}; + final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid); final path = '/exchanges${exchangeId != null ? '/$exchangeId' : ''}'; final url = Uri.parse(pfiServiceEndpoint + path); - final headers = {'Content-Type': _jsonHeader}; - - final response = await (exchangeId == null - ? _client.post(url, headers: headers, body: requestBody) - : _client.put(url, headers: headers, body: requestBody)); - return TbdexResponse(statusCode: response.statusCode); + http.Response response; + try { + response = await (exchangeId == null + ? _client.post(url, headers: headers, body: requestBody) + : _client.put(url, headers: headers, body: requestBody)); + + if (response.statusCode != 202) { + throw ResponseError( + message: exchangeId != null + ? 'failed to create exchange' + : 'failed to submit message', + status: response.statusCode, + body: response.body, + ); + } + } on Exception catch (e) { + throw RequestError( + message: exchangeId != null + ? 'failed to send create exchange request' + : 'failed to send submit message request', + url: url.toString(), + cause: e, + ); + } } static Future _getPfiServiceEndpoint(String pfiDid) async { - final didResolutionResult = - await DidResolver.resolve(pfiDid, options: _client); - - if (didResolutionResult.didDocument == null) { - throw Exception('did resolution failed'); + DidResolutionResult didResolutionResult; + try { + didResolutionResult = await DidResolver.resolve(pfiDid, options: _client); + + if (didResolutionResult.didDocument == null) { + throw Exception(didResolutionResult.didResolutionMetadata.error); + } + } on Exception catch (e) { + throw InvalidDidError( + message: 'pfi did resolution failed', + cause: e, + ); } - final service = didResolutionResult.didDocument?.service?.firstWhere( - (service) => service.type == 'PFI', - orElse: () => throw Exception('did does not have service of type PFI'), - ); - - final endpoint = service?.serviceEndpoint ?? []; - - if (endpoint.isEmpty) { - throw Exception('no service endpoints found'); + List pfiServiceEndpoints; + try { + final service = didResolutionResult.didDocument?.service?.firstWhere( + (service) => service.type == 'PFI', + orElse: () => + throw Exception('pfi did does not have service of type PFI'), + ); + + pfiServiceEndpoints = service?.serviceEndpoint ?? []; + + if (pfiServiceEndpoints.isEmpty) { + throw Exception('no PFI service endpoints found'); + } + } on Exception catch (e) { + throw MissingServiceEndpointError( + message: 'pfi service endpoint missing', + cause: e, + ); } - return endpoint[0]; + + return pfiServiceEndpoints.first; } static Future _generateRequestToken( @@ -168,16 +282,25 @@ class TbdexHttpClient { final nowEpochSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; final exp = nowEpochSeconds + _expirationDuration.inSeconds; - return Jwt.sign( - did: did, - payload: JwtClaims( - aud: pfiDid, - iss: did.uri, - exp: exp, - iat: nowEpochSeconds, - jti: TypeId.generate(''), - ), - ); + String requestToken; + try { + requestToken = await Jwt.sign( + did: did, + payload: JwtClaims( + aud: pfiDid, + iss: did.uri, + exp: exp, + iat: nowEpochSeconds, + jti: TypeId.generate(''), + ), + ); + } on Exception catch (e) { + throw RequestTokenError( + message: 'failed to sign request token', + cause: e, + ); + } + return requestToken; } } diff --git a/test/http_client/tbdex_http_client_test.dart b/test/http_client/tbdex_http_client_test.dart index 520c8f4..5bb8dc5 100644 --- a/test/http_client/tbdex_http_client_test.dart +++ b/test/http_client/tbdex_http_client_test.dart @@ -41,8 +41,7 @@ void main() async { final response = await TbdexHttpClient.getExchange(TestData.aliceDid, pfiDid, '1234'); - expect(response.data?.length, 2); - expect(response.statusCode, 200); + expect(response?.length, 2); verify( () => mockHttpClient.get( @@ -64,8 +63,7 @@ void main() async { final response = await TbdexHttpClient.listExchanges(TestData.aliceDid, pfiDid); - expect(response.data?.length, 3); - expect(response.statusCode, 200); + expect(response?.length, 3); verify( () => mockHttpClient.get( @@ -83,8 +81,7 @@ void main() async { ); final response = await TbdexHttpClient.listOfferings(pfiDid); - expect(response.data?.length, 1); - expect(response.statusCode, 200); + expect(response?.length, 1); verify( () => mockHttpClient.get(Uri.parse('$pfiServiceEndpoint/offerings/')), @@ -105,9 +102,7 @@ void main() async { (_) async => http.Response('', 202), ); - final response = - await TbdexHttpClient.createExchange(rfq, replyTo: 'reply_to'); - expect(response.statusCode, 202); + await TbdexHttpClient.createExchange(rfq, replyTo: 'reply_to'); verify( () => mockHttpClient.post( @@ -133,8 +128,7 @@ void main() async { (_) async => http.Response('', 202), ); - final response = await TbdexHttpClient.submitOrder(order); - expect(response.statusCode, 202); + await TbdexHttpClient.submitOrder(order); verify( () => mockHttpClient.put( @@ -160,8 +154,7 @@ void main() async { (_) async => http.Response('', 202), ); - final response = await TbdexHttpClient.submitClose(close); - expect(response.statusCode, 202); + await TbdexHttpClient.submitClose(close); verify( () => mockHttpClient.put(