Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 34 additions & 6 deletions lib/src/api/pushfire_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,26 @@ class PushFireApiClient {
String? errorCode;

try {
final decoded = json.decode(body) as Map<String, dynamic>;
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<String, dynamic>) {
// 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';
}

Expand All @@ -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>()
.map((e) => e['message'])
.whereType<String>()
.where((m) => m.isNotEmpty)
.toList();
return messages.isEmpty ? null : messages.join('; ');
}

/// Dispose the HTTP client
void dispose() {
_httpClient.close();
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
65 changes: 60 additions & 5 deletions test/api/pushfire_api_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html>502</html>', 502));
test('non-JSON error body is surfaced raw', () async {
final mock = MockClient(
(req) async => http.Response('<html>502 Bad Gateway</html>', 502));
final client = PushFireApiClient(config, httpClient: mock);
await expectLater(
client.post('x', {}),
throwsA(isA<PushFireApiException>()
.having((e) => e.statusCode, 'statusCode', 502)
.having((e) => e.message, 'message', contains('502'))
.having((e) => e.message, 'message', '<html>502 Bad Gateway</html>')
.having((e) => e.responseBody, 'responseBody',
contains('<html>502</html>'))),
contains('<html>502 Bad Gateway</html>'))),
);
});

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<PushFireApiException>()
.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<PushFireApiException>().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<PushFireApiException>()
.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<PushFireApiException>()
.having((e) => e.statusCode, 'statusCode', 500)
.having((e) => e.message, 'message',
'API request failed with status 500')),
);
});
});
Expand Down
Loading