diff --git a/CHANGELOG.md b/CHANGELOG.md index b08363f..eab9559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [0.2.1] + +### Fixed +- **API error messages now surface the server's actual error** instead of a generic "API request failed". The parser additionally reads validation-style `{"errors":[{"message":...}]}` responses (joining multiple messages), and when no recognized field is present it falls back to the raw response body. Non-JSON error bodies (e.g. HTML gateway pages) are shown as-is; only a truly empty body falls back to "API request failed with status <n>". (The full body was already available via `PushFireApiException.responseBody`.) + ## [0.2.0] ### Changed diff --git a/lib/src/api/pushfire_api_client.dart b/lib/src/api/pushfire_api_client.dart index 55330fe..6dc78cd 100644 --- a/lib/src/api/pushfire_api_client.dart +++ b/lib/src/api/pushfire_api_client.dart @@ -147,12 +147,26 @@ class PushFireApiClient { String? errorCode; try { - final decoded = json.decode(body) as Map; - errorMessage = decoded['message'] as String? ?? - decoded['error'] as String? ?? - 'API request failed'; - errorCode = decoded['code'] as String?; - } catch (e) { + final decoded = json.decode(body); + if (decoded is Map) { + // Try the known shapes, then fall back to the whole body so the caller + // always sees the server's actual response, not a generic string. + errorMessage = decoded['message'] as String? ?? + decoded['error'] as String? ?? + _extractErrorsList(decoded['errors']) ?? + body.trim(); + errorCode = decoded['code'] as String?; + } else { + // Valid JSON but not an object (array, string, number) — show it raw. + errorMessage = body.trim(); + } + } catch (_) { + // Body is not JSON (e.g. an HTML gateway page) — show it raw. + errorMessage = body.trim(); + } + + // Last resort when the response body is empty/blank. + if (errorMessage.isEmpty) { errorMessage = 'API request failed with status $statusCode'; } @@ -167,6 +181,20 @@ class PushFireApiClient { ); } + /// Extract and join messages from a validation-style `errors` array, e.g. + /// `{"errors":[{"path":"data.phone","message":"Phone is required"}]}`. + /// Returns null when [errors] is not a non-empty list carrying messages. + static String? _extractErrorsList(dynamic errors) { + if (errors is! List || errors.isEmpty) return null; + final messages = errors + .whereType() + .map((e) => e['message']) + .whereType() + .where((m) => m.isNotEmpty) + .toList(); + return messages.isEmpty ? null : messages.join('; '); + } + /// Dispose the HTTP client void dispose() { _httpClient.close(); diff --git a/pubspec.yaml b/pubspec.yaml index 619e049..30b8aec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A lightweight push notification tracking SDK for Firebase. homepage: https://github.com/FlywheelStudio/pushfire_sdk/blob/main/README.md repository: https://github.com/FlywheelStudio/pushfire_sdk -version: 0.2.0 +version: 0.2.1 environment: sdk: '>=3.3.1 <4.0.0' diff --git a/test/api/pushfire_api_client_test.dart b/test/api/pushfire_api_client_test.dart index b192816..a5fb7e3 100644 --- a/test/api/pushfire_api_client_test.dart +++ b/test/api/pushfire_api_client_test.dart @@ -76,17 +76,72 @@ void main() { ); }); - test('non-JSON error body falls back to a status message', () async { - final mock = - MockClient((req) async => http.Response('502', 502)); + test('non-JSON error body is surfaced raw', () async { + final mock = MockClient( + (req) async => http.Response('502 Bad Gateway', 502)); final client = PushFireApiClient(config, httpClient: mock); await expectLater( client.post('x', {}), throwsA(isA() .having((e) => e.statusCode, 'statusCode', 502) - .having((e) => e.message, 'message', contains('502')) + .having((e) => e.message, 'message', '502 Bad Gateway') .having((e) => e.responseBody, 'responseBody', - contains('502'))), + contains('502 Bad Gateway'))), + ); + }); + + test('nested errors[] array surfaces the validation message', () async { + final mock = MockClient((req) async => http.Response( + '{"errors":[{"path":"data.phone","message":"Phone is required"}]}', + 400, + )); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.patch('update-subscriber', {'data': {}}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 400) + .having((e) => e.message, 'message', 'Phone is required')), + ); + }); + + test('multiple errors[] messages are joined', () async { + final mock = MockClient((req) async => http.Response( + '{"errors":[{"message":"Phone is required"},' + '{"message":"Email is invalid"}]}', + 400, + )); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA().having((e) => e.message, 'message', + 'Phone is required; Email is invalid')), + ); + }); + + test('unknown JSON shape falls back to the whole body', () async { + final mock = MockClient((req) async => http.Response( + '{"unexpected":"shape","detail":42}', + 422, + )); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 422) + .having((e) => e.message, 'message', + '{"unexpected":"shape","detail":42}')), + ); + }); + + test('empty error body falls back to a status message', () async { + final mock = MockClient((req) async => http.Response('', 500)); + final client = PushFireApiClient(config, httpClient: mock); + await expectLater( + client.post('x', {}), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 500) + .having((e) => e.message, 'message', + 'API request failed with status 500')), ); }); });