diff --git a/core/rest_client/lib/rest_client.dart b/core/rest_client/lib/rest_client.dart index 0cd0a0b0..2e372a73 100644 --- a/core/rest_client/lib/rest_client.dart +++ b/core/rest_client/lib/rest_client.dart @@ -1,4 +1,6 @@ -export 'src/exception/rest_client_exception.dart'; -export 'src/http/rest_client_http.dart'; -export 'src/rest_client.dart'; -export 'src/rest_client_base.dart'; +export 'src/core/rest_client.dart'; +export 'src/core/rest_client_base.dart'; +export 'src/core/rest_client_intercepted.dart'; +export 'src/core/rest_client_interceptor.dart'; +export 'src/model/rest_request.dart'; +export 'src/model/rest_response.dart'; diff --git a/core/rest_client/lib/src/core/rest_client.dart b/core/rest_client/lib/src/core/rest_client.dart new file mode 100644 index 00000000..7490b835 --- /dev/null +++ b/core/rest_client/lib/src/core/rest_client.dart @@ -0,0 +1,6 @@ +import 'package:rest_client/rest_client.dart'; + +abstract interface class RestClient { + Future send(RestRequest request); + Future sendMultipart(RestRequestMultipart request); +} diff --git a/core/rest_client/lib/src/core/rest_client_base.dart b/core/rest_client/lib/src/core/rest_client_base.dart new file mode 100644 index 00000000..b6195d86 --- /dev/null +++ b/core/rest_client/lib/src/core/rest_client_base.dart @@ -0,0 +1,27 @@ +import 'package:rest_client/rest_client.dart'; + +abstract base class RestClientBase implements RestClient { + Future get(String url, {Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), headers: headers, method: 'GET')); + } + + Future post(String url, {Object? body, Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), body: body, headers: headers, method: 'POST')); + } + + Future put(String url, {Object? body, Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), body: body, headers: headers, method: 'PUT')); + } + + Future delete(String url, {Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), headers: headers, method: 'DELETE')); + } + + Future patch(String url, {Object? body, Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), body: body, headers: headers, method: 'PATCH')); + } + + Future head(String url, {Map? headers}) { + return send(RestRequestBasic(url: Uri.parse(url), headers: headers, method: 'HEAD')); + } +} diff --git a/core/rest_client/lib/src/core/rest_client_intercepted.dart b/core/rest_client/lib/src/core/rest_client_intercepted.dart new file mode 100644 index 00000000..b4df483b --- /dev/null +++ b/core/rest_client/lib/src/core/rest_client_intercepted.dart @@ -0,0 +1,25 @@ +import 'package:rest_client/rest_client.dart'; + +abstract base class RestClientIntercepted extends RestClientBase { + @override + Future send(RestRequest request) { + // TODO: implement send + throw UnimplementedError(); + } + + @override + Future sendMultipart(RestRequestMultipart request) { + // TODO: implement sendMultipart + throw UnimplementedError(); + } + + /// Sends a [RestRequest] and returns a [RestResponse]. + /// + /// This method is used by [RestClientIntercepted] to handle requests. + Future sendInternal(RestRequest request); + + /// Sends a [RestRequestMultipart] and returns a [RestResponse]. + /// + /// This method is used by [RestClientIntercepted] to handle multipart requests. + Future sendMultipartInternal(RestRequestMultipart request); +} diff --git a/core/rest_client/lib/src/core/rest_client_interceptor.dart b/core/rest_client/lib/src/core/rest_client_interceptor.dart new file mode 100644 index 00000000..112a932b --- /dev/null +++ b/core/rest_client/lib/src/core/rest_client_interceptor.dart @@ -0,0 +1,37 @@ +// ignore_for_file: avoid-unnecessary-reassignment + +import 'dart:async'; + +import 'package:rest_client/rest_client.dart'; + +abstract interface class RestClientInterceptor { + /// Intercepts a [RestRequest] before it is sent. + Future interceptRequest(RestRequest request); + + /// Intercepts a [RestResponse] after it is received. + Future interceptResponse(RestResponse response); + + /// Intercepts an error that occurs during the request. + Future interceptError(Object error, StackTrace stackTrace); +} + +mixin QueuedOperations { + final Map> _queues = {}; + + /// Runs an operation in a named queue, ensuring sequential execution + Future runQueued(String queueName, Future Function() operation) { + final previousOperation = _queues[queueName] ?? Future.value(); + var newOperation = previousOperation.then((_) => operation()); + + newOperation = newOperation.whenComplete(() { + if (_queues[queueName] == newOperation) { + _queues.remove(queueName); + } + }); + + // Update the queue with the new operation + _queues[queueName] = newOperation; + + return newOperation; + } +} diff --git a/core/rest_client/lib/src/exception/rest_client_exception.dart b/core/rest_client/lib/src/exception/rest_client_exception.dart deleted file mode 100644 index e12710ad..00000000 --- a/core/rest_client/lib/src/exception/rest_client_exception.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:meta/meta.dart'; -import 'package:rest_client/rest_client.dart'; - -/// {@template rest_client_exception} -/// Base class for all [RestClient] exceptions -/// {@endtemplate} -@immutable -sealed class RestClientException implements Exception { - /// {@macro network_exception} - const RestClientException({required this.message, this.statusCode, this.cause}); - - /// Message of the exception - final String message; - - /// The status code of the response (if any) - final int? statusCode; - - /// The cause of the exception - /// - /// It is the exception that caused this exception to be thrown. - /// - /// If the exception is not caused by another exception, this field is `null`. - final Object? cause; -} - -/// {@template client_exception} -/// [ClientException] is thrown if something went wrong on client side -/// {@endtemplate} -final class ClientException extends RestClientException { - /// {@macro client_exception} - const ClientException({required super.message, super.statusCode, super.cause}); - - @override - String toString() => - 'ClientException(' - 'message: $message, ' - 'statusCode: $statusCode, ' - 'cause: $cause' - ')'; -} - -/// {@template structured_backend_exception} -/// Exception that is used for structured backend errors -/// -/// [error] is a map that contains the error details -/// -/// This exception is raised by [RestClientBase] when the response contains -/// 'error' field like the following: -/// ```json -/// { -/// "error": { -/// "message": "Some error message", -/// "code": 123 -/// } -/// ``` -/// -/// 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 "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. -/// {@endtemplate} -final class StructuredBackendException extends RestClientException { - /// {@macro structured_backend_exception} - const StructuredBackendException({required this.error, super.statusCode}) - : super(message: 'Backend returned structured error'); - - /// The error returned by the backend - final Map error; - - @override - String toString() => - 'StructuredBackendException(' - 'message: $message, ' - 'error: $error, ' - 'statusCode: $statusCode, ' - ')'; -} - -/// {@template network_exception} -/// Exception caused by internet connection issues. -/// -/// This can be raised in multiple scenarios: -/// - When device is offline -/// - When the host is unreachable (due to DNS issues, firewall, etc.) -/// {@endtemplate} -final class NetworkException extends RestClientException { - /// {@macro connection_exception} - const NetworkException({required super.message, super.statusCode, super.cause}); - - @override - String toString() => - 'NetworkException(' - 'message: $message, ' - 'statusCode: $statusCode, ' - 'cause: $cause' - ')'; -} diff --git a/core/rest_client/lib/src/http/check_exception_browser.dart b/core/rest_client/lib/src/http/check_exception_browser.dart deleted file mode 100644 index 8ce5eec4..00000000 --- a/core/rest_client/lib/src/http/check_exception_browser.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:rest_client/rest_client.dart'; - -// coverage:ignore-start -/// Checks the [http.ClientException] and tries to parse it. -Object? checkHttpException(http.ClientException e) { - if (e.message.contains('XMLHttpRequest error')) { - return NetworkException(message: e.message, cause: e); - } - - return null; -} - -// coverage:ignore-end diff --git a/core/rest_client/lib/src/http/check_exception_io.dart b/core/rest_client/lib/src/http/check_exception_io.dart deleted file mode 100644 index 3f4367c4..00000000 --- a/core/rest_client/lib/src/http/check_exception_io.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:rest_client/rest_client.dart'; - -// coverage:ignore-start -/// Checks the [http.ClientException] and tries to parse it. -Object? checkHttpException(http.ClientException e) => switch (e) { - // Under the hood, HTTP has _ClientSocketException that implements - // SocketException interface and extends ClientException - // ignore: avoid-unrelated-type-assertions - final SocketException socketException => NetworkException( - message: socketException.message, - cause: socketException, - ), - _ => null, -}; -// coverage:ignore-end diff --git a/core/rest_client/lib/src/http/rest_client_http.dart b/core/rest_client/lib/src/http/rest_client_http.dart deleted file mode 100644 index 56dc262e..00000000 --- a/core/rest_client/lib/src/http/rest_client_http.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; - -import 'package:cronet_http/cronet_http.dart' show CronetClient; -import 'package:cupertino_http/cupertino_http.dart' show CupertinoClient; -import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; -import 'package:http/http.dart' as http; -import 'package:rest_client/rest_client.dart'; -import 'package:rest_client/src/http/check_exception_io.dart' - if (dart.library.js_interop) 'package:rest_client/src/http/check_exception_browser.dart'; - -// coverage:ignore-start -/// Creates an [http.Client] based on the current platform. -/// -/// For Android, it returns a [CronetClient] with the default Cronet engine. -/// For iOS and macOS, it returns a [CupertinoClient] -/// with the default session configuration. -http.Client createDefaultHttpClient() { - http.Client? client; - final platform = defaultTargetPlatform; - - try { - client = switch (platform) { - TargetPlatform.android => CronetClient.defaultCronetEngine(), - TargetPlatform.iOS || TargetPlatform.macOS => CupertinoClient.defaultSessionConfiguration(), - _ => null, - }; - } on Object catch (e, stackTrace) { - Zone.current.print( - 'Failed to create a default http client for platform $platform $e $stackTrace', - ); - } - - return client ?? http.Client(); -} -// coverage:ignore-end - -/// {@template rest_client_http} -/// Rest client that uses [http] for making requests. -/// {@endtemplate} -final class RestClientHttp extends RestClientBase { - /// {@macro rest_client_http} - /// - /// The [client] is optional and defaults to [http.Client] - /// - /// If you provide a [client], you are responsible for closing it. - /// - /// ```dart - /// final client = http.Client(); - /// - /// final restClient = RestClientHTTP( - /// baseUrl: 'https://example.com', - /// client: client, - /// ); - /// ``` - RestClientHttp({required super.baseUrl, http.Client? client}) : _client = client ?? http.Client(); - - final http.Client _client; - - @override - Future?> send({ - required String path, - required String method, - Map? queryParams, - Map? headers, - Map? body, - }) async { - try { - final uri = buildUri(path: path, queryParams: queryParams); - final request = http.Request(method, uri); - - if (body != null) { - request.bodyBytes = encodeBody(body); - request.headers['content-type'] = 'application/json;charset=utf-8'; - } - - if (headers != null) { - request.headers.addAll(headers); - } - - final response = await _client.send(request).then(http.Response.fromStream); - - final result = await decodeResponse( - BytesResponseBody(response.bodyBytes), - statusCode: response.statusCode, - ); - - return result; - } on RestClientException { - rethrow; - } on http.ClientException catch (e, stack) { - final checkedException = checkHttpException(e); - - if (checkedException != null) { - Error.throwWithStackTrace(checkedException, stack); - } - - Error.throwWithStackTrace(ClientException(message: e.message, cause: e), stack); - } - } -} diff --git a/core/rest_client/lib/src/model/rest_request.dart b/core/rest_client/lib/src/model/rest_request.dart new file mode 100644 index 00000000..1e25cb96 --- /dev/null +++ b/core/rest_client/lib/src/model/rest_request.dart @@ -0,0 +1,84 @@ +import 'package:http_parser/http_parser.dart'; + +sealed class RestRequest { + const RestRequest({required this.url, required this.method, this.headers}); + + final Uri url; + final String method; + final Map? headers; +} + +final class RestRequestBasic extends RestRequest { + const RestRequestBasic({ + required super.url, + required super.method, + super.headers, + this.body, + }); + + final Object? body; + + @override + String toString() { + return 'RestRequestBasic(url: $url, method: $method, headers: $headers, body: $body)'; + } +} + +final class RestRequestMultipart extends RestRequest { + const RestRequestMultipart({ + required super.url, + required super.method, + super.headers, + this.fields = const {}, + this.files = const [], + }); + + final Map fields; + final List files; + + @override + String toString() { + return 'RestRequestMultipart(url: $url, method: $method, headers: $headers, files: $files)'; + } +} + +class RestMultipartFile { + /// Creates a new [RestMultipartFile] from a chunked [Stream] of bytes. + /// + /// [contentType] currently defaults to `application/octet-stream`, but in the + /// future may be inferred from [filename]. + RestMultipartFile( + this.field, + this.stream, + this.length, { + this.filename, + MediaType? contentType, + }) : contentType = contentType ?? MediaType('application', 'octet-stream'); + + /// The stream that will emit the file's contents. + final Stream> stream; + + /// The name of the form field for the file. + final String field; + + /// The size of the file in bytes. + /// + /// This must be known in advance, even if this file is created from a + /// [Stream>]. + final int length; + + /// The basename of the file. + /// + /// May be `null`. + final String? filename; + + /// The content-type of the file. + /// + /// Defaults to `application/octet-stream`. + final MediaType contentType; + + @override + String toString() { + return 'RestMultipartFile(field: $field, length: $length, filename: $filename, contentType: $contentType)'; + } +} diff --git a/core/rest_client/lib/src/model/rest_response.dart b/core/rest_client/lib/src/model/rest_response.dart new file mode 100644 index 00000000..5562dcf1 --- /dev/null +++ b/core/rest_client/lib/src/model/rest_response.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +class RestResponse { + const RestResponse({required this.statusCode, required this.body, required this.headers}); + + final int statusCode; + final Uint8List body; + final Map? headers; + + @override + String toString() { + return 'RestResponse(statusCode: $statusCode, body: ${body.lengthInBytes}, headers: $headers)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is RestResponse && + other.statusCode == statusCode && + other.body == body && + other.headers == headers; + } + + @override + int get hashCode => Object.hash(runtimeType, statusCode, body, headers); +} diff --git a/core/rest_client/lib/src/rest_client.dart b/core/rest_client/lib/src/rest_client.dart deleted file mode 100644 index 9a7cee15..00000000 --- a/core/rest_client/lib/src/rest_client.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// {@template rest_client} -/// A REST client for making HTTP requests. -/// {@endtemplate} -abstract interface class RestClient { - /// Sends a GET request to the given [path]. - Future?> get( - String path, { - Map? headers, - Map? queryParams, - }); - - /// Sends a POST request to the given [path]. - Future?> post( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }); - - /// Sends a PUT request to the given [path]. - Future?> put( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }); - - /// Sends a DELETE request to the given [path]. - Future?> delete( - String path, { - Map? headers, - Map? queryParams, - }); - - /// Sends a PATCH request to the given [path]. - Future?> patch( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }); -} diff --git a/core/rest_client/lib/src/rest_client_base.dart b/core/rest_client/lib/src/rest_client_base.dart deleted file mode 100644 index f375bba7..00000000 --- a/core/rest_client/lib/src/rest_client_base.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:rest_client/rest_client.dart'; - -/// {@macro rest_client} -@immutable -abstract base class RestClientBase implements RestClient { - /// {@macro rest_client} - RestClientBase({required String baseUrl}) : baseUri = Uri.parse(baseUrl); - - /// The base url for the client - final Uri baseUri; - - static final _jsonUTF8 = json.fuse(utf8); - - /// Sends a request to the server - Future?> send({ - required String path, - required String method, - Map? body, - Map? headers, - Map? queryParams, - }); - - @override - Future?> delete( - String path, { - Map? headers, - Map? queryParams, - }) => send(path: path, method: 'DELETE', headers: headers, queryParams: queryParams); - - @override - Future?> get( - String path, { - Map? headers, - Map? queryParams, - }) => send(path: path, method: 'GET', headers: headers, queryParams: queryParams); - - @override - Future?> patch( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }) => send(path: path, method: 'PATCH', body: body, headers: headers, queryParams: queryParams); - - @override - Future?> post( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }) => send(path: path, method: 'POST', body: body, headers: headers, queryParams: queryParams); - - @override - Future?> put( - String path, { - required Map body, - Map? headers, - Map? queryParams, - }) => send(path: path, method: 'PUT', body: body, headers: headers, queryParams: queryParams); - - /// Encodes [body] to JSON and then to UTF8 - @protected - @visibleForTesting - List encodeBody(Map body) { - try { - return _jsonUTF8.encode(body); - } on Object catch (e, stackTrace) { - Error.throwWithStackTrace( - ClientException(message: 'Error occurred during encoding', cause: e), - stackTrace, - ); - } - } - - /// Builds [Uri] from [path], [queryParams] and [baseUri] - @protected - @visibleForTesting - Uri buildUri({required String path, Map? queryParams}) { - final finalPath = p.join(baseUri.path, path); - return baseUri.replace( - path: finalPath, - queryParameters: {...baseUri.queryParameters, ...?queryParams}, - ); - } - - /// Decodes the response [body] - /// - /// This method decodes the response body to a map and checks if the response - /// is an error or successful. If the response is an error, it throws a - /// [StructuredBackendException] with the error details. - /// - /// If the response is successful, it returns the data from the response. - /// - /// If the response is neither an error nor successful, it returns the decoded - /// body as is. - @protected - @visibleForTesting - Future?> decodeResponse( - ResponseBody? body, { - int? statusCode, - }) async { - if (body == null) return null; - - try { - final decodedBody = switch (body) { - MapResponseBody(:final Map data) => data, - StringResponseBody(:final String data) => await _decodeString(data), - BytesResponseBody(:final List data) => await _decodeBytes(data), - }; - - if (decodedBody case {'error': final Map error}) { - throw StructuredBackendException(error: error, statusCode: statusCode); - } - - if (decodedBody case {'data': final Map data}) { - return data; - } - - // Return decoded body if it is not an error or data - return decodedBody; - } on RestClientException { - rethrow; - } on Object catch (e, stackTrace) { - Error.throwWithStackTrace( - ClientException(message: 'Error occured during decoding', statusCode: statusCode, cause: e), - stackTrace, - ); - } - } - - /// Decodes a [String] to a [Map] - Future?> _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; - } - - return json.decode(stringBody) as Map; - } - - /// Decodes a [List] to a [Map] - Future?> _decodeBytes(List 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; - } - - return _jsonUTF8.decode(bytesBody)! as Map; - } -} - -/// {@template response_body} -/// A sealed class representing the response body -/// {@endtemplate} -sealed class ResponseBody { - /// {@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 { - /// {@macro string_response_body} - const StringResponseBody(super.data); -} - -/// {@template map_response_body} -/// A [ResponseBody] for a [Map] response -/// {@endtemplate} -class MapResponseBody extends ResponseBody> { - /// {@macro map_response_body} - const MapResponseBody(super.data); -} - -/// {@template bytes_response_body} -/// A [ResponseBody] for both [Uint8List] and [List] responses -/// {@endtemplate} -class BytesResponseBody extends ResponseBody> { - /// {@macro bytes_response_body} - const BytesResponseBody(super.data); -} diff --git a/core/rest_client/pubspec.yaml b/core/rest_client/pubspec.yaml index e8257a1e..8747ccb1 100644 --- a/core/rest_client/pubspec.yaml +++ b/core/rest_client/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: intercepted_client: 0.0.1 path: 1.9.1 meta: 1.16.0 + http_parser: ^4.1.2 dev_dependencies: flutter_test: diff --git a/core/rest_client/test/src/rest_client_base_test.dart b/core/rest_client/test/src/rest_client_base_test.dart deleted file mode 100644 index 22cb06de..00000000 --- a/core/rest_client/test/src/rest_client_base_test.dart +++ /dev/null @@ -1,316 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:rest_client/rest_client.dart'; - -final jsonUtf8 = const JsonCodec().fuse(utf8); - -void main() { - group('RestClientBase', () { - test('encodeBodyWithValidMap', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = {'key1': 'value1', 'key2': 2, 'key3': true}; - final encodedBody = client.encodeBody(body); - final expectedBody = jsonUtf8.encode(body); - expect(encodedBody, equals(expectedBody)); - }); - - test('encodeBodyWithEmptyMap', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = {}; - final encodedBody = client.encodeBody(body); - const expectedBody = [123, 125]; - expect(encodedBody, equals(expectedBody)); - }); - - test('encodeBodyWithInvalidMap', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = {'key1': const _NoOpClass()}; - expect(() => client.encodeBody(body), throwsA(isA())); - }); - - test('encodeBodyWithNestedMap', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = { - 'key1': 'value1', - 'key2': 2, - 'key3': true, - 'key4': {'key5': 'value5'}, - }; - final encodedBody = client.encodeBody(body); - final expectedBody = jsonUtf8.encode(body); - expect(encodedBody, equals(expectedBody)); - }); - - test('encodeBodyWithNestedLists', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = { - 'key1': 'value1', - 'key2': 2, - 'key3': true, - 'key4': ['value5'], - }; - final encodedBody = client.encodeBody(body); - final expectedBody = jsonUtf8.encode(body); - expect(encodedBody, equals(expectedBody)); - }); - - test('decodeResponseWithNullBody', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater(client.decodeResponse(null), completion(isNull)); - }); - - test('decodeResponseWithEmptyBody', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater(client.decodeResponse(const BytesResponseBody([])), 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(BytesResponseBody(encodedBody)), completion(equals(body))); - }); - - test('decodeResponseWithStringBody', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const body = '{}'; - final encodedBody = utf8.encode(body); - 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(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(BytesResponseBody(encodedBody)), - throwsA(isA()), - ); - }); - - test('decodeResponseWithInvalidJson', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const body = 'invalid json'; - final encodedBody = utf8.encode(body); - expectLater( - client.decodeResponse(BytesResponseBody(encodedBody)), - throwsA(isA()), - ); - }); - - test('decodeResponseWithErrorInResponseBody', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = { - 'error': {'message': 'Some error message', 'code': 123}, - }; - final encodedBody = jsonUtf8.encode(body); - expectLater( - client.decodeResponse(BytesResponseBody(encodedBody)), - throwsA(isA()), - ); - }); - - test('decodeResponseWithDataInResponseBody', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - final body = { - 'data': {'key1': 'value1', 'key2': 2, 'key3': true}, - }; - final encodedBody = jsonUtf8.encode(body); - expectLater( - client.decodeResponse(BytesResponseBody(encodedBody)), - completion(equals(body['data'])), - ); - }); - - test('buildUriWithValidPathAndQueryParams', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const path = '/path'; - final queryParams = {'key1': 'value1', 'key2': 'value2'}; - final uri = client.buildUri(path: path, queryParams: queryParams); - final expectedUri = Uri.parse('http://localhost:8080$path?key1=value1&key2=value2'); - expect(uri, equals(expectedUri)); - }); - - test('buildUriWithEmptyPath', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const path = ''; - final queryParams = {'key1': 'value1', 'key2': 'value2'}; - final uri = client.buildUri(path: path, queryParams: queryParams); - final expectedUri = Uri.parse('http://localhost:8080?key1=value1&key2=value2'); - expect(uri, equals(expectedUri)); - }); - - test('buildUriWithSpecialCharactersInPath', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const path = '/path with spaces'; - final queryParams = {'key1': 'value1', 'key2': 'value2'}; - final uri = client.buildUri(path: path, queryParams: queryParams); - final expectedUri = Uri.parse( - 'http://localhost:8080/path%20with%20spaces?key1=value1&key2=value2', - ); - expect(uri, equals(expectedUri)); - }); - - test('buildUriWithEndingSlashInBaseUrl', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080/'); - const path = '/path'; - final queryParams = {'key1': 'value1', 'key2': 'value2'}; - final uri = client.buildUri(path: path, queryParams: queryParams); - final expectedUri = Uri.parse('http://localhost:8080$path?key1=value1&key2=value2'); - expect(uri, equals(expectedUri)); - }); - - test('buildUriWithPathWithoutSlash', () { - final client = NoOpRestClientBase(baseUrl: 'http://localhost:8080'); - const path = 'path'; - final queryParams = {'key1': 'value1', 'key2': 'value2'}; - final uri = client.buildUri(path: path, queryParams: queryParams); - final expectedUri = Uri.parse('http://localhost:8080/$path?key1=value1&key2=value2'); - expect(uri, equals(expectedUri)); - }); - - test('getReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.get('/path'), - completion( - equals({ - 'path': '/path', - 'method': 'GET', - 'body': null, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - - test('postReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.post('/path', body: {}), - completion( - equals({ - 'path': '/path', - 'method': 'POST', - 'body': {}, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - - test('putReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.put('/path', body: {}), - completion( - equals({ - 'path': '/path', - 'method': 'PUT', - 'body': {}, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - - test('deleteReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.delete('/path'), - completion( - equals({ - 'path': '/path', - 'method': 'DELETE', - 'body': null, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - - test('patchReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.patch('/path', body: {}), - completion( - equals({ - 'path': '/path', - 'method': 'PATCH', - 'body': {}, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - - test('sendReturns', () { - final client = _ReturningRestClientBase(baseUrl: 'http://localhost:8080'); - expectLater( - client.send(path: '/path', method: 'GET'), - completion( - equals({ - 'path': '/path', - 'method': 'GET', - 'body': null, - 'headers': null, - 'queryParams': null, - }), - ), - ); - }); - }); -} - -class _NoOpClass { - const _NoOpClass(); -} - -final class _ReturningRestClientBase extends RestClientBase { - _ReturningRestClientBase({required super.baseUrl}); - - @override - Future?> send({ - required String path, - required String method, - Map? body, - Map? headers, - Map? queryParams, - }) async => { - 'path': path, - 'method': method, - 'body': body, - 'headers': headers, - 'queryParams': queryParams, - }; -} - -/// A no-op implementation of [RestClientBase]. -/// -/// This is used in tests to verify behaviour of basic methods -/// like encoding and decoding of request and response. -final class NoOpRestClientBase extends RestClientBase { - NoOpRestClientBase({required super.baseUrl}); - - @override - Future?> send({ - required String path, - required String method, - Map? body, - Map? headers, - Map? queryParams, - }) => throw UnimplementedError(); -} diff --git a/core/rest_client/test/src/rest_client_http_test.dart b/core/rest_client/test/src/rest_client_http_test.dart deleted file mode 100644 index ce103e47..00000000 --- a/core/rest_client/test/src/rest_client_http_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart' as http_testing; -import 'package:rest_client/rest_client.dart'; - -void main() { - group('RestClientHttp', () { - test('returns normally', () { - final mockClient = http_testing.MockClient( - (request) async => http.Response('{"data": {"hello": "world"}}', 200), - ); - - final restClient = RestClientHttp(baseUrl: 'https://example.com', client: mockClient); - - expectLater( - restClient.send(path: '/', method: 'GET'), - completion( - isA>().having( - (json) => json['hello'], - 'Data contains hello', - 'world', - ), - ), - ); - }); - - test("adds body if it's not null", () async { - final mockClient = http_testing.MockClient((request) async { - expect(request.body, '{"hello":"world"}', reason: 'Body should be {"hello":"world"}'); - - return http.Response('{"data": {"hello": "world"}}', 200); - }); - - final restClient = RestClientHttp(baseUrl: 'https://example.com', client: mockClient); - - await expectLater( - restClient.send(path: '/', method: 'POST', body: {'hello': 'world'}), - completion( - isA>().having( - (json) => json['hello'], - 'Data contains hello', - 'world', - ), - ), - ); - }); - - test('adds headers', () { - final mockClient = http_testing.MockClient((request) async { - expect(request.headers['hello'], 'world'); - return http.Response('{"data": {"hello": "world"}}', 200); - }); - - final restClient = RestClientHttp(baseUrl: 'https://example.com', client: mockClient); - - expectLater( - restClient.send(path: '/', method: 'GET', headers: {'hello': 'world'}), - completion( - isA>().having( - (json) => json['hello'], - 'Data contains hello', - 'world', - ), - ), - ); - }); - - test('throws RestClientException on error', () { - final mockClient = http_testing.MockClient( - (request) async => http.Response('{"error": {}}', 400), - ); - - final restClient = RestClientHttp(baseUrl: 'https://example.com', client: mockClient); - - expectLater( - restClient.send(path: '/', method: 'GET'), - throwsA(isA()), - ); - }); - }); -}