From c9385744497659bb00a71b7b87fb8f79c6aba90b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 5 Aug 2025 14:15:53 +0200 Subject: [PATCH 01/11] Add classes for Dio Http client --- packages/stream_core/lib/src/api.dart | 6 + .../lib/src/api/connection_id_provider.dart | 4 + .../stream_core/lib/src/api/http_client.dart | 290 +++++++++++++++ .../lib/src/api/http_client_options.dart | 41 +++ .../additional_headers_interceptor.dart | 29 ++ .../api/interceptors/auth_interceptor.dart | 77 ++++ .../connection_id_interceptor.dart | 26 ++ .../api/interceptors/logging_interceptor.dart | 347 ++++++++++++++++++ .../lib/src/api/stream_core_dio_error.dart | 43 +++ .../lib/src/api/system_environment.dart | 57 +++ .../src/api/system_environment_manager.dart | 55 +++ .../lib/src/api/token_manager.dart | 50 +++ packages/stream_core/lib/src/errors.dart | 2 + .../lib/src/errors/client_exception.dart | 23 +- .../lib/src/errors/http_api_error.dart | 31 -- .../lib/src/errors/stream_api_error.dart | 101 +++++ .../lib/src/errors/stream_api_error.g.dart | 35 ++ .../lib/src/errors/stream_error_code.dart | 159 ++++++++ packages/stream_core/lib/stream_core.dart | 2 + packages/stream_core/pubspec.yaml | 1 + 20 files changed, 1343 insertions(+), 36 deletions(-) create mode 100644 packages/stream_core/lib/src/api.dart create mode 100644 packages/stream_core/lib/src/api/connection_id_provider.dart create mode 100644 packages/stream_core/lib/src/api/http_client.dart create mode 100644 packages/stream_core/lib/src/api/http_client_options.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart create mode 100644 packages/stream_core/lib/src/api/stream_core_dio_error.dart create mode 100644 packages/stream_core/lib/src/api/system_environment.dart create mode 100644 packages/stream_core/lib/src/api/system_environment_manager.dart create mode 100644 packages/stream_core/lib/src/api/token_manager.dart create mode 100644 packages/stream_core/lib/src/errors.dart delete mode 100644 packages/stream_core/lib/src/errors/http_api_error.dart create mode 100644 packages/stream_core/lib/src/errors/stream_api_error.dart create mode 100644 packages/stream_core/lib/src/errors/stream_api_error.g.dart create mode 100644 packages/stream_core/lib/src/errors/stream_error_code.dart diff --git a/packages/stream_core/lib/src/api.dart b/packages/stream_core/lib/src/api.dart new file mode 100644 index 0000000..a7e65f9 --- /dev/null +++ b/packages/stream_core/lib/src/api.dart @@ -0,0 +1,6 @@ +export 'api/connection_id_provider.dart'; +export 'api/http_client.dart'; +export 'api/stream_core_dio_error.dart'; +export 'api/system_environment.dart'; +export 'api/system_environment_manager.dart'; +export 'api/token_manager.dart'; diff --git a/packages/stream_core/lib/src/api/connection_id_provider.dart b/packages/stream_core/lib/src/api/connection_id_provider.dart new file mode 100644 index 0000000..1f7925d --- /dev/null +++ b/packages/stream_core/lib/src/api/connection_id_provider.dart @@ -0,0 +1,4 @@ +// ignore_for_file: use_setters_to_change_properties + +/// Provides the connection id of the websocket connection +typedef ConnectionIdProvider = String? Function(); diff --git a/packages/stream_core/lib/src/api/http_client.dart b/packages/stream_core/lib/src/api/http_client.dart new file mode 100644 index 0000000..91693cf --- /dev/null +++ b/packages/stream_core/lib/src/api/http_client.dart @@ -0,0 +1,290 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; + +import '../../stream_core.dart'; +import 'connection_id_provider.dart'; +import 'interceptors/additional_headers_interceptor.dart'; +import 'interceptors/auth_interceptor.dart'; +import 'interceptors/connection_id_interceptor.dart'; +import 'interceptors/logging_interceptor.dart'; +import 'stream_core_dio_error.dart'; +import 'system_environment_manager.dart'; +import 'token_manager.dart'; + +part 'http_client_options.dart'; + +/// This is where we configure the base url, headers, +/// query parameters and convenient methods for http verbs with error parsing. +class CoreHttpClient { + /// [CoreHttpClient] constructor + CoreHttpClient( + this.apiKey, { + Dio? dio, + HttpClientOptions? options, + TokenManager? tokenManager, + ConnectionIdProvider? connectionIdProvider, + required SystemEnvironmentManager systemEnvironmentManager, + Logger? logger, + Iterable? interceptors, + HttpClientAdapter? httpClientAdapter, + }) : _options = options ?? const HttpClientOptions(), + httpClient = dio ?? Dio() { + httpClient + ..options.baseUrl = _options.baseUrl + ..options.receiveTimeout = _options.receiveTimeout + ..options.connectTimeout = _options.connectTimeout + ..options.queryParameters = { + 'api_key': apiKey, + ..._options.queryParameters, + } + ..options.headers = { + 'Content-Type': 'application/json', + 'Content-Encoding': 'application/gzip', + ..._options.headers, + } + ..interceptors.addAll([ + AdditionalHeadersInterceptor(systemEnvironmentManager), + if (tokenManager != null) AuthInterceptor(this, tokenManager), + if (connectionIdProvider != null) + ConnectionIdInterceptor(connectionIdProvider), + ...interceptors ?? + [ + // Add a default logging interceptor if no interceptors are + // provided. + if (logger != null && logger.level != Level.OFF) + LoggingInterceptor( + requestHeader: true, + logPrint: (step, message) { + switch (step) { + case InterceptStep.request: + return logger.info(message); + case InterceptStep.response: + return logger.info(message); + case InterceptStep.error: + return logger.severe(message); + } + }, + ), + ], + ]); + if (httpClientAdapter != null) { + httpClient.httpClientAdapter = httpClientAdapter; + } + } + + /// Your project Stream Chat api key. + /// Find your API keys here https://getstream.io/dashboard/ + final String apiKey; + + /// Your project Stream Chat ClientOptions + final HttpClientOptions _options; + + /// [Dio] httpClient + /// It's been chosen because it's easy to use + /// and supports interesting features out of the box + /// (Interceptors, Global configuration, FormData, File downloading etc.) + @visibleForTesting + final Dio httpClient; + + /// Shuts down the [StreamHttpClient]. + /// + /// If [force] is `false` the [StreamHttpClient] will be kept alive + /// until all active connections are done. If [force] is `true` any active + /// connections will be closed to immediately release all resources. These + /// closed connections will receive an error event to indicate that the client + /// was shut down. In both cases trying to establish a new connection after + /// calling [close] will throw an exception. + void close({bool force = false}) => httpClient.close(force: force); + + ClientException _parseError(DioException exception) { + // locally thrown dio error + if (exception is StreamDioException) return exception.exception; + // real network request dio error + return exception.toClientException(); + } + + /// Handy method to make http GET request with error parsing. + Future> get( + String path, { + Map? queryParameters, + Map? headers, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.get( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http POST request with error parsing. + Future> post( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.post( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http DELETE request with error parsing. + Future> delete( + String path, { + Map? queryParameters, + Map? headers, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.delete( + path, + queryParameters: queryParameters, + options: Options(headers: headers), + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PATCH request with error parsing. + Future> patch( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.patch( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PUT request with error parsing. + Future> put( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.put( + path, + queryParameters: queryParameters, + data: data, + options: Options(headers: headers), + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to post files with error parsing. + Future> postFile( + String path, + MultipartFile file, { + Map? queryParameters, + Map? headers, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + final formData = FormData.fromMap({'file': file}); + final response = await post( + path, + data: formData, + queryParameters: queryParameters, + headers: headers, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } + + /// Handy method to make generic http request with error parsing. + Future> request( + String path, { + Object? data, + Map? queryParameters, + Options? options, + ProgressCallback? onSendProgress, + ProgressCallback? onReceiveProgress, + CancelToken? cancelToken, + }) async { + try { + final response = await httpClient.request( + path, + data: data, + queryParameters: queryParameters, + options: options, + onSendProgress: onSendProgress, + onReceiveProgress: onReceiveProgress, + cancelToken: cancelToken, + ); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http requests from [RequestOptions] + /// with error parsing. + Future> fetch( + RequestOptions requestOptions, + ) async { + try { + final response = await httpClient.fetch(requestOptions); + return response; + } on DioException catch (error) { + throw _parseError(error); + } + } +} diff --git a/packages/stream_core/lib/src/api/http_client_options.dart b/packages/stream_core/lib/src/api/http_client_options.dart new file mode 100644 index 0000000..55b298b --- /dev/null +++ b/packages/stream_core/lib/src/api/http_client_options.dart @@ -0,0 +1,41 @@ +part of 'http_client.dart'; + +const _defaultBaseURL = 'https://chat.stream-io-api.com'; + +/// Client options to modify [CoreHttpClient] +class HttpClientOptions { + /// Instantiates a new [HttpClientOptions] + const HttpClientOptions({ + String? baseUrl, + this.connectTimeout = const Duration(seconds: 30), + this.receiveTimeout = const Duration(seconds: 30), + this.queryParameters = const {}, + this.headers = const {}, + }) : baseUrl = baseUrl ?? _defaultBaseURL; + + /// base url to use with client. + final String baseUrl; + + /// connect timeout, default to 30s + final Duration connectTimeout; + + /// received timeout, default to 30s + final Duration receiveTimeout; + + /// Common query parameters. + /// + /// List values use the default [ListFormat.multiCompatible]. + /// + /// The value can be overridden per parameter by adding a [MultiParam] + /// object wrapping the actual List value and the desired format. + final Map queryParameters; + + /// Http request headers. + /// The keys of initial headers will be converted to lowercase, + /// for example 'Content-Type' will be converted to 'content-type'. + /// + /// The key of Header Map is case-insensitive + /// eg: content-type and Content-Type are + /// regard as the same key. + final Map headers; +} diff --git a/packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart new file mode 100644 index 0000000..23986f2 --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../system_environment_manager.dart'; + +/// Interceptor that sets additional headers for all requests. +class AdditionalHeadersInterceptor extends Interceptor { + /// Initialize a new [AdditionalHeadersInterceptor]. + const AdditionalHeadersInterceptor(this._systemEnvironmentManager); + + final SystemEnvironmentManager _systemEnvironmentManager; + + /// Additional headers for all requests + static Map additionalHeaders = {}; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final userAgent = _systemEnvironmentManager.userAgent; + + options.headers = { + ...options.headers, + ...additionalHeaders, + 'X-Stream-Client': userAgent, + }; + return handler.next(options); + } +} diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..3144fa6 --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; + +import '../../errors.dart'; +import '../../errors/stream_error_code.dart'; +import '../http_client.dart'; +import '../stream_core_dio_error.dart'; +import '../token_manager.dart'; + +/// Authentication interceptor that refreshes the token if +/// an auth error is received +class AuthInterceptor extends QueuedInterceptor { + /// Initialize a new auth interceptor + AuthInterceptor(this._client, this._tokenManager); + + final CoreHttpClient _client; + + /// The token manager used in the client + final TokenManager _tokenManager; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + try { + final token = await _tokenManager.loadToken(); + + final params = {'user_id': _tokenManager.userId}; + final headers = { + 'Authorization': token, + 'stream-auth-type': _tokenManager.authType, + }; + options + ..queryParameters.addAll(params) + ..headers.addAll(headers); + return handler.next(options); + } catch (e, stackTrace) { + final error = ClientException( + message: 'Failed to load auth token', + stackTrace: stackTrace, + error: e, + ); + final dioError = StreamDioException( + exception: error, + requestOptions: options, + stackTrace: StackTrace.current, + ); + return handler.reject(dioError, true); + } + } + + @override + Future onError( + DioException exception, + ErrorInterceptorHandler handler, + ) async { + final data = exception.response?.data; + if (data == null || data is! Map) { + return handler.next(exception); + } + + final error = StreamApiError.fromJson(data); + if (error.isTokenExpiredError) { + if (_tokenManager.isStatic) return handler.next(exception); + await _tokenManager.loadToken(refresh: true); + try { + final options = exception.requestOptions; + // ignore: inference_failure_on_function_invocation + final response = await _client.fetch(options); + return handler.resolve(response); + } on DioException catch (exception) { + return handler.next(exception); + } + } + return handler.next(exception); + } +} diff --git a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart new file mode 100644 index 0000000..0f45c21 --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../connection_id_provider.dart'; + +/// Interceptor that injects the connection id in the request params +class ConnectionIdInterceptor extends Interceptor { + /// + ConnectionIdInterceptor(this.connectionIdProvider); + + /// + final ConnectionIdProvider connectionIdProvider; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final connectionId = connectionIdProvider(); + if (connectionId != null) { + options.queryParameters.addAll({ + 'connection_id': connectionId, + }); + } + handler.next(options); + } +} diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart new file mode 100644 index 0000000..6d6f74c --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -0,0 +1,347 @@ +// ignore_for_file: lines_longer_than_80_chars +// coverage:ignore-file + +import 'dart:math' as math; + +import 'package:dio/dio.dart'; + +/// Step where we're logging +enum InterceptStep { + /// Request + request, + + /// Response + response, + + /// Error + error, +} + +/// Function used to print the log +typedef LogPrint = void Function(InterceptStep step, Object object); + +void _defaultLogPrint(InterceptStep step, Object object) => print(object); + +/// Interceptor dedicated to logging +class LoggingInterceptor extends Interceptor { + /// Initialize a new logging interceptor + LoggingInterceptor({ + this.request = true, + this.requestHeader = false, + this.requestBody = true, + this.responseHeader = false, + this.responseBody = true, + this.error = true, + this.maxWidth = 120, + this.compact = true, + this.logPrint = _defaultLogPrint, + }); + + /// Print request [Options] + final bool request; + + /// Print request header [Options.headers] + final bool requestHeader; + + /// Print request data [Options.data] + final bool requestBody; + + /// Print [Response.data] + final bool responseBody; + + /// Print [Response.headers] + final bool responseHeader; + + /// Print error message + final bool error; + + /// InitialTab count to logPrint json response + static const int initialTab = 1; + + /// 1 tab length + static const String tabStep = ' '; + + /// Print compact json response + final bool compact; + + /// Width size per logPrint + final int maxWidth; + + /// Log printer; defaults logPrint log to console. + /// In flutter, you'd better use debugPrint. + /// you can also write log in a file. + void Function(InterceptStep step, Object object) logPrint; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (request) { + _printRequestHeader(_logPrintRequest, options); + } + if (requestHeader) { + _printMapAsTable( + _logPrintRequest, + options.queryParameters, + header: 'Query Parameters', + ); + final requestHeaders = {...options.headers}; + requestHeaders['contentType'] = options.contentType?.toString(); + requestHeaders['responseType'] = options.responseType.toString(); + requestHeaders['followRedirects'] = options.followRedirects; + requestHeaders['connectTimeout'] = options.connectTimeout?.toString(); + requestHeaders['receiveTimeout'] = options.receiveTimeout?.toString(); + _printMapAsTable(_logPrintRequest, requestHeaders, header: 'Headers'); + _printMapAsTable(_logPrintRequest, options.extra, header: 'Extras'); + } + if (requestBody && options.method != 'GET') { + final dynamic data = options.data; + if (data != null) { + if (data is Map) { + _printMapAsTable( + _logPrintRequest, + options.data as Map?, + header: 'Body', + ); + } else if (data is FormData) { + final formDataMap = {} + ..addEntries(data.fields) + ..addEntries(data.files); + _printMapAsTable( + _logPrintRequest, + formDataMap, + header: 'Form data | ${data.boundary}', + ); + } else { + _printBlock(_logPrintRequest, data.toString()); + } + } + } + super.onRequest(options, handler); + } + + @override + void onError(DioException exception, ErrorInterceptorHandler handler) { + if (error) { + if (exception.type == DioExceptionType.badResponse) { + final uri = exception.response?.requestOptions.uri; + _printBoxed( + _logPrintError, + header: + 'DioException ║ Status: ${exception.response?.statusCode} ${exception.response?.statusMessage}', + text: uri.toString(), + ); + if (exception.response != null && exception.response?.data != null) { + _logPrintError('╔ ${exception.type.toString()}'); + _printResponse(_logPrintError, exception.response!); + } + _printLine(_logPrintError, '╚'); + _logPrintError(''); + } else { + _printBoxed( + _logPrintError, + header: 'DioException ║ ${exception.type}', + text: exception.message, + ); + _printRequestHeader(_logPrintError, exception.requestOptions); + } + } + super.onError(exception, handler); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + _printResponseHeader(_logPrintResponse, response); + if (responseHeader) { + final responseHeaders = {}; + response.headers + .forEach((k, list) => responseHeaders[k] = list.toString()); + _printMapAsTable(_logPrintResponse, responseHeaders, header: 'Headers'); + } + + if (responseBody) { + _logPrintResponse('╔ Body'); + _logPrintResponse('║'); + _printResponse(_logPrintResponse, response); + _logPrintResponse('║'); + _printLine(_logPrintResponse, '╚'); + } + super.onResponse(response, handler); + } + + void _printBoxed( + void Function(Object) logPrint, { + String? header, + String? text, + }) { + logPrint(''); + logPrint('╔╣ $header'); + logPrint('║ $text'); + _printLine(logPrint, '╚'); + } + + void _printResponse(void Function(Object) logPrint, Response response) { + if (response.data != null) { + if (response.data is Map) { + _printPrettyMap(logPrint, response.data as Map); + } else if (response.data is List) { + logPrint('║${_indent()}['); + _printList(logPrint, response.data as List); + logPrint('║${_indent()}['); + } else { + _printBlock(logPrint, response.data.toString()); + } + } + } + + void _printResponseHeader(void Function(Object) logPrint, Response response) { + final uri = response.requestOptions.uri; + final method = response.requestOptions.method; + _printBoxed( + logPrint, + header: + 'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}', + text: uri.toString(), + ); + } + + void _printRequestHeader( + void Function(Object) logPrint, + RequestOptions options, + ) { + final uri = options.uri; + final method = options.method; + _printBoxed(logPrint, header: 'Request ║ $method ', text: uri.toString()); + } + + void _printLine( + void Function(Object) logPrint, [ + String pre = '', + String suf = '╝', + ]) => + logPrint('$pre${'═' * maxWidth}$suf'); + + void _printKV(void Function(Object) logPrint, String? key, Object? v) { + final pre = '╟ $key: '; + final msg = v.toString(); + + if (pre.length + msg.length > maxWidth) { + logPrint(pre); + _printBlock(logPrint, msg); + } else { + logPrint('$pre$msg'); + } + } + + void _printBlock(void Function(Object) logPrint, String msg) { + final lines = (msg.length / maxWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint((i >= 0 ? '║ ' : '') + + msg.substring( + i * maxWidth, + math.min(i * maxWidth + maxWidth, msg.length), + )); + } + } + + String _indent([int tabCount = initialTab]) => tabStep * tabCount; + + void _printPrettyMap( + void Function(Object) logPrint, + Map data, { + int tabs = initialTab, + bool isListItem = false, + bool isLast = false, + }) { + var _tabs = tabs; + final isRoot = _tabs == initialTab; + final initialIndent = _indent(_tabs); + _tabs++; + + if (isRoot || isListItem) logPrint('║$initialIndent{'); + + data.keys.toList().asMap().forEach((index, dynamic key) { + final isLast = index == data.length - 1; + dynamic value = data[key]; + if (value is String) { + value = '"${value.toString().replaceAll(RegExp(r'(\r|\n)+'), " ")}"'; + } + if (value is Map) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: $value${!isLast ? ',' : ''}'); + } else { + logPrint('║${_indent(_tabs)} $key: {'); + _printPrettyMap(logPrint, value, tabs: _tabs); + } + } else if (value is List) { + if (compact) { + logPrint('║${_indent(_tabs)} $key: ${value.toString()}'); + } else { + logPrint('║${_indent(_tabs)} $key: ['); + _printList(logPrint, value, tabs: _tabs); + logPrint('║${_indent(_tabs)} ]${isLast ? '' : ','}'); + } + } else { + final msg = value.toString().replaceAll('\n', ''); + final indent = _indent(_tabs); + final linWidth = maxWidth - indent.length; + if (msg.length + indent.length > linWidth) { + final lines = (msg.length / linWidth).ceil(); + for (var i = 0; i < lines; ++i) { + logPrint('║${_indent(_tabs)} ${msg.substring( + i * linWidth, + math.min(i * linWidth + linWidth, msg.length), + )}'); + } + } else { + logPrint('║${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}'); + } + } + }); + + logPrint('║$initialIndent}${isListItem && !isLast ? ',' : ''}'); + } + + void _printList( + void Function(Object) logPrint, + List list, { + int tabs = initialTab, + }) { + list.asMap().forEach((i, dynamic e) { + final isLast = i == list.length - 1; + if (e is Map) { + if (compact) { + logPrint('║${_indent(tabs)} $e${!isLast ? ',' : ''}'); + } else { + _printPrettyMap( + logPrint, + e, + tabs: tabs + 1, + isListItem: true, + isLast: isLast, + ); + } + } else { + logPrint('║${_indent(tabs + 2)} $e${isLast ? '' : ','}'); + } + }); + } + + void _printMapAsTable( + void Function(Object) logPrint, + Map? map, { + String? header, + }) { + if (map == null || map.isEmpty) return; + logPrint('╔ $header '); + map.forEach((dynamic key, dynamic value) => + _printKV(logPrint, key.toString(), value)); + _printLine(logPrint, '╚'); + } + + void _logPrintRequest(Object object) => + logPrint(InterceptStep.request, object); + + void _logPrintResponse(Object object) => + logPrint(InterceptStep.response, object); + + void _logPrintError(Object object) => logPrint(InterceptStep.error, object); +} diff --git a/packages/stream_core/lib/src/api/stream_core_dio_error.dart b/packages/stream_core/lib/src/api/stream_core_dio_error.dart new file mode 100644 index 0000000..1643d9f --- /dev/null +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../stream_core.dart'; + +/// Error class specific to StreamChat and Dio +class StreamDioException extends DioException { + /// Initialize a stream chat dio error + StreamDioException({ + required this.exception, + required super.requestOptions, + super.response, + super.type, + StackTrace? stackTrace, + super.message, + }) : super( + error: exception, + stackTrace: stackTrace ?? StackTrace.current, + ); + + final ClientException exception; +} + +extension StreamDioExceptionExtension on DioException { + HttpClientException toClientException() { + final response = this.response; + StreamApiError? apiError; + final data = response?.data; + if (data is Map) { + apiError = StreamApiError.fromJson(data); + } else if (data is String) { + apiError = StreamApiError.fromJson(jsonDecode(data)); + } + return HttpClientException( + message: apiError?.message ?? response?.statusMessage ?? message ?? '', + error: apiError ?? this, + statusCode: apiError?.statusCode ?? response?.statusCode, + stackTrace: stackTrace, + isRequestCancelledError: type == DioExceptionType.cancel, + ); + } +} diff --git a/packages/stream_core/lib/src/api/system_environment.dart b/packages/stream_core/lib/src/api/system_environment.dart new file mode 100644 index 0000000..32987e3 --- /dev/null +++ b/packages/stream_core/lib/src/api/system_environment.dart @@ -0,0 +1,57 @@ +/// {@template streamSystemEnvironment} +/// A class that represents the environment in which the Stream SDK is +/// running. +/// +/// This class provides information about the SDK, application, and device +/// used for tracking and debugging purposes. +/// {@endtemplate} +class SystemEnvironment { + /// {@macro streamSystemEnvironment} + const SystemEnvironment({ + required this.sdkName, + required this.sdkIdentifier, + required this.sdkVersion, + this.appName, + this.appVersion, + this.osName, + this.osVersion, + this.deviceModel, + }); + + /// The name of the SDK. + final String sdkName; + + /// The identifier of the SDK platform. + /// + /// This is used to distinguish between different implementations + /// (e.g., 'dart', 'flutter', etc.). + final String sdkIdentifier; + + /// The version of the SDK. + final String sdkVersion; + + /// The name of the application. + /// + /// This is null by default and could be set by the application. + final String? appName; + + /// The version of the application. + /// + /// This is null by default and could be set by the application. + final String? appVersion; + + /// The name of the operating system. + /// + /// This is null by default and could be set by the application. + final String? osName; + + /// The version of the operating system. + /// + /// This is null by default and could be set by the application. + final String? osVersion; + + /// The device model information. + /// + /// This is null by default and could be set by the application. + final String? deviceModel; +} diff --git a/packages/stream_core/lib/src/api/system_environment_manager.dart b/packages/stream_core/lib/src/api/system_environment_manager.dart new file mode 100644 index 0000000..086e497 --- /dev/null +++ b/packages/stream_core/lib/src/api/system_environment_manager.dart @@ -0,0 +1,55 @@ +// ignore_for_file: use_setters_to_change_properties + +import 'system_environment.dart'; + +/// {@template systemEnvironmentManager} +/// A manager class to handle the current [SystemEnvironment]. +/// {@endtemplate} +class SystemEnvironmentManager { + /// {@macro systemEnvironmentManager} + SystemEnvironmentManager({ + required SystemEnvironment environment, + }) : _environment = environment; + + /// Returns the Stream client user agent string based on the current + /// [environment] value. + String get userAgent => _environment.xStreamClientHeader; + + /// The current [SystemEnvironment]. + SystemEnvironment get environment => _environment; + SystemEnvironment _environment; + + /// Updates the current [SystemEnvironment]. + void updateEnvironment(SystemEnvironment environment) { + _environment = environment; + } +} + +/// Extension on [SystemEnvironment] to build a Stream client header string. +extension XStreamClientHeaderExtension on SystemEnvironment { + /// Builds a Stream client header string for API requests. + /// + /// The header follows the format: + /// `{sdk}-{identifier}-v{version} + /// |app={appName} + /// |app_version={appVersion} + /// |os={osName} {osVersion} + /// |device_model={deviceModel}` + /// + /// Only non-null values are included in the header. + String get xStreamClientHeader { + final clientInfo = '$sdkName-$sdkIdentifier-v$sdkVersion'; + + return [ + clientInfo, + if (appName case final name?) 'app=$name', + if (appVersion case final version?) 'app_version=$version', + switch ((osName, osVersion)) { + (final name?, final version?) => 'os=$name $version', + (final name?, null) => 'os=$name', + _ => null, + }, + if (deviceModel case final model?) 'device_model=$model', + ].nonNulls.join('|'); + } +} diff --git a/packages/stream_core/lib/src/api/token_manager.dart b/packages/stream_core/lib/src/api/token_manager.dart new file mode 100644 index 0000000..e48601d --- /dev/null +++ b/packages/stream_core/lib/src/api/token_manager.dart @@ -0,0 +1,50 @@ +import '../../stream_core.dart'; + +/// A function which can be used to request a Stream API token from your +/// own backend server. +/// Function requires a single [userId]. +typedef TokenProvider = Future Function(String userId); + +/// Handles common token operations +class TokenManager { + /// Initialize a new token manager with a static token + TokenManager.static({ + required this.user, + required String token, + }) : _token = token; + + /// Initialize a new token manager with a token provider + TokenManager.provider({ + required this.user, + required TokenProvider provider, + String? token, + }) : _provider = provider, + _token = token; + + /// User to which this TokenManager is configured to + final User user; + + /// User id to which this TokenManager is configured to + String get userId => user.id; + + /// Auth type to which this TokenManager is configured to + String get authType => switch (user.type) { + UserAuthType.regular || UserAuthType.guest => 'jwt', + UserAuthType.anonymous => 'anonymous', + }; + + /// True if it's a static token and can't be refreshed + bool get isStatic => _provider == null; + + String? _token; + + TokenProvider? _provider; + + /// Returns the token refreshing the existing one if [refresh] is true + Future loadToken({bool refresh = false}) async { + if ((refresh && _provider != null) || _token == null) { + _token = await _provider!(userId); + } + return _token!; + } +} diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart new file mode 100644 index 0000000..387dbbd --- /dev/null +++ b/packages/stream_core/lib/src/errors.dart @@ -0,0 +1,2 @@ +export 'errors/client_exception.dart'; +export 'errors/stream_api_error.dart'; \ No newline at end of file diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart index 7247e53..52bf099 100644 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ b/packages/stream_core/lib/src/errors/client_exception.dart @@ -1,19 +1,21 @@ import 'package:equatable/equatable.dart'; -import 'http_api_error.dart'; +import 'stream_api_error.dart'; -class ClientException extends Equatable { +class ClientException extends Equatable implements Exception { final String? message; late final Object? underlyingError; - late final HttpApiError? apiError; + late final StreamApiError? apiError; + final StackTrace? stackTrace; ClientException({ this.message, Object? error, + this.stackTrace, }) { underlyingError = error; - if (error is HttpApiError) { + if (error is StreamApiError) { apiError = error; } } @@ -22,6 +24,18 @@ class ClientException extends Equatable { List get props => [message, underlyingError, apiError]; } +class HttpClientException extends ClientException { + HttpClientException({ + super.message, + super.error, + super.stackTrace, + required this.statusCode, + required this.isRequestCancelledError, + }); + final int? statusCode; + final bool isRequestCancelledError; +} + class WebSocketException extends ClientException { WebSocketException(this.serverException, {super.error}) : super( @@ -31,7 +45,6 @@ class WebSocketException extends ClientException { final WebSocketEngineException? serverException; } - class WebSocketEngineException extends ClientException { static const stopErrorCode = 1000; diff --git a/packages/stream_core/lib/src/errors/http_api_error.dart b/packages/stream_core/lib/src/errors/http_api_error.dart deleted file mode 100644 index 447e849..0000000 --- a/packages/stream_core/lib/src/errors/http_api_error.dart +++ /dev/null @@ -1,31 +0,0 @@ -abstract interface class HttpApiError { - /// Response HTTP status code - int get statusCode; - - /// API error code - int get code; - - /// Additional error-specific information - List get details; - - /// Request duration - String get duration; - - /// Additional error info - Map get exceptionFields; - - /// Message describing an error - String get message; - - /// URL with additional information - String get moreInfo; - - /// Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? get unrecoverable; -} diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart new file mode 100644 index 0000000..e47b4a7 --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -0,0 +1,101 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'stream_api_error.g.dart'; + +@JsonSerializable() +class StreamApiError { + const StreamApiError({ + required this.code, + required this.details, + required this.duration, + this.exceptionFields, + required this.message, + required this.moreInfo, + required this.statusCode, + this.unrecoverable, + }); + + /// API error code + final int code; + + /// Additional error-specific information + final List details; + + /// Request duration + final String duration; + + /// Additional error info + final Map? exceptionFields; + + /// Message describing an error + final String message; + + /// URL with additional information + final String moreInfo; + + /// Response HTTP status code + @JsonKey(name: 'StatusCode') + final int statusCode; + + /// Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it + final bool? unrecoverable; + + Map toJson() => _$StreamApiErrorToJson(this); + + static StreamApiError fromJson(Map json) => + _$StreamApiErrorFromJson(json); + + @override + String toString() { + return 'APIError(' + 'code: $code, ' + 'details: $details, ' + 'duration: $duration, ' + 'exceptionFields: $exceptionFields, ' + 'message: $message, ' + 'moreInfo: $moreInfo, ' + 'statusCode: $statusCode, ' + 'unrecoverable: $unrecoverable, ' + ')'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is StreamApiError && + other.code == code && + other.details == details && + other.duration == duration && + other.exceptionFields == exceptionFields && + other.message == message && + other.moreInfo == moreInfo && + other.statusCode == statusCode && + other.unrecoverable == unrecoverable; + } + + @override + int get hashCode { + return Object.hashAll([ + code, + details, + duration, + exceptionFields, + message, + moreInfo, + statusCode, + unrecoverable, + ]); + } +} + +final _tokenInvalidErrorCodes = _range(40, 42); +final _clientErrorCodes = _range(400, 499); + +extension StreamApiErrorExtension on StreamApiError { + bool get isTokenExpiredError => _tokenInvalidErrorCodes.contains(code); + bool get isClientError => _clientErrorCodes.contains(code); + bool get isRateLimitError => statusCode == 429; +} + +List _range(int from, int to) => + List.generate(to - from + 1, (i) => i + from); diff --git a/packages/stream_core/lib/src/errors/stream_api_error.g.dart b/packages/stream_core/lib/src/errors/stream_api_error.g.dart new file mode 100644 index 0000000..15ba50e --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_api_error.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stream_api_error.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StreamApiError _$StreamApiErrorFromJson(Map json) => + StreamApiError( + code: (json['code'] as num).toInt(), + details: (json['details'] as List) + .map((e) => (e as num).toInt()) + .toList(), + duration: json['duration'] as String, + exceptionFields: (json['exception_fields'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + message: json['message'] as String, + moreInfo: json['more_info'] as String, + statusCode: (json['StatusCode'] as num).toInt(), + unrecoverable: json['unrecoverable'] as bool?, + ); + +Map _$StreamApiErrorToJson(StreamApiError instance) => + { + 'code': instance.code, + 'details': instance.details, + 'duration': instance.duration, + 'exception_fields': instance.exceptionFields, + 'message': instance.message, + 'more_info': instance.moreInfo, + 'StatusCode': instance.statusCode, + 'unrecoverable': instance.unrecoverable, + }; diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart new file mode 100644 index 0000000..4586060 --- /dev/null +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -0,0 +1,159 @@ +import 'package:collection/collection.dart'; + +/// Complete list of errors that are returned by the API +/// together with the description and API code. +enum StreamErrorCode { + // Client errors + + /// Unauthenticated, token not defined + undefinedToken, + + // Bad Request + + /// Wrong data/parameter is sent to the API + inputError, + + /// Duplicate username is sent while enforce_unique_usernames is enabled + duplicateUsername, + + /// Message is too long + messageTooLong, + + /// Event is not supported + eventNotSupported, + + /// The feature is currently disabled + /// on the dashboard (i.e. Reactions & Replies) + channelFeatureNotSupported, + + /// Multiple Levels Reply is not supported + /// the API only supports 1 level deep reply threads + multipleNestling, + + /// Custom Command handler returned an error + customCommandEndpointCall, + + /// App config does not have custom_action_handler_url + customCommandEndpointMissing, + + // Unauthorised + + /// Unauthenticated, problem with authentication + authenticationError, + + /// Unauthenticated, token expired + tokenExpired, + + /// Unauthenticated, token date incorrect + tokenBeforeIssuedAt, + + /// Unauthenticated, token not valid yet + tokenNotValid, + + /// Unauthenticated, token signature invalid + tokenSignatureInvalid, + + /// Access Key invalid + accessKeyError, + + // Forbidden + + /// Unauthorised / forbidden to make request + notAllowed, + + /// App suspended + appSuspended, + + /// User tried to post a message during the cooldown period + cooldownError, + + // Miscellaneous + + /// Resource not found + doesNotExist, + + /// Request timed out + requestTimeout, + + /// Payload too big + payloadTooBig, + + /// Too many requests in a certain time frame + rateLimitError, + + /// Request headers are too large + maximumHeaderSizeExceeded, + + /// Something goes wrong in the system + internalSystemError, + + /// No access to requested channels + noAccessToChannels, +} + +const _errorCodeWithDescription = { + StreamErrorCode.internalSystemError: + MapEntry(-1, 'Something goes wrong in the system'), + StreamErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'), + StreamErrorCode.inputError: + MapEntry(4, 'Wrong data/parameter is sent to the API'), + StreamErrorCode.authenticationError: + MapEntry(5, 'Unauthenticated, problem with authentication'), + StreamErrorCode.duplicateUsername: MapEntry( + 6, + 'Duplicate username is sent while enforce_unique_usernames is enabled', + ), + StreamErrorCode.rateLimitError: + MapEntry(9, 'Too many requests in a certain time frame'), + StreamErrorCode.doesNotExist: MapEntry(16, 'Resource not found'), + StreamErrorCode.notAllowed: + MapEntry(17, 'Unauthorised / forbidden to make request'), + StreamErrorCode.eventNotSupported: MapEntry(18, 'Event is not supported'), + StreamErrorCode.channelFeatureNotSupported: MapEntry( + 19, + 'The feature is currently disabled on the dashboard (i.e. Reactions & Replies)', + ), + StreamErrorCode.messageTooLong: MapEntry(20, 'Message is too long'), + StreamErrorCode.multipleNestling: MapEntry( + 21, + 'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads', + ), + StreamErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'), + StreamErrorCode.requestTimeout: MapEntry(23, 'Request timed out'), + StreamErrorCode.maximumHeaderSizeExceeded: + MapEntry(24, 'Request headers are too large'), + StreamErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'), + StreamErrorCode.tokenNotValid: + MapEntry(41, 'Unauthenticated, token not valid yet'), + StreamErrorCode.tokenBeforeIssuedAt: + MapEntry(42, 'Unauthenticated, token date incorrect'), + StreamErrorCode.tokenSignatureInvalid: + MapEntry(43, 'Unauthenticated, token signature invalid'), + StreamErrorCode.customCommandEndpointMissing: + MapEntry(44, 'App config does not have custom_action_handler_url'), + StreamErrorCode.customCommandEndpointCall: + MapEntry(45, 'Custom Command handler returned an error'), + StreamErrorCode.cooldownError: + MapEntry(60, 'User tried to post a message during the cooldown period'), + StreamErrorCode.noAccessToChannels: + MapEntry(70, 'No access to requested channels'), + StreamErrorCode.appSuspended: MapEntry(99, 'App suspended'), + StreamErrorCode.undefinedToken: + MapEntry(1000, 'Unauthorised, token not defined'), +}; + +const _authenticationErrors = [ + StreamErrorCode.undefinedToken, + StreamErrorCode.authenticationError, + StreamErrorCode.tokenExpired, + StreamErrorCode.tokenBeforeIssuedAt, + StreamErrorCode.tokenNotValid, + StreamErrorCode.tokenSignatureInvalid, + StreamErrorCode.accessKeyError, + StreamErrorCode.noAccessToChannels, +]; + +/// +StreamErrorCode? streamErrorCodeFromCode(int code) => + _errorCodeWithDescription.keys + .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); diff --git a/packages/stream_core/lib/stream_core.dart b/packages/stream_core/lib/stream_core.dart index 1e11211..d58b303 100644 --- a/packages/stream_core/lib/stream_core.dart +++ b/packages/stream_core/lib/stream_core.dart @@ -1,3 +1,5 @@ +export 'src/api.dart'; +export 'src/errors.dart'; export 'src/models.dart'; export 'src/user.dart'; export 'src/utils.dart'; diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index 1d621fd..3e304f3 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -8,6 +8,7 @@ environment: sdk: ^3.6.2 dependencies: + dio: ^5.8.0+1 equatable: ^2.0.7 jose: ^0.3.4 json_annotation: ^4.9.0 From a8ab0f1c0b0544e4fc1084301e9bc1752e03471f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 5 Aug 2025 14:40:34 +0200 Subject: [PATCH 02/11] decrease minimum meta version --- packages/stream_core/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index 3e304f3..bab2493 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: equatable: ^2.0.7 jose: ^0.3.4 json_annotation: ^4.9.0 - meta: ^1.16.0 + meta: ^1.15.0 rxdart: ^0.28.0 web_socket_channel: ^3.0.1 From d9500fcdd755f80d648f1cce06e7f878c759f60d Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 09:59:02 +0200 Subject: [PATCH 03/11] fix easy analysis warnings and add first tests --- .../stream_core_flutter_workflow.yml | 2 +- analysis_options.yaml | 4 + .../stream_core/lib/src/api/http_client.dart | 34 +- .../lib/src/api/http_client_options.dart | 3 - .../api/interceptors/auth_interceptor.dart | 13 +- .../connection_id_interceptor.dart | 2 +- .../api/interceptors/logging_interceptor.dart | 92 +-- packages/stream_core/lib/src/errors.dart | 2 +- .../lib/src/errors/client_exception.dart | 31 +- .../lib/src/errors/stream_api_error.dart | 28 - .../lib/src/errors/stream_error_code.dart | 12 - packages/stream_core/lib/src/logger.dart | 1 + .../lib/src/logger/impl/external_logger.dart | 26 + .../lib/src/logger/impl/file_logger.dart | 315 +++++++++ .../lib/src/logger/impl/tagged_logger.dart | 40 ++ .../stream_core/lib/src/logger/logger.dart | 3 + .../lib/src/logger/stream_log.dart | 149 +++++ .../lib/src/logger/stream_logger.dart | 63 ++ .../lib/src/models/pagination_result.dart | 4 +- .../user/connect_user_details_request.dart | 14 +- .../stream_core/lib/src/utils/result.dart | 5 +- .../stream_core/lib/src/utils/standard.dart | 10 + .../client/connection_recovery_handler.dart | 20 +- .../default_connection_recovery_handler.dart | 2 +- .../web_socket_channel_factory.dart | 2 +- .../web_socket_channel_factory_html.dart | 2 +- .../lib/src/ws/client/web_socket_client.dart | 33 +- .../client/web_socket_connection_state.dart | 8 +- .../lib/src/ws/client/web_socket_engine.dart | 1 + .../ws/client/web_socket_ping_controller.dart | 12 +- .../lib/src/ws/events/sendable_event.dart | 1 + packages/stream_core/pubspec.yaml | 4 + .../api/stream_http_client_options_test.dart | 28 + .../test/api/stream_http_client_test.dart | 604 ++++++++++++++++++ packages/stream_core/test/mocks.dart | 17 + .../stream_core/test/stream_core_test.dart | 12 - 36 files changed, 1415 insertions(+), 184 deletions(-) create mode 100644 packages/stream_core/lib/src/logger.dart create mode 100644 packages/stream_core/lib/src/logger/impl/external_logger.dart create mode 100644 packages/stream_core/lib/src/logger/impl/file_logger.dart create mode 100644 packages/stream_core/lib/src/logger/impl/tagged_logger.dart create mode 100644 packages/stream_core/lib/src/logger/logger.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log.dart create mode 100644 packages/stream_core/lib/src/logger/stream_logger.dart create mode 100644 packages/stream_core/lib/src/utils/standard.dart create mode 100644 packages/stream_core/test/api/stream_http_client_options_test.dart create mode 100644 packages/stream_core/test/api/stream_http_client_test.dart create mode 100644 packages/stream_core/test/mocks.dart delete mode 100644 packages/stream_core/test/stream_core_test.dart diff --git a/.github/workflows/stream_core_flutter_workflow.yml b/.github/workflows/stream_core_flutter_workflow.yml index b92ff2f..8985e9a 100644 --- a/.github/workflows/stream_core_flutter_workflow.yml +++ b/.github/workflows/stream_core_flutter_workflow.yml @@ -46,7 +46,7 @@ jobs: - name: Dart Analyze run: | - melos run analyze + melos run analyze - name: Check formatting run: | diff --git a/analysis_options.yaml b/analysis_options.yaml index b72b730..ba811b0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -89,3 +89,7 @@ linter: # There are situations where we use default in enums on purpose no_default_cases: false + + # Temporarily disabled to find more important issues + public_member_api_docs: false + avoid_print: false diff --git a/packages/stream_core/lib/src/api/http_client.dart b/packages/stream_core/lib/src/api/http_client.dart index 91693cf..2847db9 100644 --- a/packages/stream_core/lib/src/api/http_client.dart +++ b/packages/stream_core/lib/src/api/http_client.dart @@ -1,21 +1,19 @@ import 'dart:async'; import 'package:dio/dio.dart'; -import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import '../../stream_core.dart'; -import 'connection_id_provider.dart'; +import '../logger/stream_logger.dart'; import 'interceptors/additional_headers_interceptor.dart'; import 'interceptors/auth_interceptor.dart'; import 'interceptors/connection_id_interceptor.dart'; import 'interceptors/logging_interceptor.dart'; -import 'stream_core_dio_error.dart'; -import 'system_environment_manager.dart'; -import 'token_manager.dart'; part 'http_client_options.dart'; +const _tag = 'SC:CoreHttpClient'; + /// This is where we configure the base url, headers, /// query parameters and convenient methods for http verbs with error parsing. class CoreHttpClient { @@ -27,7 +25,7 @@ class CoreHttpClient { TokenManager? tokenManager, ConnectionIdProvider? connectionIdProvider, required SystemEnvironmentManager systemEnvironmentManager, - Logger? logger, + StreamLogger? logger, Iterable? interceptors, HttpClientAdapter? httpClientAdapter, }) : _options = options ?? const HttpClientOptions(), @@ -54,17 +52,29 @@ class CoreHttpClient { [ // Add a default logging interceptor if no interceptors are // provided. - if (logger != null && logger.level != Level.OFF) + if (logger != null) LoggingInterceptor( requestHeader: true, logPrint: (step, message) { switch (step) { case InterceptStep.request: - return logger.info(message); + return logger.log( + Priority.info, + _tag, + message.toString, + ); case InterceptStep.response: - return logger.info(message); + return logger.log( + Priority.info, + _tag, + message.toString, + ); case InterceptStep.error: - return logger.severe(message); + return logger.log( + Priority.error, + _tag, + message.toString, + ); } }, ), @@ -89,9 +99,9 @@ class CoreHttpClient { @visibleForTesting final Dio httpClient; - /// Shuts down the [StreamHttpClient]. + /// Shuts down the [CoreHttpClient]. /// - /// If [force] is `false` the [StreamHttpClient] will be kept alive + /// If [force] is `false` the [CoreHttpClient] will be kept alive /// until all active connections are done. If [force] is `true` any active /// connections will be closed to immediately release all resources. These /// closed connections will receive an error event to indicate that the client diff --git a/packages/stream_core/lib/src/api/http_client_options.dart b/packages/stream_core/lib/src/api/http_client_options.dart index 55b298b..a764733 100644 --- a/packages/stream_core/lib/src/api/http_client_options.dart +++ b/packages/stream_core/lib/src/api/http_client_options.dart @@ -25,9 +25,6 @@ class HttpClientOptions { /// Common query parameters. /// /// List values use the default [ListFormat.multiCompatible]. - /// - /// The value can be overridden per parameter by adding a [MultiParam] - /// object wrapping the actual List value and the desired format. final Map queryParameters; /// Http request headers. diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 3144fa6..cda95c4 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import '../../errors.dart'; -import '../../errors/stream_error_code.dart'; import '../http_client.dart'; import '../stream_core_dio_error.dart'; import '../token_manager.dart'; @@ -51,20 +50,20 @@ class AuthInterceptor extends QueuedInterceptor { @override Future onError( - DioException exception, + DioException err, ErrorInterceptorHandler handler, ) async { - final data = exception.response?.data; + final data = err.response?.data; if (data == null || data is! Map) { - return handler.next(exception); + return handler.next(err); } final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - if (_tokenManager.isStatic) return handler.next(exception); + if (_tokenManager.isStatic) return handler.next(err); await _tokenManager.loadToken(refresh: true); try { - final options = exception.requestOptions; + final options = err.requestOptions; // ignore: inference_failure_on_function_invocation final response = await _client.fetch(options); return handler.resolve(response); @@ -72,6 +71,6 @@ class AuthInterceptor extends QueuedInterceptor { return handler.next(exception); } } - return handler.next(exception); + return handler.next(err); } } diff --git a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart index 0f45c21..66b4d8c 100644 --- a/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/connection_id_interceptor.dart @@ -11,7 +11,7 @@ class ConnectionIdInterceptor extends Interceptor { final ConnectionIdProvider connectionIdProvider; @override - void onRequest( + Future onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index 6d6f74c..77a2b73 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -43,7 +43,7 @@ class LoggingInterceptor extends Interceptor { /// Print request header [Options.headers] final bool requestHeader; - /// Print request data [Options.data] + /// Print request data [RequestOptions.data] final bool requestBody; /// Print [Response.data] @@ -119,36 +119,39 @@ class LoggingInterceptor extends Interceptor { } @override - void onError(DioException exception, ErrorInterceptorHandler handler) { + void onError(DioException err, ErrorInterceptorHandler handler) { if (error) { - if (exception.type == DioExceptionType.badResponse) { - final uri = exception.response?.requestOptions.uri; + if (err.type == DioExceptionType.badResponse) { + final uri = err.response?.requestOptions.uri; _printBoxed( _logPrintError, header: - 'DioException ║ Status: ${exception.response?.statusCode} ${exception.response?.statusMessage}', + 'DioException ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}', text: uri.toString(), ); - if (exception.response != null && exception.response?.data != null) { - _logPrintError('╔ ${exception.type.toString()}'); - _printResponse(_logPrintError, exception.response!); + if (err.response != null && err.response?.data != null) { + _logPrintError('╔ ${err.type}'); + _printResponse(_logPrintError, err.response!); } _printLine(_logPrintError, '╚'); _logPrintError(''); } else { _printBoxed( _logPrintError, - header: 'DioException ║ ${exception.type}', - text: exception.message, + header: 'DioException ║ ${err.type}', + text: err.message, ); - _printRequestHeader(_logPrintError, exception.requestOptions); + _printRequestHeader(_logPrintError, err.requestOptions); } } - super.onError(exception, handler); + super.onError(err, handler); } @override - void onResponse(Response response, ResponseInterceptorHandler handler) { + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { _printResponseHeader(_logPrintResponse, response); if (responseHeader) { final responseHeaders = {}; @@ -178,7 +181,10 @@ class LoggingInterceptor extends Interceptor { _printLine(logPrint, '╚'); } - void _printResponse(void Function(Object) logPrint, Response response) { + void _printResponse( + void Function(Object) logPrint, + Response response, + ) { if (response.data != null) { if (response.data is Map) { _printPrettyMap(logPrint, response.data as Map); @@ -192,7 +198,10 @@ class LoggingInterceptor extends Interceptor { } } - void _printResponseHeader(void Function(Object) logPrint, Response response) { + void _printResponseHeader( + void Function(Object) logPrint, + Response response, + ) { final uri = response.requestOptions.uri; final method = response.requestOptions.method; _printBoxed( @@ -234,11 +243,13 @@ class LoggingInterceptor extends Interceptor { void _printBlock(void Function(Object) logPrint, String msg) { final lines = (msg.length / maxWidth).ceil(); for (var i = 0; i < lines; ++i) { - logPrint((i >= 0 ? '║ ' : '') + - msg.substring( - i * maxWidth, - math.min(i * maxWidth + maxWidth, msg.length), - )); + logPrint( + (i >= 0 ? '║ ' : '') + + msg.substring( + i * maxWidth, + math.min(i * maxWidth + maxWidth, msg.length), + ), + ); } } @@ -246,15 +257,15 @@ class LoggingInterceptor extends Interceptor { void _printPrettyMap( void Function(Object) logPrint, - Map data, { + Map data, { int tabs = initialTab, bool isListItem = false, bool isLast = false, }) { - var _tabs = tabs; - final isRoot = _tabs == initialTab; - final initialIndent = _indent(_tabs); - _tabs++; + var indentedTabs = tabs; + final isRoot = indentedTabs == initialTab; + final initialIndent = _indent(indentedTabs); + indentedTabs++; if (isRoot || isListItem) logPrint('║$initialIndent{'); @@ -262,37 +273,37 @@ class LoggingInterceptor extends Interceptor { final isLast = index == data.length - 1; dynamic value = data[key]; if (value is String) { - value = '"${value.toString().replaceAll(RegExp(r'(\r|\n)+'), " ")}"'; + value = '"${value.replaceAll(RegExp(r'(\r|\n)+'), " ")}"'; } if (value is Map) { if (compact) { - logPrint('║${_indent(_tabs)} $key: $value${!isLast ? ',' : ''}'); + logPrint('║${_indent(indentedTabs)} $key: $value${!isLast ? ',' : ''}'); } else { - logPrint('║${_indent(_tabs)} $key: {'); - _printPrettyMap(logPrint, value, tabs: _tabs); + logPrint('║${_indent(indentedTabs)} $key: {'); + _printPrettyMap(logPrint, value, tabs: indentedTabs); } } else if (value is List) { if (compact) { - logPrint('║${_indent(_tabs)} $key: ${value.toString()}'); + logPrint('║${_indent(indentedTabs)} $key: $value'); } else { - logPrint('║${_indent(_tabs)} $key: ['); - _printList(logPrint, value, tabs: _tabs); - logPrint('║${_indent(_tabs)} ]${isLast ? '' : ','}'); + logPrint('║${_indent(indentedTabs)} $key: ['); + _printList(logPrint, value, tabs: indentedTabs); + logPrint('║${_indent(indentedTabs)} ]${isLast ? '' : ','}'); } } else { final msg = value.toString().replaceAll('\n', ''); - final indent = _indent(_tabs); + final indent = _indent(indentedTabs); final linWidth = maxWidth - indent.length; if (msg.length + indent.length > linWidth) { final lines = (msg.length / linWidth).ceil(); for (var i = 0; i < lines; ++i) { - logPrint('║${_indent(_tabs)} ${msg.substring( + logPrint('║${_indent(indentedTabs)} ${msg.substring( i * linWidth, math.min(i * linWidth + linWidth, msg.length), )}'); } } else { - logPrint('║${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}'); + logPrint('║${_indent(indentedTabs)} $key: $msg${!isLast ? ',' : ''}'); } } }); @@ -302,7 +313,7 @@ class LoggingInterceptor extends Interceptor { void _printList( void Function(Object) logPrint, - List list, { + List list, { int tabs = initialTab, }) { list.asMap().forEach((i, dynamic e) { @@ -327,13 +338,14 @@ class LoggingInterceptor extends Interceptor { void _printMapAsTable( void Function(Object) logPrint, - Map? map, { + Map? map, { String? header, }) { if (map == null || map.isEmpty) return; logPrint('╔ $header '); - map.forEach((dynamic key, dynamic value) => - _printKV(logPrint, key.toString(), value)); + map.forEach( + (dynamic key, dynamic value) => _printKV(logPrint, key.toString(), value), + ); _printLine(logPrint, '╚'); } diff --git a/packages/stream_core/lib/src/errors.dart b/packages/stream_core/lib/src/errors.dart index 387dbbd..e74197a 100644 --- a/packages/stream_core/lib/src/errors.dart +++ b/packages/stream_core/lib/src/errors.dart @@ -1,2 +1,2 @@ export 'errors/client_exception.dart'; -export 'errors/stream_api_error.dart'; \ No newline at end of file +export 'errors/stream_api_error.dart'; diff --git a/packages/stream_core/lib/src/errors/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart index 52bf099..a703315 100644 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ b/packages/stream_core/lib/src/errors/client_exception.dart @@ -1,14 +1,6 @@ -import 'package:equatable/equatable.dart'; - import 'stream_api_error.dart'; -class ClientException extends Equatable implements Exception { - final String? message; - - late final Object? underlyingError; - late final StreamApiError? apiError; - final StackTrace? stackTrace; - +class ClientException implements Exception { ClientException({ this.message, Object? error, @@ -17,11 +9,16 @@ class ClientException extends Equatable implements Exception { underlyingError = error; if (error is StreamApiError) { apiError = error; + } else { + apiError = null; } } - @override - List get props => [message, underlyingError, apiError]; + final String? message; + + late final Object? underlyingError; + late final StreamApiError? apiError; + final StackTrace? stackTrace; } class HttpClientException extends ClientException { @@ -46,12 +43,6 @@ class WebSocketException extends ClientException { } class WebSocketEngineException extends ClientException { - static const stopErrorCode = 1000; - - final String reason; - final int code; - final Object? engineError; - WebSocketEngineException({ required this.reason, required this.code, @@ -64,4 +55,10 @@ class WebSocketEngineException extends ClientException { code: 0, engineError: null, ); + + static const stopErrorCode = 1000; + + final String reason; + final int code; + final Object? engineError; } diff --git a/packages/stream_core/lib/src/errors/stream_api_error.dart b/packages/stream_core/lib/src/errors/stream_api_error.dart index e47b4a7..aff2bab 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -58,34 +58,6 @@ class StreamApiError { 'unrecoverable: $unrecoverable, ' ')'; } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is StreamApiError && - other.code == code && - other.details == details && - other.duration == duration && - other.exceptionFields == exceptionFields && - other.message == message && - other.moreInfo == moreInfo && - other.statusCode == statusCode && - other.unrecoverable == unrecoverable; - } - - @override - int get hashCode { - return Object.hashAll([ - code, - details, - duration, - exceptionFields, - message, - moreInfo, - statusCode, - unrecoverable, - ]); - } } final _tokenInvalidErrorCodes = _range(40, 42); diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index 4586060..da26928 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -142,18 +142,6 @@ const _errorCodeWithDescription = { MapEntry(1000, 'Unauthorised, token not defined'), }; -const _authenticationErrors = [ - StreamErrorCode.undefinedToken, - StreamErrorCode.authenticationError, - StreamErrorCode.tokenExpired, - StreamErrorCode.tokenBeforeIssuedAt, - StreamErrorCode.tokenNotValid, - StreamErrorCode.tokenSignatureInvalid, - StreamErrorCode.accessKeyError, - StreamErrorCode.noAccessToChannels, -]; - -/// StreamErrorCode? streamErrorCodeFromCode(int code) => _errorCodeWithDescription.keys .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); diff --git a/packages/stream_core/lib/src/logger.dart b/packages/stream_core/lib/src/logger.dart new file mode 100644 index 0000000..3b79d90 --- /dev/null +++ b/packages/stream_core/lib/src/logger.dart @@ -0,0 +1 @@ +export 'logger/stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/impl/external_logger.dart b/packages/stream_core/lib/src/logger/impl/external_logger.dart new file mode 100644 index 0000000..5552e76 --- /dev/null +++ b/packages/stream_core/lib/src/logger/impl/external_logger.dart @@ -0,0 +1,26 @@ +import '../stream_logger.dart'; + +typedef ExternalFunction = void Function( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, +]); + +class ExternalStreamLogger extends StreamLogger { + const ExternalStreamLogger(this.external); + + final ExternalFunction external; + + @override + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + return external.call(priority, tag, message, error, stk); + } +} diff --git a/packages/stream_core/lib/src/logger/impl/file_logger.dart b/packages/stream_core/lib/src/logger/impl/file_logger.dart new file mode 100644 index 0000000..634deb7 --- /dev/null +++ b/packages/stream_core/lib/src/logger/impl/file_logger.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; + +import '../../utils/standard.dart'; +import '../stream_logger.dart'; + +const String _tag = 'SV:FileLogger'; +const int _defaultSize = 12 * 1024 * 1024; + +const String _shareableFilePrefix = 'stream_log_'; +const String _internalFile0 = 'internal_0.txt'; +const String _internalFile1 = 'internal_1.txt'; + +typedef FileLogSender = Future Function(File); + +final _timeFormat = DateFormat("yyyy-MM-dd HH:mm:ss''SSS"); +final _dateFormat = DateFormat('yyMMddHHmm_ss'); + +class FileStreamLogger extends StreamLogger { + FileStreamLogger( + this.config, { + this.sender, + this.console, + }); + + static final Finalizer _finalizer = + Finalizer((ioSink) async => ioSink.close()); + + final FileLogConfig config; + final FileLogSender? sender; + final StreamLogger? console; + + String get pathSeparator => Platform.pathSeparator; + + late final Directory _filesDir; + late final Directory _tempsDir; + late final File _file0; + late final File _file1; + + File? _currentFile; + IOSink? _currentIO; + + @override + Future log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) async { + await _initIfNeeded(); + await _swapFiles(); + try { + _currentIO?.log(priority, tag, message, error, stk); + } catch (e, stk) { + _logE(() => '[log] failed: $e; $stk'); + } + } + + Future _initIfNeeded() async { + try { + if (_currentFile == null) { + _logD(() => '[initIfNeeded] no args'); + _filesDir = await config.filesDir; + _tempsDir = await config.tempsDir; + _file0 = File('${_filesDir.path}$pathSeparator$_internalFile0') + ..createSync(recursive: true); + _file1 = File('${_filesDir.path}$pathSeparator$_internalFile1') + ..createSync(recursive: true); + final File currentFile; + if (!_file0.existsSync() || !_file1.existsSync()) { + currentFile = _file0; + } else if (_file0 + .lastModifiedSync() + .isAfter(_file1.lastModifiedSync())) { + currentFile = _file0; + } else { + currentFile = _file1; + } + _currentFile = currentFile; + _currentIO = currentFile.openWrite(mode: FileMode.append).also((it) { + _finalizer.attach(this, it, detach: this); + }); + } + // ignore: empty_catches + } catch (e) {} + } + + Future _swapFiles() async { + try { + final curLen = _currentFile?.lengthSync() ?? 0; + final maxLogSize = config.maxLogSize; + if (curLen >= maxLogSize / 2) { + _logD(() => '[swapFiles] no args'); + final currentIO = _currentIO; + _currentIO = null; + await currentIO?.close(); + File currentFile; + if (_currentFile == _file0) { + currentFile = _file1; + } else { + currentFile = _file0; + } + currentFile + ..deleteSync() + ..createSync(recursive: true); + _currentFile = currentFile; + _currentIO = currentFile.openWrite(mode: FileMode.append).also((it) { + _finalizer.attach(this, it, detach: this); + }); + } + } catch (e, stk) { + _logE(() => '[swapFiles] failed: $e; $stk'); + } + } + + Future clear() async { + try { + _logD( + () => '[clear] before; file0: ${_file0.lengthSync()}, ' + 'file1: ${_file1.lengthSync()}', + ); + final currentIO = _currentIO; + _currentIO = null; + await currentIO?.close(); + + _file0 + ..deleteSync() + ..createSync(recursive: true); + _file1 + ..deleteSync() + ..createSync(recursive: true); + + _currentFile = _file0; + _currentIO = _currentFile?.openWrite(mode: FileMode.append).also((it) { + _finalizer.attach(this, it, detach: this); + }); + _logV( + () => '[clear] after; file0: ${_file0.lengthSync()}, ' + 'file1: ${_file1.lengthSync()}', + ); + } catch (e, stk) { + _logE(() => '[clear] failed: $e; $stk'); + rethrow; + } + } + + Future share() async { + _logD(() => '[share] no args'); + final sender = this.sender; + if (sender == null) { + _logW(() => '[share] rejected (sender is not provided)'); + throw const FileLoggerException('Sender is not provided'); + } + try { + final shareable = await prepareShareable(); + _logV(() => '[share] shareable: $shareable(${shareable.existsSync()})'); + return await sender.call(shareable); + } catch (e, stk) { + _logE(() => '[share] failed: $e; $stk'); + rethrow; + } + } + + Future prepareShareable() async { + final filename = '$_shareableFilePrefix' + '${_dateFormat.format(DateTime.now())}.txt'; + final out = File('${_tempsDir.path}$pathSeparator$filename') + ..createSync(recursive: true); + _logD(() => '[prepareShareable] out: $out'); + + IOSink? writer; + try { + writer = out.openWrite(mode: FileMode.append); + writer.writeln(await _buildHeader()); + final filtered = [_file0, _file1] + .where((file) => file.existsSync()) + .sortedBy((file) => file.lastModifiedSync()); + for (final file in filtered) { + if (file.existsSync()) { + await writer.addStream(file.openRead()); + } + } + await writer.flush(); + } catch (e, stk) { + _logE(() => '[prepareShareable] failed: $e; $stk'); + } finally { + await writer?.close(); + } + return out; + } + + Future _buildHeader() async { + final buffer = StringBuffer(); + buffer + ..write('|=============================================================') + ..write('\n') + ..write('|Logs Collected: ') + ..write(_timeFormat.format(DateTime.now())) + ..write('\n') + ..write('|App Version: ') + ..write(await config.appVersion) + ..write('\n') + ..write('|Device Info: '); + + final deviceInfo = await config.deviceInfo; + if (deviceInfo is Map) { + buffer.write('\n'); + deviceInfo.forEach((key, value) { + buffer + ..write('| ') + ..write(key) + ..write(': ') + ..write(value) + ..write('\n'); + }); + } else { + buffer + ..write(deviceInfo) + ..write('\n'); + } + + buffer + ..write('|=============================================================') + ..write('\n') + ..write('|'); + + return buffer.toString(); + } + + void _logV(MessageBuilder message) { + console?.log(Priority.verbose, _tag, message); + } + + void _logD(MessageBuilder message) { + console?.log(Priority.debug, _tag, message); + } + + // ignore: unused_element + void _logI(MessageBuilder message) { + console?.log(Priority.info, _tag, message); + } + + void _logW(MessageBuilder message) { + console?.log(Priority.warning, _tag, message); + } + + void _logE(MessageBuilder message) { + console?.log(Priority.error, _tag, message); + } +} + +abstract class FileLogConfig { + int get maxLogSize => _defaultSize; + + Future get filesDir; + + Future get tempsDir; + + Future get appVersion; + + Future get deviceInfo; +} + +extension on IOSink { + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + final formattedDateTime = _timeFormat.format(DateTime.now()); + final formattedPriority = priority.stringify(); + final formatterPrefix = '$formattedDateTime $formattedPriority [$tag]: '; + + write(formatterPrefix); + writeln(message()); + } +} + +extension on Priority { + String stringify() { + switch (this) { + case Priority.verbose: + return 'V'; + case Priority.debug: + return 'D'; + case Priority.info: + return 'I'; + case Priority.warning: + return 'W'; + case Priority.error: + return 'E'; + case Priority.none: + return 'X'; + } + } +} + +class FileLoggerException implements Exception { + const FileLoggerException([this.message]); + + final dynamic message; + + @override + String toString() { + final message = this.message; + if (message == null) return 'FileLoggerException'; + return 'FileLoggerException: $message'; + } +} diff --git a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart new file mode 100644 index 0000000..5b9bb92 --- /dev/null +++ b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart @@ -0,0 +1,40 @@ +import '../stream_log.dart'; +import '../stream_logger.dart'; + +TaggedLogger taggedLogger({required Tag tag}) { + return TaggedLogger(tag); +} + +class TaggedLogger { + const TaggedLogger(this.tag); + + final Tag tag; + + void v(MessageBuilder message) { + streamLog.v(tag, message); + } + + void d(MessageBuilder message) { + streamLog.d(tag, message); + } + + void i(MessageBuilder message) { + streamLog.i(tag, message); + } + + void w(MessageBuilder message) { + streamLog.w(tag, message); + } + + void e(MessageBuilder message) { + streamLog.e(tag, message); + } + + void log(Priority priority, MessageBuilder message) { + streamLog.log(priority, tag, message); + } + + void logConditional(String? Function(Priority priority) messageBuilder) { + streamLog.logConditional(tag, messageBuilder); + } +} diff --git a/packages/stream_core/lib/src/logger/logger.dart b/packages/stream_core/lib/src/logger/logger.dart new file mode 100644 index 0000000..3f614f8 --- /dev/null +++ b/packages/stream_core/lib/src/logger/logger.dart @@ -0,0 +1,3 @@ +export 'impl/tagged_logger.dart'; +export 'stream_log.dart'; +export 'stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log.dart b/packages/stream_core/lib/src/logger/stream_log.dart new file mode 100644 index 0000000..9862603 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log.dart @@ -0,0 +1,149 @@ +import 'stream_logger.dart'; + +StreamLog get streamLog => StreamLog(); + +class StreamLog { + factory StreamLog() { + return _instance; + } + + StreamLog._(); + + static final StreamLog _instance = StreamLog._(); + + StreamLogger _logger = const SilentStreamLogger(); + IsLoggableValidator _validator = (Priority priority, Tag tag) => false; + Finder _finder = _defaultFinder; + Priority _priority = Priority.none; + + static StreamLog get instance => _instance; + static List excludeTags = []; + static List includeOnlyTags = []; + + set logger(StreamLogger logger) { + _logger = logger; + } + + set priority(Priority priority) { + _priority = priority; + _validator = (logPriority, tag) { + if (excludeTags.isNotEmpty && excludeTags.contains(tag)) { + return false; + } + + if (includeOnlyTags.isNotEmpty && !includeOnlyTags.contains(tag)) { + return false; + } + + return logPriority.index >= priority.index; + }; + } + + set validator(IsLoggableValidator validator) { + _validator = validator; + } + + set finder(Finder finder) { + _finder = finder; + } + + T? find([dynamic criteria]) { + return _finder.call(criteria); + } + + void v(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.verbose, tag)) { + _logger.log(Priority.verbose, tag, message); + } + } + + void d(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.debug, tag)) { + _logger.log(Priority.debug, tag, message); + } + } + + void i(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.info, tag)) { + _logger.log(Priority.info, tag, message); + } + } + + void w(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.warning, tag)) { + _logger.log(Priority.warning, tag, message); + } + } + + void e(Tag tag, MessageBuilder message) { + if (_validator.call(Priority.error, tag)) { + _logger.log(Priority.error, tag, message); + } + } + + void log(Priority priority, Tag tag, MessageBuilder message) { + if (_validator.call(priority, tag)) { + _logger.log(priority, tag, message); + } + } + + void logConditional( + Tag tag, + String? Function(Priority priority) messageBuilder, + ) { + final message = messageBuilder(_priority); + if (message != null && message.isNotEmpty) { + _logger.log( + _priority, + tag, + () => message, + ); + } + } + + static T? _defaultFinder([dynamic criteria]) { + final logger = _instance._logger; + if (logger is T) return logger; + + if (logger is CompositeStreamLogger) { + for (final child in logger.children) { + if (child is T) return child; + } + } + return null; + } +} + +class SilentStreamLogger extends StreamLogger { + const SilentStreamLogger(); + + @override + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + /* no-op */ + } +} + +class CompositeStreamLogger extends StreamLogger { + const CompositeStreamLogger(this.children); + + final List children; + + @override + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]) { + for (final child in children) { + child.log(priority, tag, message, error, stk); + } + } +} diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart new file mode 100644 index 0000000..e0c9f4f --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -0,0 +1,63 @@ +final _priorityEmojiMapper = { + Priority.error: '🚨', + Priority.warning: '⚠️', + Priority.info: 'ℹ️', + Priority.debug: '🔧', + Priority.verbose: '🔍', +}; + +final _priorityNameMapper = { + Priority.error: 'E', + Priority.warning: 'W', + Priority.info: 'I', + Priority.debug: 'D', + Priority.verbose: 'V', +}; + +abstract class StreamLogger { + const StreamLogger(); + + String emoji(Priority priority) => _priorityEmojiMapper[priority] ?? '📣'; + + String name(Priority priority) => _priorityNameMapper[priority] ?? '*'; + + void log( + Priority priority, + String tag, + MessageBuilder message, [ + Object? error, + StackTrace? stk, + ]); +} + +typedef MessageBuilder = String Function(); +typedef Tag = String; +typedef IsLoggableValidator = bool Function(Priority, Tag); +typedef Finder = T? Function([dynamic criteria]); + +enum Priority implements Comparable { + verbose(level: 2), + debug(level: 3), + info(level: 4), + warning(level: 5), + error(level: 6), + none(level: 7); + + const Priority({required this.level}); + + final int level; + + @override + String toString() => name; + + @override + int compareTo(Priority other) => level.compareTo(other.level); + + bool operator <(Priority other) => level < other.level; + + bool operator <=(Priority other) => level <= other.level; + + bool operator >(Priority other) => level > other.level; + + bool operator >=(Priority other) => level >= other.level; +} diff --git a/packages/stream_core/lib/src/models/pagination_result.dart b/packages/stream_core/lib/src/models/pagination_result.dart index b644e1c..bb5781f 100644 --- a/packages/stream_core/lib/src/models/pagination_result.dart +++ b/packages/stream_core/lib/src/models/pagination_result.dart @@ -16,10 +16,10 @@ class PaginationData extends Equatable { this.previous, }); - /// Item id of where to start searching from for next [results] + /// Item id of where to start searching from for next results final String? next; - /// Item id of where to start searching from for previous [results] + /// Item id of where to start searching from for previous results final String? previous; @override diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.dart b/packages/stream_core/lib/src/user/connect_user_details_request.dart index d8d03fe..ca8a071 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.dart @@ -4,13 +4,6 @@ part 'connect_user_details_request.g.dart'; @JsonSerializable() class ConnectUserDetailsRequest { - final String id; - final String? image; - final bool? invisible; - final String? language; - final String? name; - final Map? customData; - const ConnectUserDetailsRequest({ required this.id, this.image, @@ -20,6 +13,13 @@ class ConnectUserDetailsRequest { this.customData, }); + final String id; + final String? image; + final bool? invisible; + final String? language; + final String? name; + final Map? customData; + Map toJson() => _$ConnectUserDetailsRequestToJson(this); static ConnectUserDetailsRequest fromJson(Map json) => _$ConnectUserDetailsRequestFromJson(json); diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index fb421ea..3dcadcd 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -3,13 +3,14 @@ import 'package:equatable/equatable.dart'; enum _ResultType { success, failure } /// A class which encapsulates a successful outcome with a value of type [T] -/// or a failure with [VideoError]. +/// or a [Failure] with error. abstract class Result extends Equatable { const Result._(this._type); const factory Result.success(T value) = Success._; - const factory Result.failure(Object error, [StackTrace stackTrace]) = Failure._; + const factory Result.failure(Object error, [StackTrace stackTrace]) = + Failure._; final _ResultType _type; diff --git a/packages/stream_core/lib/src/utils/standard.dart b/packages/stream_core/lib/src/utils/standard.dart new file mode 100644 index 0000000..c81ace6 --- /dev/null +++ b/packages/stream_core/lib/src/utils/standard.dart @@ -0,0 +1,10 @@ +extension Standard on T { + R let(R Function(T it) convert) { + return convert(this); + } + + T also(void Function(T it) block) { + block(this); + return this; + } +} diff --git a/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart index 191dcba..46bc432 100644 --- a/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart @@ -7,6 +7,7 @@ import '../../utils/network_monitor.dart'; import 'web_socket_client.dart'; import 'web_socket_connection_state.dart'; +// ignore: one_member_abstracts abstract class AutomaticReconnectionPolicy { bool canBeReconnected(); } @@ -20,7 +21,7 @@ class ConnectionRecoveryHandler { final WebSocketClient client; final List policies; - List> subscriptions = []; + final List> subscriptions = []; final RetryStrategy retryStrategy; Timer? _reconnectionTimer; @@ -64,7 +65,8 @@ class ConnectionRecoveryHandler { Future dispose() async { await Future.wait( - subscriptions.map((subscription) => subscription.cancel())); + subscriptions.map((subscription) => subscription.cancel()), + ); subscriptions.clear(); cancelReconnectionTimer(); } @@ -75,10 +77,10 @@ class ConnectionRecoveryHandler { class WebSocketAutomaticReconnectionPolicy implements AutomaticReconnectionPolicy { - WebSocketClient client; - WebSocketAutomaticReconnectionPolicy({required this.client}); + final WebSocketClient client; + @override bool canBeReconnected() { return client.connectionState.isAutomaticReconnectionEnabled; @@ -87,9 +89,8 @@ class WebSocketAutomaticReconnectionPolicy class InternetAvailableReconnectionPolicy implements AutomaticReconnectionPolicy { - NetworkMonitor networkMonitor; - InternetAvailableReconnectionPolicy({required this.networkMonitor}); + final NetworkMonitor networkMonitor; @override bool canBeReconnected() { @@ -123,9 +124,8 @@ abstract class RetryStrategy { } class DefaultRetryStrategy extends RetryStrategy { - static const maximumReconnectionDelayInSeconds = 25; - DefaultRetryStrategy(); + static const maximumReconnectionDelayInSeconds = 25; @override Duration get nextRetryDelay { @@ -134,7 +134,9 @@ class DefaultRetryStrategy extends RetryStrategy { if (consecutiveFailuresCount == 0) return Duration.zero; final maxDelay = math.min( - 0.5 + consecutiveFailuresCount * 2, maximumReconnectionDelayInSeconds); + 0.5 + consecutiveFailuresCount * 2, + maximumReconnectionDelayInSeconds, + ); final minDelay = math.min( math.max(0.25, (consecutiveFailuresCount - 1) * 2), maximumReconnectionDelayInSeconds, diff --git a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart index d65dd67..e1f8d61 100644 --- a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart @@ -63,6 +63,6 @@ mixin WebSocketAwareConnectionRecoveryHandler on ConnectionRecoveryHandler { void subscribeToWebSocketConnectionChanges() { subscriptions.add( - client.connectionStateStream.listen(_websocketConnectionStateChanged)); + client.connectionStateStream.listen(_websocketConnectionStateChanged),); } } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart index 1bbe589..ea9f0cd 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart @@ -5,7 +5,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebSocketChannelFactory { const WebSocketChannelFactory(); Future connect(Uri uri, - {Iterable? protocols}) async { + {Iterable? protocols,}) async { throw UnsupportedError('No implementation of the connect api provided'); } } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart index a765397..0ada6ed 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart @@ -9,7 +9,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebSocketChannelFactory { const WebSocketChannelFactory(); Future connect(Uri uri, - {Iterable? protocols}) async { + {Iterable? protocols,}) async { final completer = Completer(); final webSocket = web.WebSocket(uri.toString()) ..binaryType = BinaryType.list.value; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_client.dart b/packages/stream_core/lib/src/ws/client/web_socket_client.dart index 486e6ac..6b82569 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_client.dart @@ -7,6 +7,22 @@ import 'web_socket_engine.dart'; import 'web_socket_ping_controller.dart'; class WebSocketClient implements WebSocketEngineListener, WebSocketPingClient { + + WebSocketClient({ + required String url, + required this.eventDecoder, + this.pingReguestBuilder, + this.onConnectionEstablished, + this.onConnected, + WebSocketEnvironment environment = const WebSocketEnvironment(), + }) { + engine = environment.createEngine( + url: url, + listener: this, + ); + + pingController = environment.createPingController(client: this); + } late final WebSocketEngine engine; late final WebSocketPingController pingController; final PingReguestBuilder? pingReguestBuilder; @@ -38,23 +54,6 @@ class WebSocketClient implements WebSocketEngineListener, WebSocketPingClient { String? get connectionId => _connectionId; String? _connectionId; - WebSocketClient({ - required String url, - required this.eventDecoder, - this.pingReguestBuilder, - this.onConnectionEstablished, - this.onConnected, - WebSocketEnvironment environment = const WebSocketEnvironment(), - }) { - engine = environment.createEngine( - url: url, - listener: this, - ); - - pingController = environment.createPingController(client: this); - } - - @override void send(SendableEvent message) { engine.send(message: message); } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index 5163f61..a69deb7 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -12,13 +12,13 @@ sealed class WebSocketConnectionState extends Equatable { factory WebSocketConnectionState.connecting() => const Connecting(); factory WebSocketConnectionState.authenticating() => const Authenticating(); factory WebSocketConnectionState.connected( - {HealthCheckInfo? healthCheckInfo}) => + {HealthCheckInfo? healthCheckInfo,}) => Connected(healthCheckInfo: healthCheckInfo); factory WebSocketConnectionState.disconnecting( - {required DisconnectionSource source}) => + {required DisconnectionSource source,}) => Disconnecting(source: source); factory WebSocketConnectionState.disconnected( - {required DisconnectionSource source}) => + {required DisconnectionSource source,}) => Disconnected(source: source); /// Checks if the connection state is connected. @@ -36,7 +36,7 @@ sealed class WebSocketConnectionState extends Equatable { final source = (this as Disconnected).source; return switch (source) { - ServerInitiated serverInitiated => + final ServerInitiated serverInitiated => serverInitiated.error != null, //TODO: Implement UserInitiated() => false, SystemInitiated() => true, diff --git a/packages/stream_core/lib/src/ws/client/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/web_socket_engine.dart index 3cbaa84..7390912 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_engine.dart @@ -35,6 +35,7 @@ class URLSessionWebSocketEngine implements WebSocketEngine { }); /// The URI to connect to. + @override final String url; /// The protocols to use. diff --git a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart index fdebc2a..2d456bc 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart @@ -12,12 +12,6 @@ import 'web_socket_connection_state.dart'; /// /// The controller will automatically resume the ping timer when the connection is resumed. class WebSocketPingController { - final WebSocketPingClient _client; - - final Duration _pingTimeInterval; - final Duration _pongTimeout; - Timer? _pongTimeoutTimer; - Timer? _pingTimer; WebSocketPingController({ required WebSocketPingClient client, @@ -26,6 +20,12 @@ class WebSocketPingController { }) : _client = client, _pingTimeInterval = pingTimeInterval, _pongTimeout = pongTimeout; + final WebSocketPingClient _client; + + final Duration _pingTimeInterval; + final Duration _pongTimeout; + Timer? _pongTimeoutTimer; + Timer? _pingTimer; void connectionStateChanged(WebSocketConnectionState connectionState) { _pongTimeoutTimer?.cancel(); diff --git a/packages/stream_core/lib/src/ws/events/sendable_event.dart b/packages/stream_core/lib/src/ws/events/sendable_event.dart index ab17c41..2115354 100644 --- a/packages/stream_core/lib/src/ws/events/sendable_event.dart +++ b/packages/stream_core/lib/src/ws/events/sendable_event.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +// ignore: one_member_abstracts abstract interface class SendableEvent { /// Serialize the object to `String` or `Uint8List`. Object toSerializedData(); diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index bab2493..d7aab78 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -8,15 +8,19 @@ environment: sdk: ^3.6.2 dependencies: + collection: ^1.19.1 dio: ^5.8.0+1 equatable: ^2.0.7 + intl: ^0.20.2 jose: ^0.3.4 json_annotation: ^4.9.0 meta: ^1.15.0 rxdart: ^0.28.0 + web: ^1.1.1 web_socket_channel: ^3.0.1 dev_dependencies: build_runner: ^2.5.4 json_serializable: ^6.9.5 + mocktail: ^1.0.4 test: ^1.26.2 diff --git a/packages/stream_core/test/api/stream_http_client_options_test.dart b/packages/stream_core/test/api/stream_http_client_options_test.dart new file mode 100644 index 0000000..26cc421 --- /dev/null +++ b/packages/stream_core/test/api/stream_http_client_options_test.dart @@ -0,0 +1,28 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + test('should return the all default set params', () { + const options = HttpClientOptions(); + expect(options.baseUrl, 'https://chat.stream-io-api.com'); + expect(options.connectTimeout, const Duration(seconds: 30)); + expect(options.receiveTimeout, const Duration(seconds: 30)); + expect(options.queryParameters, const {}); + expect(options.headers, const {}); + }); + + test('should override all the default set params', () { + const options = HttpClientOptions( + baseUrl: 'base-url', + connectTimeout: Duration(seconds: 3), + receiveTimeout: Duration(seconds: 3), + headers: {'test': 'test'}, + queryParameters: {'123': '123'}, + ); + expect(options.baseUrl, 'base-url'); + expect(options.connectTimeout, const Duration(seconds: 3)); + expect(options.receiveTimeout, const Duration(seconds: 3)); + expect(options.headers, {'test': 'test'}); + expect(options.queryParameters, {'123': '123'}); + }); +} diff --git a/packages/stream_core/test/api/stream_http_client_test.dart b/packages/stream_core/test/api/stream_http_client_test.dart new file mode 100644 index 0000000..185fc87 --- /dev/null +++ b/packages/stream_core/test/api/stream_http_client_test.dart @@ -0,0 +1,604 @@ +// ignore_for_file: inference_failure_on_function_invocation + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/src/api/interceptors/additional_headers_interceptor.dart'; +import 'package:stream_core/src/api/interceptors/auth_interceptor.dart'; +import 'package:stream_core/src/api/interceptors/connection_id_interceptor.dart'; +import 'package:stream_core/src/api/interceptors/logging_interceptor.dart'; +import 'package:stream_core/src/logger/logger.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +final systemEnvironmentManager = SystemEnvironmentManager( + environment: const SystemEnvironment( + sdkName: 'core', + sdkIdentifier: 'dart', + sdkVersion: '0.1', + ), +); + +const testUser = User( + id: 'user-id', + name: 'test-user', + imageUrl: 'https://example.com/image.png', +); + +StreamApiError _createStreamApiError({ + int code = 0, + List details = const [], + String message = '', + String duration = '', + String moreInfo = '', + int statusCode = 0, +}) { + return StreamApiError( + code: code, + details: details, + duration: duration, + message: message, + moreInfo: moreInfo, + statusCode: statusCode, + ); +} + +void main() { + Response successResponse(String path) => Response( + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + DioException throwableError( + String path, { + ClientException? error, + bool streamDioError = false, + }) { + if (streamDioError) assert(error != null, ''); + final options = RequestOptions(path: path); + final data = StreamApiError( + code: 0, + statusCode: error is HttpClientException ? error.statusCode ?? 0 : 0, + message: error?.message ?? '', + details: [], + duration: '', + moreInfo: '', + ); + DioException? dioError; + if (streamDioError) { + dioError = StreamDioException(exception: error!, requestOptions: options); + } else { + dioError = DioException( + error: error, + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: data.statusCode, + data: data.toJson(), + ), + ); + } + return dioError; + } + + test('UserAgentInterceptor should be added', () { + const apiKey = 'api-key'; + final client = CoreHttpClient(apiKey, + systemEnvironmentManager: systemEnvironmentManager,); + + expect( + client.httpClient.interceptors + .whereType() + .length, + 1,); + }); + + test('AuthInterceptor should be added if tokenManager is provided', () { + const apiKey = 'api-key'; + final client = CoreHttpClient( + apiKey, + tokenManager: TokenManager.static(token: 'token', user: testUser), + systemEnvironmentManager: systemEnvironmentManager, + ); + + expect( + client.httpClient.interceptors.whereType().length, 1,); + }); + + test( + '''connectionIdInterceptor should be added if connectionIdManager is provided''', + () { + const apiKey = 'api-key'; + final client = CoreHttpClient( + apiKey, + connectionIdProvider: () => null, + systemEnvironmentManager: systemEnvironmentManager, + ); + + expect( + client.httpClient.interceptors + .whereType() + .length, + 1, + ); + }, + ); + + group('loggingInterceptor', () { + test('should be added if logger is provided', () { + const apiKey = 'api-key'; + final client = CoreHttpClient( + apiKey, + systemEnvironmentManager: systemEnvironmentManager, + logger: const SilentStreamLogger(), + ); + + expect( + client.httpClient.interceptors.whereType().length, + 1, + ); + }); + + test('should log requests', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = CoreHttpClient( + apiKey, + systemEnvironmentManager: systemEnvironmentManager, + logger: logger, + ); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.log(Priority.info, any(), any())) + .called(greaterThan(0)); + }); + + test('should log error', () async { + const apiKey = 'api-key'; + final logger = MockLogger(); + final client = CoreHttpClient( + apiKey, + systemEnvironmentManager: systemEnvironmentManager, + logger: logger, + ); + + try { + await client.get('path'); + } catch (_) {} + + verify(() => logger.log(Priority.error, any(), any())) + .called(greaterThan(0)); + }); + }); + + test('`.close` should close the dio client', () async { + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + )..close(force: true); + try { + await client.get('path'); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + expect( + (e as ClientException).message, + "The connection errored: Dio can't establish a new connection" + ' after it was closed. This indicates an error which most likely' + ' cannot be solved by the library.', + ); + } + }); + + test('`.get` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); + + const path = 'test-get-api-path'; + when(() => dio.get( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.get(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.get( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.get` should throw an instance of `ClientException`', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-get-api-path'; + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); + + try { + await client.get(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.get( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test('`.post` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); + + const path = 'test-post-api-path'; + when(() => dio.post( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.post(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.post` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-post-api-path'; + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when(() => dio.post( + path, + options: any(named: 'options'), + ),).thenThrow(error); + + try { + await client.post(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.post( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.delete` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-delete-api-path'; + when(() => dio.delete( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.delete(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.delete( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.delete` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-delete-api-path'; + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when(() => dio.delete( + path, + options: any(named: 'options'), + ),).thenThrow(error); + + try { + await client.delete(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.delete( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.patch` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-patch-api-path'; + when(() => dio.patch( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.patch(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.patch( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.patch` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-patch-api-path'; + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when(() => dio.patch( + path, + options: any(named: 'options'), + ),).thenThrow(error); + + try { + await client.patch(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.patch( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.put` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-put-api-path'; + when(() => dio.put( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.put(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.put( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.put` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when(() => dio.put( + path, + options: any(named: 'options'), + ),).thenThrow(error); + + try { + await client.put(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.put( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.postFile` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-delete-api-path'; + final file = MultipartFile.fromBytes([]); + + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.postFile(path, file); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.postFile` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-post-file-api-path'; + final file = MultipartFile.fromBytes([]); + + final error = throwableError( + path, + error: ClientException(error: _createStreamApiError()), + ); + when(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ),).thenThrow(error); + + try { + await client.postFile(path, file); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); + + test('`.request` should return response successfully', () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-request-api-path'; + when(() => dio.request( + path, + options: any(named: 'options'), + ),).thenAnswer((_) async => successResponse(path)); + + final res = await client.request(path); + + expect(res, isNotNull); + expect(res.statusCode, 200); + expect(res.requestOptions.path, path); + + verify(() => dio.request( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }); + + test( + '`.request` should throw an instance of `ClientException`', + () async { + final dio = MockDio(); + final client = CoreHttpClient('api-key', + systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + + const path = 'test-put-api-path'; + final error = throwableError( + path, + streamDioError: true, + error: ClientException(error: _createStreamApiError()), + ); + when( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); + + try { + await client.request(path); + fail('Expected an exception to be thrown'); + } catch (e) { + expect(e, isA()); + } + + verify(() => dio.request( + path, + options: any(named: 'options'), + ),).called(1); + verifyNoMoreInteractions(dio); + }, + ); +} diff --git a/packages/stream_core/test/mocks.dart b/packages/stream_core/test/mocks.dart new file mode 100644 index 0000000..9c7aab4 --- /dev/null +++ b/packages/stream_core/test/mocks.dart @@ -0,0 +1,17 @@ +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/src/logger.dart'; + +class MockLogger extends Mock implements StreamLogger {} + +class MockDio extends Mock implements Dio { + BaseOptions? _options; + + @override + BaseOptions get options => _options ??= BaseOptions(); + + Interceptors? _interceptors; + + @override + Interceptors get interceptors => _interceptors ??= Interceptors(); +} diff --git a/packages/stream_core/test/stream_core_test.dart b/packages/stream_core/test/stream_core_test.dart deleted file mode 100644 index de6b66e..0000000 --- a/packages/stream_core/test/stream_core_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:test/test.dart'; - -import 'package:stream_core/stream_core.dart'; - -void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); - }); -} From c780e6ae1f28c6ecf902ef40110ef18a9b07c4ee Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:07:18 +0200 Subject: [PATCH 04/11] disable changelog requirement --- .github/workflows/pr_title.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml index 9328141..9ac43e4 100644 --- a/.github/workflows/pr_title.yml +++ b/.github/workflows/pr_title.yml @@ -26,6 +26,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} semantic_changelog_update: + if: ${{ false }} # TODO: Enable after the first release needs: conventional_pr_title # Trigger after the [conventional_pr_title] completes runs-on: ubuntu-latest steps: From 8c59a2d416c7812f45f5e034836e950fa715f40a Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:14:31 +0200 Subject: [PATCH 05/11] downgrade build_runner --- melos.yaml | 2 ++ packages/stream_core/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/melos.yaml b/melos.yaml index 5d6c339..c9112c3 100644 --- a/melos.yaml +++ b/melos.yaml @@ -14,6 +14,8 @@ command: sdk: ^3.6.2 # We are not using carat '^' syntax here because flutter don't follow semantic versioning. flutter: ">=3.27.4" + dev_dependencies: + build_runner: ^2.4.15 scripts: postclean: diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index d7aab78..18dfa4d 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: web_socket_channel: ^3.0.1 dev_dependencies: - build_runner: ^2.5.4 + build_runner: ^2.4.15 json_serializable: ^6.9.5 mocktail: ^1.0.4 test: ^1.26.2 From 00a8a4b554697e992e2d32cec8cfbefeeb3533c7 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:25:36 +0200 Subject: [PATCH 06/11] Formatting --- .../api/interceptors/logging_interceptor.dart | 4 +- .../default_connection_recovery_handler.dart | 3 +- .../web_socket_channel_factory.dart | 6 +- .../web_socket_channel_factory_html.dart | 6 +- .../lib/src/ws/client/web_socket_client.dart | 1 - .../client/web_socket_connection_state.dart | 15 +- .../ws/client/web_socket_ping_controller.dart | 1 - .../test/api/stream_http_client_test.dart | 371 +++++++++++------- 8 files changed, 254 insertions(+), 153 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index 77a2b73..61694bd 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -277,7 +277,9 @@ class LoggingInterceptor extends Interceptor { } if (value is Map) { if (compact) { - logPrint('║${_indent(indentedTabs)} $key: $value${!isLast ? ',' : ''}'); + logPrint( + '║${_indent(indentedTabs)} $key: $value${!isLast ? ',' : ''}', + ); } else { logPrint('║${_indent(indentedTabs)} $key: {'); _printPrettyMap(logPrint, value, tabs: indentedTabs); diff --git a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart index e1f8d61..b3e07db 100644 --- a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart @@ -63,6 +63,7 @@ mixin WebSocketAwareConnectionRecoveryHandler on ConnectionRecoveryHandler { void subscribeToWebSocketConnectionChanges() { subscriptions.add( - client.connectionStateStream.listen(_websocketConnectionStateChanged),); + client.connectionStateStream.listen(_websocketConnectionStateChanged), + ); } } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart index ea9f0cd..001212f 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart @@ -4,8 +4,10 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebSocketChannelFactory { const WebSocketChannelFactory(); - Future connect(Uri uri, - {Iterable? protocols,}) async { + Future connect( + Uri uri, { + Iterable? protocols, + }) async { throw UnsupportedError('No implementation of the connect api provided'); } } diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart index 0ada6ed..fa2071d 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart @@ -8,8 +8,10 @@ import 'package:web_socket_channel/web_socket_channel.dart'; class WebSocketChannelFactory { const WebSocketChannelFactory(); - Future connect(Uri uri, - {Iterable? protocols,}) async { + Future connect( + Uri uri, { + Iterable? protocols, + }) async { final completer = Completer(); final webSocket = web.WebSocket(uri.toString()) ..binaryType = BinaryType.list.value; diff --git a/packages/stream_core/lib/src/ws/client/web_socket_client.dart b/packages/stream_core/lib/src/ws/client/web_socket_client.dart index 6b82569..4f1e4b5 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_client.dart @@ -7,7 +7,6 @@ import 'web_socket_engine.dart'; import 'web_socket_ping_controller.dart'; class WebSocketClient implements WebSocketEngineListener, WebSocketPingClient { - WebSocketClient({ required String url, required this.eventDecoder, diff --git a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart index a69deb7..6623f3f 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_connection_state.dart @@ -11,14 +11,17 @@ sealed class WebSocketConnectionState extends Equatable { factory WebSocketConnectionState.initialized() => const Initialized(); factory WebSocketConnectionState.connecting() => const Connecting(); factory WebSocketConnectionState.authenticating() => const Authenticating(); - factory WebSocketConnectionState.connected( - {HealthCheckInfo? healthCheckInfo,}) => + factory WebSocketConnectionState.connected({ + HealthCheckInfo? healthCheckInfo, + }) => Connected(healthCheckInfo: healthCheckInfo); - factory WebSocketConnectionState.disconnecting( - {required DisconnectionSource source,}) => + factory WebSocketConnectionState.disconnecting({ + required DisconnectionSource source, + }) => Disconnecting(source: source); - factory WebSocketConnectionState.disconnected( - {required DisconnectionSource source,}) => + factory WebSocketConnectionState.disconnected({ + required DisconnectionSource source, + }) => Disconnected(source: source); /// Checks if the connection state is connected. diff --git a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart index 2d456bc..dccc0c5 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart @@ -12,7 +12,6 @@ import 'web_socket_connection_state.dart'; /// /// The controller will automatically resume the ping timer when the connection is resumed. class WebSocketPingController { - WebSocketPingController({ required WebSocketPingClient client, Duration pingTimeInterval = const Duration(seconds: 25), diff --git a/packages/stream_core/test/api/stream_http_client_test.dart b/packages/stream_core/test/api/stream_http_client_test.dart index 185fc87..561612c 100644 --- a/packages/stream_core/test/api/stream_http_client_test.dart +++ b/packages/stream_core/test/api/stream_http_client_test.dart @@ -84,14 +84,17 @@ void main() { test('UserAgentInterceptor should be added', () { const apiKey = 'api-key'; - final client = CoreHttpClient(apiKey, - systemEnvironmentManager: systemEnvironmentManager,); + final client = CoreHttpClient( + apiKey, + systemEnvironmentManager: systemEnvironmentManager, + ); expect( - client.httpClient.interceptors - .whereType() - .length, - 1,); + client.httpClient.interceptors + .whereType() + .length, + 1, + ); }); test('AuthInterceptor should be added if tokenManager is provided', () { @@ -103,7 +106,9 @@ void main() { ); expect( - client.httpClient.interceptors.whereType().length, 1,); + client.httpClient.interceptors.whereType().length, + 1, + ); }); test( @@ -203,10 +208,12 @@ void main() { ); const path = 'test-get-api-path'; - when(() => dio.get( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.get(path); @@ -214,17 +221,22 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.get( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); test('`.get` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-get-api-path'; final error = throwableError( @@ -245,10 +257,12 @@ void main() { expect(e, isA()); } - verify(() => dio.get( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -261,10 +275,12 @@ void main() { ); const path = 'test-post-api-path'; - when(() => dio.post( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.post(path); @@ -272,10 +288,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.post( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -283,18 +301,23 @@ void main() { '`.post` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-post-api-path'; final error = throwableError( path, error: ClientException(error: _createStreamApiError()), ); - when(() => dio.post( - path, - options: any(named: 'options'), - ),).thenThrow(error); + when( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.post(path); @@ -303,24 +326,31 @@ void main() { expect(e, isA()); } - verify(() => dio.post( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); test('`.delete` should return response successfully', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-delete-api-path'; - when(() => dio.delete( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.delete(path); @@ -328,10 +358,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.delete( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -339,18 +371,23 @@ void main() { '`.delete` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-delete-api-path'; final error = throwableError( path, error: ClientException(error: _createStreamApiError()), ); - when(() => dio.delete( - path, - options: any(named: 'options'), - ),).thenThrow(error); + when( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.delete(path); @@ -359,24 +396,31 @@ void main() { expect(e, isA()); } - verify(() => dio.delete( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); test('`.patch` should return response successfully', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-patch-api-path'; - when(() => dio.patch( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.patch(path); @@ -384,10 +428,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.patch( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -395,18 +441,23 @@ void main() { '`.patch` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-patch-api-path'; final error = throwableError( path, error: ClientException(error: _createStreamApiError()), ); - when(() => dio.patch( - path, - options: any(named: 'options'), - ),).thenThrow(error); + when( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.patch(path); @@ -415,24 +466,31 @@ void main() { expect(e, isA()); } - verify(() => dio.patch( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); test('`.put` should return response successfully', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-put-api-path'; - when(() => dio.put( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.put(path); @@ -440,10 +498,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.put( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -451,18 +511,23 @@ void main() { '`.put` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-put-api-path'; final error = throwableError( path, error: ClientException(error: _createStreamApiError()), ); - when(() => dio.put( - path, - options: any(named: 'options'), - ),).thenThrow(error); + when( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.put(path); @@ -471,27 +536,34 @@ void main() { expect(e, isA()); } - verify(() => dio.put( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); test('`.postFile` should return response successfully', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-delete-api-path'; final file = MultipartFile.fromBytes([]); - when(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.postFile(path, file); @@ -499,11 +571,13 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -511,8 +585,11 @@ void main() { '`.postFile` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-post-file-api-path'; final file = MultipartFile.fromBytes([]); @@ -521,11 +598,13 @@ void main() { path, error: ClientException(error: _createStreamApiError()), ); - when(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - ),).thenThrow(error); + when( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.postFile(path, file); @@ -534,25 +613,32 @@ void main() { expect(e, isA()); } - verify(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); test('`.request` should return response successfully', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-request-api-path'; - when(() => dio.request( - path, - options: any(named: 'options'), - ),).thenAnswer((_) async => successResponse(path)); + when( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.request(path); @@ -560,10 +646,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.request( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -571,8 +659,11 @@ void main() { '`.request` should throw an instance of `ClientException`', () async { final dio = MockDio(); - final client = CoreHttpClient('api-key', - systemEnvironmentManager: systemEnvironmentManager, dio: dio,); + final client = CoreHttpClient( + 'api-key', + systemEnvironmentManager: systemEnvironmentManager, + dio: dio, + ); const path = 'test-put-api-path'; final error = throwableError( @@ -594,10 +685,12 @@ void main() { expect(e, isA()); } - verify(() => dio.request( - path, - options: any(named: 'options'), - ),).called(1); + verify( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); From 47b0065398171565b773be5038dcaf909038ab07 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:36:57 +0200 Subject: [PATCH 07/11] decrease pana requirements --- .github/workflows/pana.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pana.yml b/.github/workflows/pana.yml index ae34137..e6f8aea 100644 --- a/.github/workflows/pana.yml +++ b/.github/workflows/pana.yml @@ -22,4 +22,4 @@ jobs: uses: ./.github/actions/pana with: working_directory: packages/stream_core - min_score: 140 # Missing 10 points for no example and 10 points for license + min_score: 125 # Missing 10 points for no example, 10 points for license, and 10 points for `publish_to` in pubspec. From c0781f2f85043e6a8858ce5ffd2b201b8a045957 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:43:58 +0200 Subject: [PATCH 08/11] add codecov settings --- codecov.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..774e6b4 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: # default is the status check's name, not default settings + target: auto + threshold: 5 + base: auto + patch: + default: + target: 80% \ No newline at end of file From d9a0100983e89d47200a9b1a68ca516b825afee6 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 10:50:32 +0200 Subject: [PATCH 09/11] Improve typing of http calls --- .../stream_core/lib/src/api/http_client.dart | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/stream_core/lib/src/api/http_client.dart b/packages/stream_core/lib/src/api/http_client.dart index 2847db9..60d0858 100644 --- a/packages/stream_core/lib/src/api/http_client.dart +++ b/packages/stream_core/lib/src/api/http_client.dart @@ -117,7 +117,7 @@ class CoreHttpClient { } /// Handy method to make http GET request with error parsing. - Future> get( + Future> get( String path, { Map? queryParameters, Map? headers, @@ -125,7 +125,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { try { - final response = await httpClient.get( + final response = await httpClient.get( path, queryParameters: queryParameters, options: Options(headers: headers), @@ -133,13 +133,13 @@ class CoreHttpClient { cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to make http POST request with error parsing. - Future> post( + Future> post( String path, { Object? data, Map? queryParameters, @@ -149,7 +149,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { try { - final response = await httpClient.post( + final response = await httpClient.post( path, queryParameters: queryParameters, data: data, @@ -159,33 +159,33 @@ class CoreHttpClient { cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to make http DELETE request with error parsing. - Future> delete( + Future> delete( String path, { Map? queryParameters, Map? headers, CancelToken? cancelToken, }) async { try { - final response = await httpClient.delete( + final response = await httpClient.delete( path, queryParameters: queryParameters, options: Options(headers: headers), cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to make http PATCH request with error parsing. - Future> patch( + Future> patch( String path, { Object? data, Map? queryParameters, @@ -195,7 +195,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { try { - final response = await httpClient.patch( + final response = await httpClient.patch( path, queryParameters: queryParameters, data: data, @@ -205,13 +205,13 @@ class CoreHttpClient { cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to make http PUT request with error parsing. - Future> put( + Future> put( String path, { Object? data, Map? queryParameters, @@ -221,7 +221,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { try { - final response = await httpClient.put( + final response = await httpClient.put( path, queryParameters: queryParameters, data: data, @@ -231,13 +231,13 @@ class CoreHttpClient { cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to post files with error parsing. - Future> postFile( + Future> postFile( String path, MultipartFile file, { Map? queryParameters, @@ -247,7 +247,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { final formData = FormData.fromMap({'file': file}); - final response = await post( + final response = await post( path, data: formData, queryParameters: queryParameters, @@ -260,7 +260,7 @@ class CoreHttpClient { } /// Handy method to make generic http request with error parsing. - Future> request( + Future> request( String path, { Object? data, Map? queryParameters, @@ -270,7 +270,7 @@ class CoreHttpClient { CancelToken? cancelToken, }) async { try { - final response = await httpClient.request( + final response = await httpClient.request( path, data: data, queryParameters: queryParameters, @@ -280,21 +280,21 @@ class CoreHttpClient { cancelToken: cancelToken, ); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } /// Handy method to make http requests from [RequestOptions] /// with error parsing. - Future> fetch( + Future> fetch( RequestOptions requestOptions, ) async { try { - final response = await httpClient.fetch(requestOptions); + final response = await httpClient.fetch(requestOptions); return response; - } on DioException catch (error) { - throw _parseError(error); + } on DioException catch (error, stackTrace) { + throw Error.throwWithStackTrace(_parseError(error), stackTrace); } } } From 878bf9349b1fdaf2e7845c6672965fc39dde6bf1 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 11:19:15 +0200 Subject: [PATCH 10/11] Add tests for interceptors --- .../lib/src/errors/stream_error_code.dart | 6 + .../additional_headers_interceptor_test.dart | 51 ++++ .../interceptor/auth_interceptor_test.dart | 239 ++++++++++++++++++ .../connection_id_interceptor_test.dart | 57 +++++ .../test/api/stream_http_client_test.dart | 40 +-- packages/stream_core/test/mocks.dart | 31 +++ 6 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart create mode 100644 packages/stream_core/test/api/interceptor/auth_interceptor_test.dart create mode 100644 packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart index da26928..4415acd 100644 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ b/packages/stream_core/lib/src/errors/stream_error_code.dart @@ -145,3 +145,9 @@ const _errorCodeWithDescription = { StreamErrorCode? streamErrorCodeFromCode(int code) => _errorCodeWithDescription.keys .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); + +int codeFromStreamErrorCode(StreamErrorCode errorCode) => + _errorCodeWithDescription[errorCode]!.key; + +String messageFromStreamErrorCode(StreamErrorCode errorCode) => + _errorCodeWithDescription[errorCode]!.value; diff --git a/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart b/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart new file mode 100644 index 0000000..1f048c4 --- /dev/null +++ b/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart @@ -0,0 +1,51 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'package:dio/dio.dart'; +import 'package:stream_core/src/api/interceptors/additional_headers_interceptor.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + group('AdditionalHeadersInterceptor tests', () { + group('with SystemEnvironmentManager', () { + late AdditionalHeadersInterceptor additionalHeadersInterceptor; + + setUp(() { + additionalHeadersInterceptor = AdditionalHeadersInterceptor( + FakeSystemEnvironmentManager( + environment: systemEnvironmentManager.environment, + ), + ); + }); + + test('should add user agent header when available', () async { + AdditionalHeadersInterceptor.additionalHeaders = { + 'test-header': 'test-value', + }; + addTearDown(() => AdditionalHeadersInterceptor.additionalHeaders = {}); + + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + await additionalHeadersInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updateHeaders = updatedOptions.headers; + + expect(updateHeaders.containsKey('test-header'), isTrue); + expect(updateHeaders['test-header'], 'test-value'); + expect(updateHeaders.containsKey('X-Stream-Client'), isTrue); + expect(updateHeaders['X-Stream-Client'], 'test-user-agent'); + }); + }); + }); +} + +class FakeSystemEnvironmentManager extends SystemEnvironmentManager { + FakeSystemEnvironmentManager({required super.environment}); + + @override + String get userAgent => 'test-user-agent'; +} diff --git a/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart new file mode 100644 index 0000000..3fd096f --- /dev/null +++ b/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart @@ -0,0 +1,239 @@ +// ignore_for_file: invalid_use_of_protected_member, unawaited_futures + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/src/api/interceptors/auth_interceptor.dart'; +import 'package:stream_core/src/errors/stream_error_code.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + late CoreHttpClient client; + late TokenManager tokenManager; + late AuthInterceptor authInterceptor; + + setUp(() { + client = MockHttpClient(); + tokenManager = MockTokenManager(); + authInterceptor = AuthInterceptor(client, tokenManager); + }); + + test( + '`onRequest` should add userId, authToken, authType in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final headers = options.headers; + final queryParams = options.queryParameters; + expect(headers.containsKey('Authorization'), isFalse); + expect(headers.containsKey('stream-auth-type'), isFalse); + expect(queryParams.containsKey('user_id'), isFalse); + + const token = 'test-user-token'; + const userId = 'test-user-id'; + const user = User(id: userId, name: 'test-user-name'); + when(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .thenAnswer((_) async => token); + + when(() => tokenManager.userId).thenReturn(user.id); + when(() => tokenManager.authType).thenReturn('jwt'); + + authInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updateHeaders = updatedOptions.headers; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updateHeaders.containsKey('Authorization'), isTrue); + expect(updateHeaders['Authorization'], token); + expect(updateHeaders.containsKey('stream-auth-type'), isTrue); + expect(updateHeaders['stream-auth-type'], 'jwt'); + expect(updatedQueryParams.containsKey('user_id'), isTrue); + expect(updatedQueryParams['user_id'], userId); + + verify(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) + .called(1); + verify(() => tokenManager.userId).called(1); + verify(() => tokenManager.authType).called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onRequest` should reject with error if `tokenManager.loadToken` throws', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + authInterceptor.onRequest(options, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + final clientException = (error as StreamDioException).error; + expect(clientException, isA()); + expect( + (clientException! as ClientException).message, + 'Failed to load auth token', + ); + } + }, + ); + + test('`onError` should retry the request with refreshed token', () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = StreamErrorCode.tokenExpired; + final errorResponse = createStreamApiError( + code: codeFromStreamErrorCode(code), + message: messageFromStreamErrorCode(code), + ); + + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioException(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + const token = 'test-user-token'; + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.fetch(options)).thenAnswer( + (_) async => Response( + requestOptions: options, + statusCode: 200, + ), + ); + + authInterceptor.onError(err, handler); + + final res = await handler.future; + + var data = res.data; + expect(data, isA>()); + data = data as Response; + expect(data, isNotNull); + expect(data.statusCode, 200); + expect(data.requestOptions.path, path); + + verify(() => tokenManager.isStatic).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.fetch(options)).called(1); + verifyNoMoreInteractions(client); + }); + + test( + '`onError` should reject with error if retried request throws', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = StreamErrorCode.tokenExpired; + final errorResponse = createStreamApiError( + code: codeFromStreamErrorCode(code), + message: messageFromStreamErrorCode(code), + ); + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioException(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(false); + + const token = 'test-user-token'; + when(() => tokenManager.loadToken(refresh: true)) + .thenAnswer((_) async => token); + + when(() => client.fetch(options)).thenThrow(err); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + + verify(() => tokenManager.isStatic).called(1); + + verify(() => tokenManager.loadToken(refresh: true)).called(1); + verifyNoMoreInteractions(tokenManager); + + verify(() => client.fetch(options)).called(1); + verifyNoMoreInteractions(client); + }, + ); + + test( + '`onError` should reject with error if `tokenManager.isStatic` is true', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + const code = StreamErrorCode.tokenExpired; + final errorResponse = createStreamApiError( + code: codeFromStreamErrorCode(code), + message: messageFromStreamErrorCode(code), + ); + final response = Response( + requestOptions: options, + data: errorResponse.toJson(), + ); + final err = DioException(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + when(() => tokenManager.isStatic).thenReturn(true); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + final response = (error as DioException).toClientException(); + expect(response.apiError?.code, codeFromStreamErrorCode(code)); + } + + verify(() => tokenManager.isStatic).called(1); + verifyNoMoreInteractions(tokenManager); + }, + ); + + test( + '`onError` should reject with error if error is not a `tokenExpired error`', + () async { + const path = 'test-request-path'; + final options = RequestOptions(path: path); + final response = Response(requestOptions: options); + final err = DioException(requestOptions: options, response: response); + final handler = ErrorInterceptorHandler(); + + authInterceptor.onError(err, handler); + + try { + await handler.future; + } catch (e) { + // need to cast it as the type is private in dio + final error = (e as dynamic).data; + expect(error, isA()); + } + }, + ); +} diff --git a/packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart b/packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart new file mode 100644 index 0000000..ad2a667 --- /dev/null +++ b/packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart @@ -0,0 +1,57 @@ +// ignore_for_file: invalid_use_of_protected_member, unawaited_futures + +import 'package:dio/dio.dart'; +import 'package:stream_core/src/api/interceptors/connection_id_interceptor.dart'; +import 'package:test/test.dart'; + +void main() { + late ConnectionIdInterceptor connectionIdInterceptor; + + String? connectionId; + String? connectionIdProvider() { + return connectionId; + } + + setUp(() { + connectionIdInterceptor = ConnectionIdInterceptor(connectionIdProvider); + }); + + test( + '`onRequest` should add connectionId in the request', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + + connectionId = 'test-connection-id'; + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isTrue); + }, + ); + + test( + '`onRequest` should not add connectionId if `hasConnectionId` is false', + () async { + final options = RequestOptions(path: 'test-path'); + final handler = RequestInterceptorHandler(); + + final queryParams = options.queryParameters; + expect(queryParams.containsKey('connection_id'), isFalse); + connectionId = null; + + connectionIdInterceptor.onRequest(options, handler); + + final updatedOptions = (await handler.future).data as RequestOptions; + final updatedQueryParams = updatedOptions.queryParameters; + + expect(updatedQueryParams.containsKey('connection_id'), isFalse); + }, + ); +} diff --git a/packages/stream_core/test/api/stream_http_client_test.dart b/packages/stream_core/test/api/stream_http_client_test.dart index 561612c..04f097d 100644 --- a/packages/stream_core/test/api/stream_http_client_test.dart +++ b/packages/stream_core/test/api/stream_http_client_test.dart @@ -12,38 +12,12 @@ import 'package:test/test.dart'; import '../mocks.dart'; -final systemEnvironmentManager = SystemEnvironmentManager( - environment: const SystemEnvironment( - sdkName: 'core', - sdkIdentifier: 'dart', - sdkVersion: '0.1', - ), -); - const testUser = User( id: 'user-id', name: 'test-user', imageUrl: 'https://example.com/image.png', ); -StreamApiError _createStreamApiError({ - int code = 0, - List details = const [], - String message = '', - String duration = '', - String moreInfo = '', - int statusCode = 0, -}) { - return StreamApiError( - code: code, - details: details, - duration: duration, - message: message, - moreInfo: moreInfo, - statusCode: statusCode, - ); -} - void main() { Response successResponse(String path) => Response( requestOptions: RequestOptions(path: path), @@ -241,7 +215,7 @@ void main() { const path = 'test-get-api-path'; final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.get( @@ -310,7 +284,7 @@ void main() { const path = 'test-post-api-path'; final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.post( @@ -380,7 +354,7 @@ void main() { const path = 'test-delete-api-path'; final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.delete( @@ -450,7 +424,7 @@ void main() { const path = 'test-patch-api-path'; final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.patch( @@ -520,7 +494,7 @@ void main() { const path = 'test-put-api-path'; final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.put( @@ -596,7 +570,7 @@ void main() { final error = throwableError( path, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.post( @@ -669,7 +643,7 @@ void main() { final error = throwableError( path, streamDioError: true, - error: ClientException(error: _createStreamApiError()), + error: ClientException(error: createStreamApiError()), ); when( () => dio.request( diff --git a/packages/stream_core/test/mocks.dart b/packages/stream_core/test/mocks.dart index 9c7aab4..a8dac87 100644 --- a/packages/stream_core/test/mocks.dart +++ b/packages/stream_core/test/mocks.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_core/src/logger.dart'; +import 'package:stream_core/stream_core.dart'; class MockLogger extends Mock implements StreamLogger {} @@ -15,3 +16,33 @@ class MockDio extends Mock implements Dio { @override Interceptors get interceptors => _interceptors ??= Interceptors(); } + +class MockHttpClient extends Mock implements CoreHttpClient {} + +class MockTokenManager extends Mock implements TokenManager {} + +final systemEnvironmentManager = SystemEnvironmentManager( + environment: const SystemEnvironment( + sdkName: 'core', + sdkIdentifier: 'dart', + sdkVersion: '0.1', + ), +); + +StreamApiError createStreamApiError({ + int code = 0, + List details = const [], + String message = '', + String duration = '', + String moreInfo = '', + int statusCode = 0, +}) { + return StreamApiError( + code: code, + details: details, + duration: duration, + message: message, + moreInfo: moreInfo, + statusCode: statusCode, + ); +} From 3dc2b2bcdccef0ae5bdfddea8c8e2e14963558a0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 6 Aug 2025 11:56:29 +0200 Subject: [PATCH 11/11] Add test for network recovery --- packages/stream_core/lib/src/ws.dart | 2 +- .../default_connection_recovery_handler.dart | 2 +- packages/stream_core/test/mocks.dart | 2 + .../ws/connection_recovery_handler_test.dart | 118 ++++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/stream_core/test/ws/connection_recovery_handler_test.dart diff --git a/packages/stream_core/lib/src/ws.dart b/packages/stream_core/lib/src/ws.dart index bc8ae77..49d6578 100644 --- a/packages/stream_core/lib/src/ws.dart +++ b/packages/stream_core/lib/src/ws.dart @@ -1,5 +1,5 @@ export 'ws/client/connection_recovery_handler.dart'; export 'ws/client/default_connection_recovery_handler.dart'; -export 'ws/client/web_socket_client.dart' show WebSocketClient; +export 'ws/client/web_socket_client.dart' show CloseCode, WebSocketClient; export 'ws/client/web_socket_connection_state.dart'; export 'ws/events/ws_event.dart'; diff --git a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart index b3e07db..f6eb824 100644 --- a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart @@ -31,7 +31,7 @@ class DefaultConnectionRecoveryHandler extends ConnectionRecoveryHandler mixin NetworkAwareConnectionRecoveryHandler on ConnectionRecoveryHandler { void _networkStatusChanged(NetworkStatus status) { - if (status == NetworkStatus.connected) { + if (status == NetworkStatus.disconnected) { disconnectIfNeeded(); } else { reconnectIfNeeded(); diff --git a/packages/stream_core/test/mocks.dart b/packages/stream_core/test/mocks.dart index a8dac87..8268474 100644 --- a/packages/stream_core/test/mocks.dart +++ b/packages/stream_core/test/mocks.dart @@ -21,6 +21,8 @@ class MockHttpClient extends Mock implements CoreHttpClient {} class MockTokenManager extends Mock implements TokenManager {} +class MockWebSocketClient extends Mock implements WebSocketClient {} + final systemEnvironmentManager = SystemEnvironmentManager( environment: const SystemEnvironment( sdkName: 'core', diff --git a/packages/stream_core/test/ws/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/connection_recovery_handler_test.dart new file mode 100644 index 0000000..af2b594 --- /dev/null +++ b/packages/stream_core/test/ws/connection_recovery_handler_test.dart @@ -0,0 +1,118 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + late MockWebSocketClient client; + late ConnectionRecoveryHandler connectionRecoveryHandler; + + late FakeNetworkMonitor networkMonitor; + + setUpAll(() { + registerFallbackValue(CloseCode.normalClosure); + registerFallbackValue(DisconnectionSource.systemInitiated()); + }); + + setUp(() { + client = MockWebSocketClient(); + networkMonitor = FakeNetworkMonitor(); + }); + + tearDown(() { + connectionRecoveryHandler.dispose(); + }); + + test('Should disconnect on losing internet', () async { + when(() => client.connectionState) + .thenReturn(WebSocketConnectionState.connected()); + when(() => client.connectionStateStream) + .thenReturn(MutableSharedEmitterImpl()); + when(() => client.disconnect()).thenReturn(null); + + connectionRecoveryHandler = DefaultConnectionRecoveryHandler( + client: client, + networkMonitor: networkMonitor, + ); + + networkMonitor.updateStatus(NetworkStatus.disconnected); + await Future.delayed(Duration.zero); + + verify( + () => client.disconnect( + code: CloseCode.normalClosure, + source: DisconnectionSource.systemInitiated(), + ), + ).called(1); + }); + + test('Should not disconnect on losing internet when already disconnected', + () async { + when(() => client.connectionState).thenReturn( + WebSocketConnectionState.disconnected( + source: DisconnectionSource.noPongReceived(), + ), + ); + when(() => client.connectionStateStream) + .thenReturn(MutableSharedEmitterImpl()); + when(() => client.disconnect()).thenReturn(null); + + connectionRecoveryHandler = DefaultConnectionRecoveryHandler( + client: client, + networkMonitor: networkMonitor, + ); + + networkMonitor.updateStatus(NetworkStatus.disconnected); + await Future.delayed(Duration.zero); + + verifyNever( + () => client.disconnect( + code: any(named: 'code'), + source: any(named: 'source'), + ), + ); + }); + + test('Should reconnect on gaining internet', () async { + when(() => client.connectionState).thenReturn( + WebSocketConnectionState.disconnected( + source: DisconnectionSource.systemInitiated(), + ), + ); + when(() => client.connectionStateStream) + .thenReturn(MutableSharedEmitterImpl()); + + connectionRecoveryHandler = DefaultConnectionRecoveryHandler( + client: client, + networkMonitor: networkMonitor, + ); + + networkMonitor.updateStatus(NetworkStatus.connected); + await Future.delayed(Duration.zero); + + verify(() => client.connect()).called(1); + }); +} + +class FakeNetworkMonitor implements NetworkMonitor { + FakeNetworkMonitor({NetworkStatus initialStatus = NetworkStatus.connected}) + : currentStatus = initialStatus; + + void updateStatus(NetworkStatus status) { + currentStatus = status; + _statusController.add(status); + } + + @override + NetworkStatus currentStatus; + + final StreamController _statusController = StreamController(); + @override + // TODO: implement onStatusChange + Stream get onStatusChange => _statusController.stream; +}