Skip to content

Commit

Permalink
[feat] improvements for rest client (#385)
Browse files Browse the repository at this point in the history
* [feat] improvements for rest client

* style

* format
  • Loading branch information
hawkkiller authored Nov 4, 2024
1 parent 13dd1e9 commit 20ef50d
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 52 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import 'package:meta/meta.dart';
import 'package:sizzle_starter/src/core/rest_client/rest_client.dart';

// coverage:ignore-start
/// {@template rest_client_exception}
/// Base class for all rest client exceptions
/// Base class for all [RestClient] exceptions
/// {@endtemplate}
@immutable
abstract base class RestClientException implements Exception {
sealed class RestClientException implements Exception {
/// {@macro network_exception}
const RestClientException({
required this.message,
Expand Down Expand Up @@ -64,9 +63,10 @@ final class ClientException extends RestClientException {
///
/// This class exists to make handling of structured errors easier.
/// Basically, in data providers that use [RestClientBase], you can catch
/// this exception and convert it to a system-wide error. For example,
/// if backend returns an error with code 123 that means that the action
/// is not allowed, you can convert this exception to a NotAllowedException
/// this exception and convert it to a system-wide error.
///
/// For example, if backend returns an error with code "not_allowed" that means that the action
/// is not allowed and you can convert this exception to a NotAllowedException
/// and rethrow. This way, the rest of the application does not need to know
/// about the structure of the error and should only handle system-wide
/// exceptions.
Expand Down Expand Up @@ -142,4 +142,3 @@ final class InternalServerException extends RestClientException {
'cause: $cause'
')';
}
// coverage:ignore-end
6 changes: 2 additions & 4 deletions lib/src/core/rest_client/src/http/rest_client_http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,10 @@ final class RestClientHttp extends RestClientBase {
request.headers.addAll(headers);
}

final response = await _client.send(request).then(
http.Response.fromStream,
);
final response = await _client.send(request).then(http.Response.fromStream);

final result = await decodeResponse(
response.bodyBytes,
BytesResponseBody(response.bodyBytes),
statusCode: response.statusCode,
);

Expand Down
2 changes: 1 addition & 1 deletion lib/src/core/rest_client/src/rest_client.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// {@template rest_client}
/// A REST client for making HTTP requests.
/// {@endtemplate}
abstract class RestClient {
abstract interface class RestClient {
/// Sends a GET request to the given [path].
Future<Map<String, Object?>?> get(
String path, {
Expand Down
92 changes: 60 additions & 32 deletions lib/src/core/rest_client/src/rest_client_base.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';

import 'package:meta/meta.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:sizzle_starter/src/core/rest_client/rest_client.dart';

Expand Down Expand Up @@ -135,26 +134,16 @@ abstract base class RestClientBase implements RestClient {
@protected
@visibleForTesting
Future<Map<String, Object?>?> decodeResponse(
/* String, Map<String, Object?>, List<int> */
Object? body, {
ResponseBody<Object>? body, {
int? statusCode,
}) async {
if (body == null) return null;

assert(
body is String || body is Map<String, Object?> || body is List<int>,
'Unexpected response body type: ${body.runtimeType}',
);

try {
final decodedBody = switch (body) {
final Map<String, Object?> map => map,
final String str => await _decodeString(str),
final List<int> bytes => await _decodeBytes(bytes),
_ => throw WrongResponseTypeException(
message: 'Unexpected response body type: ${body.runtimeType}',
statusCode: statusCode,
),
MapResponseBody(:final Map<String, Object?> data) => data,
StringResponseBody(:final String data) => await _decodeString(data),
BytesResponseBody(:final List<int> data) => await _decodeBytes(data),
};

if (decodedBody case {'error': final Map<String, Object?> error}) {
Expand All @@ -169,8 +158,6 @@ abstract base class RestClientBase implements RestClient {
}

// Simply return decoded body if it is not an error or data
// This is useful for responses that do not follow the structured response
// But generally, it is recommended to follow the structured response :)
return decodedBody;
} on RestClientException {
rethrow;
Expand All @@ -187,26 +174,67 @@ abstract base class RestClientBase implements RestClient {
}

/// Decodes a [String] to a [Map<String, Object?>]
Future<Map<String, Object?>?> _decodeString(String str) async {
if (str.isEmpty) return null;

if (str.length > 1000) {
return Isolate.run(() => json.decode(str) as Map<String, Object?>);
Future<Map<String, Object?>?> _decodeString(String stringBody) async {
if (stringBody.isEmpty) return null;

if (stringBody.length > 1000) {
return (await compute(
json.decode,
stringBody,
debugLabel: kDebugMode ? 'Decode String Compute' : null,
)) as Map<String, Object?>;
}

return json.decode(str) as Map<String, Object?>;
return json.decode(stringBody) as Map<String, Object?>;
}

/// Decodes a [List<int>] to a [Map<String, Object?>]
Future<Map<String, Object?>?> _decodeBytes(List<int> bytes) async {
if (bytes.isEmpty) return null;

if (bytes.length > 1000) {
return Isolate.run(
() => _jsonUTF8.decode(bytes)! as Map<String, Object?>,
);
Future<Map<String, Object?>?> _decodeBytes(List<int> bytesBody) async {
if (bytesBody.isEmpty) return null;

if (bytesBody.length > 1000) {
return (await compute(
_jsonUTF8.decode,
bytesBody,
debugLabel: kDebugMode ? 'Decode Bytes Compute' : null,
))! as Map<String, Object?>;
}

return _jsonUTF8.decode(bytes)! as Map<String, Object?>;
return _jsonUTF8.decode(bytesBody)! as Map<String, Object?>;
}
}

/// {@template response_body}
/// A sealed class representing the response body
/// {@endtemplate}
sealed class ResponseBody<T> {
/// {@macro response_body}
const ResponseBody(this.data);

/// The data of the response.
final T data;
}

/// {@template string_response_body}
/// A [ResponseBody] for a [String] response
/// {@endtemplate}
class StringResponseBody extends ResponseBody<String> {
/// {@macro string_response_body}
const StringResponseBody(super.data);
}

/// {@template map_response_body}
/// A [ResponseBody] for a [Map<String, Object?>] response
/// {@endtemplate}
class MapResponseBody extends ResponseBody<Map<String, Object?>> {
/// {@macro map_response_body}
const MapResponseBody(super.data);
}

/// {@template bytes_response_body}
/// A [ResponseBody] for both [Uint8List] and [List<int>] responses
/// {@endtemplate}
class BytesResponseBody extends ResponseBody<List<int>> {
/// {@macro bytes_response_body}
const BytesResponseBody(super.data);
}
16 changes: 8 additions & 8 deletions test/src/core/rest_client/rest_client_base_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,36 +62,36 @@ void main() {

test('decodeResponseWithEmptyBody', () {
final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080');
expectLater(client.decodeResponse(<int>[]), completion(isNull));
expectLater(client.decodeResponse(const BytesResponseBody(<int>[])), completion(isNull));
});

test('decodeResponseWithMapBody', () {
final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080');
final body = {'key1': 'value1', 'key2': 2, 'key3': true};
final encodedBody = jsonUtf8.encode(body);
expectLater(client.decodeResponse(encodedBody), completion(equals(body)));
expectLater(client.decodeResponse(BytesResponseBody(encodedBody)), completion(equals(body)));
});

test('decodeResponseWithStringBody', () {
final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080');
const body = '{}';
final encodedBody = utf8.encode(body);
expectLater(client.decodeResponse(encodedBody), completion(equals({})));
expectLater(client.decodeResponse(BytesResponseBody(encodedBody)), completion(equals({})));
});

test('decodeResponseWithEmptyStringBody', () {
final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080');
const body = '';
final encodedBody = utf8.encode(body);
expectLater(client.decodeResponse(encodedBody), completion(equals(null)));
expectLater(client.decodeResponse(BytesResponseBody(encodedBody)), completion(equals(null)));
});

test('decodeResponseWithInvalidJsonBody', () {
final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080');
const body = 'invalid json';
final encodedBody = utf8.encode(body);
expectLater(
client.decodeResponse(encodedBody),
client.decodeResponse(BytesResponseBody(encodedBody)),
throwsA(isA<ClientException>()),
);
});
Expand All @@ -101,7 +101,7 @@ void main() {
const body = 'invalid json';
final encodedBody = utf8.encode(body);
expectLater(
client.decodeResponse(encodedBody),
client.decodeResponse(BytesResponseBody(encodedBody)),
throwsA(isA<ClientException>()),
);
});
Expand All @@ -113,7 +113,7 @@ void main() {
};
final encodedBody = jsonUtf8.encode(body);
expectLater(
client.decodeResponse(encodedBody),
client.decodeResponse(BytesResponseBody(encodedBody)),
throwsA(isA<StructuredBackendException>()),
);
});
Expand All @@ -125,7 +125,7 @@ void main() {
};
final encodedBody = jsonUtf8.encode(body);
expectLater(
client.decodeResponse(encodedBody),
client.decodeResponse(BytesResponseBody(encodedBody)),
completion(equals(body['data'])),
);
});
Expand Down

0 comments on commit 20ef50d

Please sign in to comment.