From c398ad84665ceb753dc764ae7c3881b463845bd6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 25 Aug 2025 18:04:30 +0200 Subject: [PATCH 1/8] refactor: core --- analysis_options.yaml | 3 + melos.yaml | 20 + packages/stream_core/docs/web_socket.md | 322 +++- packages/stream_core/lib/src/api.dart | 10 +- .../lib/src/api/connection_id_provider.dart | 4 - .../stream_core/lib/src/api/http_client.dart | 300 ---- .../lib/src/api/http_client_options.dart | 38 - .../interceptors/api_error_interceptor.dart | 32 + .../api/interceptors/api_key_interceptor.dart | 18 + .../api/interceptors/auth_interceptor.dart | 33 +- .../connection_id_interceptor.dart | 22 +- ...erceptor.dart => headers_interceptor.dart} | 16 +- .../lib/src/api/stream_core_dio_error.dart | 2 - .../lib/src/api/stream_core_http_client.dart | 26 + .../lib/src/api/token_manager.dart | 50 - .../lib/src/errors/client_exception.dart | 58 +- .../lib/src/errors/stream_api_error.dart | 64 +- .../lib/src/errors/stream_error_code.dart | 153 -- packages/stream_core/lib/src/models.dart | 1 - .../lib/src/models/pagination_result.dart | 27 - packages/stream_core/lib/src/platform.dart | 1 + .../lib/src/platform/current_platform.dart | 99 ++ .../platform/detector/platform_detector.dart | 13 + .../detector/platform_detector_io.dart | 25 + packages/stream_core/lib/src/query.dart | 3 + .../stream_core/lib/src/query/filter.dart | 198 +++ .../lib/src/query/filter_operator.dart | 52 + packages/stream_core/lib/src/query/sort.dart | 221 +++ .../stream_core/lib/src/query/sort.g.dart | 18 + packages/stream_core/lib/src/user.dart | 3 + .../user/connect_user_details_request.dart | 8 +- .../user/connect_user_details_request.g.dart | 13 +- .../lib/src/user/token_manager.dart | 95 ++ .../lib/src/user/token_provider.dart | 115 ++ packages/stream_core/lib/src/user/user.dart | 64 +- .../stream_core/lib/src/user/user_token.dart | 125 ++ .../lib/src/user/ws_auth_message_request.dart | 18 +- .../src/user/ws_auth_message_request.g.dart | 16 +- packages/stream_core/lib/src/utils.dart | 8 +- .../utils/app_lifecycle_state_provider.dart | 22 + .../lib/src/utils/comparable_extensions.dart | 14 + .../stream_core/lib/src/utils/disposable.dart | 18 + .../lib/src/utils/list_extensions.dart | 449 ++++++ .../lib/src/utils/network_monitor.dart | 12 - .../lib/src/utils/network_state_provider.dart | 24 + .../stream_core/lib/src/utils/result.dart | 341 ++-- .../lib/src/utils/shared_emitter.dart | 195 ++- .../stream_core/lib/src/utils/standard.dart | 45 +- .../lib/src/utils/state_emitter.dart | 206 +++ packages/stream_core/lib/src/ws.dart | 20 +- .../client/connection_recovery_handler.dart | 163 -- .../default_connection_recovery_handler.dart | 69 - .../engine/stream_web_socket_engine.dart | 123 ++ .../ws/client/engine/web_socket_engine.dart | 202 +++ .../ws/client/engine/web_socket_options.dart | 55 + .../automatic_reconnection_policy.dart | 97 ++ .../connection_recovery_handler.dart | 161 ++ .../ws/client/reconnect/retry_strategy.dart | 97 ++ .../ws/client/stream_web_socket_client.dart | 258 +++ .../web_socket_channel_factory.dart | 13 - .../web_socket_channel_factory_html.dart | 33 - .../web_socket_channel_factory_io.dart | 22 - .../lib/src/ws/client/web_socket_client.dart | 216 --- .../client/web_socket_connection_state.dart | 323 +++- .../lib/src/ws/client/web_socket_engine.dart | 124 -- .../ws/client/web_socket_health_monitor.dart | 108 ++ .../ws/client/web_socket_ping_controller.dart | 71 - .../lib/src/ws/events/event_emitter.dart | 68 + .../lib/src/ws/events/sendable_event.dart | 25 - .../lib/src/ws/events/ws_event.dart | 30 - .../lib/src/ws/events/ws_request.dart | 24 + packages/stream_core/lib/stream_core.dart | 8 +- packages/stream_core/pubspec.yaml | 13 + .../additional_headers_interceptor_test.dart | 105 +- .../interceptor/auth_interceptor_test.dart | 478 +++--- .../api/stream_http_client_options_test.dart | 28 - .../test/api/stream_http_client_test.dart | 1342 ++++++++-------- packages/stream_core/test/mocks.dart | 100 +- .../stream_core/test/query/filter_test.dart | 200 +++ .../test/query/list_extensions_test.dart | 1409 +++++++++++++++++ .../stream_core/test/query/sort_test.dart | 480 ++++++ .../ws/connection_recovery_handler_test.dart | 236 +-- 82 files changed, 7348 insertions(+), 2973 deletions(-) delete mode 100644 packages/stream_core/lib/src/api/connection_id_provider.dart delete mode 100644 packages/stream_core/lib/src/api/http_client.dart delete mode 100644 packages/stream_core/lib/src/api/http_client_options.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart create mode 100644 packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart rename packages/stream_core/lib/src/api/interceptors/{additional_headers_interceptor.dart => headers_interceptor.dart} (51%) create mode 100644 packages/stream_core/lib/src/api/stream_core_http_client.dart delete mode 100644 packages/stream_core/lib/src/api/token_manager.dart delete mode 100644 packages/stream_core/lib/src/errors/stream_error_code.dart delete mode 100644 packages/stream_core/lib/src/models.dart delete mode 100644 packages/stream_core/lib/src/models/pagination_result.dart create mode 100644 packages/stream_core/lib/src/platform.dart create mode 100644 packages/stream_core/lib/src/platform/current_platform.dart create mode 100644 packages/stream_core/lib/src/platform/detector/platform_detector.dart create mode 100644 packages/stream_core/lib/src/platform/detector/platform_detector_io.dart create mode 100644 packages/stream_core/lib/src/query.dart create mode 100644 packages/stream_core/lib/src/query/filter.dart create mode 100644 packages/stream_core/lib/src/query/filter_operator.dart create mode 100644 packages/stream_core/lib/src/query/sort.dart create mode 100644 packages/stream_core/lib/src/query/sort.g.dart create mode 100644 packages/stream_core/lib/src/user/token_manager.dart create mode 100644 packages/stream_core/lib/src/user/token_provider.dart create mode 100644 packages/stream_core/lib/src/user/user_token.dart create mode 100644 packages/stream_core/lib/src/utils/app_lifecycle_state_provider.dart create mode 100644 packages/stream_core/lib/src/utils/comparable_extensions.dart create mode 100644 packages/stream_core/lib/src/utils/disposable.dart create mode 100644 packages/stream_core/lib/src/utils/list_extensions.dart delete mode 100644 packages/stream_core/lib/src/utils/network_monitor.dart create mode 100644 packages/stream_core/lib/src/utils/network_state_provider.dart create mode 100644 packages/stream_core/lib/src/utils/state_emitter.dart delete mode 100644 packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart delete mode 100644 packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart create mode 100644 packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart create mode 100644 packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart create mode 100644 packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart create mode 100644 packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart create mode 100644 packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart create mode 100644 packages/stream_core/lib/src/ws/client/reconnect/retry_strategy.dart create mode 100644 packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_client.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_engine.dart create mode 100644 packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart delete mode 100644 packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart create mode 100644 packages/stream_core/lib/src/ws/events/event_emitter.dart delete mode 100644 packages/stream_core/lib/src/ws/events/sendable_event.dart create mode 100644 packages/stream_core/lib/src/ws/events/ws_request.dart delete mode 100644 packages/stream_core/test/api/stream_http_client_options_test.dart create mode 100644 packages/stream_core/test/query/filter_test.dart create mode 100644 packages/stream_core/test/query/list_extensions_test.dart create mode 100644 packages/stream_core/test/query/sort_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index ba811b0..b873dbc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -26,6 +26,9 @@ linter: always_put_control_body_on_new_line: false + # Not always necessary, especially for interfaces. + one_member_abstracts: false + # Does not always make code more readable. cascade_invocations: false diff --git a/melos.yaml b/melos.yaml index c9112c3..ad3a13f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -14,8 +14,28 @@ command: sdk: ^3.6.2 # We are not using carat '^' syntax here because flutter don't follow semantic versioning. flutter: ">=3.27.4" + + # List of all the dependencies used in the project. + dependencies: + collection: ^1.19.0 + 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 + synchronized: ^3.3.0 + web: ^1.1.1 + web_socket_channel: ^3.0.1 + + # List of all the dev_dependencies used in the project. dev_dependencies: build_runner: ^2.4.15 + json_serializable: ^6.9.5 + melos: ^6.2.0 + mocktail: ^1.0.4 + test: ^1.26.2 scripts: postclean: diff --git a/packages/stream_core/docs/web_socket.md b/packages/stream_core/docs/web_socket.md index 1cc28eb..3259472 100644 --- a/packages/stream_core/docs/web_socket.md +++ b/packages/stream_core/docs/web_socket.md @@ -1,67 +1,317 @@ -# Stream Core Websocket +# Stream Core WebSocket ## TODO - [ ] cover with unit tests - [ ] test implementation -- [ ] reconnect logic -- [ ] improve docs +- [x] reconnect logic +- [x] improve docs - [ ] replace print statements with proper logs -## Overall architecture +Stream Core WebSocket provides a robust WebSocket client with automatic reconnection, health monitoring, and type-safe event handling for real-time applications. -The `WebSocketEngine` is purely responsible for connecting to the websocket and handling events. +The WebSocket implementation includes comprehensive connection lifecycle management, intelligent reconnection policies, and structured event processing for building reliable real-time features. -The `WebSocketClient` is the public interface and can be used for apps to connect to a websocket. - -The `WebSocketPingController` keeps track of the timings of the ping/pong for health checks. -It uses the `WebSocketPingClient`, implemented by the `WebSocketClient` to send the ping and listen to pongs. -It also will call the `WebSocketPingClient` if it should disconnect because of a bad connection. - -The `ConnectionRecoveryHandler` manages the reconnection for all cases. +## Architecture Overview +The WebSocket system consists of several key components working together: +- **[StreamWebSocketEngine]**: Handles low-level WebSocket connections and message transport +- **[StreamWebSocketClient]**: Main public interface for WebSocket operations and state management +- **[WebSocketHealthMonitor]**: Manages ping/pong health checks to detect connection issues +- **[ConnectionRecoveryHandler]**: Implements intelligent reconnection with configurable policies ```mermaid graph TD; - ConnectionRecoveryHandler-->WebSocketClient; - ConnectionRecoveryHandler-->NetworkMonitor; - WebSocketClient-->WebSocketEngine; - WebSocketPingController-->WebSocketClient; + ConnectionRecoveryHandler-->StreamWebSocketClient; + NetworkStateProvider-->ConnectionRecoveryHandler; + AppLifecycleStateProvider-->ConnectionRecoveryHandler; + StreamWebSocketClient-->StreamWebSocketEngine; + StreamWebSocketClient-->WebSocketHealthMonitor; + StreamWebSocketEngine-->StreamWebSocketClient; + WebSocketHealthMonitor-->StreamWebSocketClient; + WebSocketMessageCodec-->StreamWebSocketEngine; +``` + +## WebSocketMessageCodec + +An interface for WebSocket message encoding and decoding. + +Handles the serialization and deserialization of messages between WebSocket transport format and application-specific types. The codec supports both text and binary message formats. + +```dart +abstract interface class WebSocketMessageCodec { + /// Encodes an outgoing message for WebSocket transmission. + /// + /// Returns either a [String] or [Uint8List] for transmission over the WebSocket connection. + Object encode(Outgoing message); + + /// Decodes an incoming WebSocket message. + /// + /// The [message] received from the WebSocket is converted to the application-specific incoming message type. + Incoming decode(Object message); +} +``` + +### Implementation Example + +```dart +class JsonMessageCodec implements WebSocketMessageCodec { + @override + String encode(WsRequest message) { + return jsonEncode(message.toJson()); + } + + @override + WsEvent decode(Object message) { + final json = jsonDecode(message as String); + return WsEvent.fromJson(json); + } +} +``` + +## StreamWebSocketClient + +A WebSocket client with connection management and event handling. + +The primary interface for WebSocket connections in the Stream Core SDK that provides functionality for real-time communication with automatic reconnection, health monitoring, and sophisticated state management. + +Each [StreamWebSocketClient] instance manages its own connection lifecycle and maintains state that can be observed for real-time updates. + +### Constructor + +Creates a [StreamWebSocketClient] instance for real-time WebSocket communication. + +```dart +StreamWebSocketClient({ + required WebSocketOptions options, + required WebSocketMessageCodec messageCodec, + PingRequestBuilder pingRequestBuilder = _defaultPingRequestBuilder, + void Function()? onConnectionEstablished, + Iterable>? eventResolvers, +}) ``` -## WebSocketClient +The [options] specify connection configuration including URL, protocols, and query parameters. The [messageCodec] handles encoding outgoing requests and decoding incoming events. When [onConnectionEstablished] is provided, it's called when the connection is ready for authentication. + +### Authentication + +When authentication is required, send authentication messages in the [onConnectionEstablished] callback: + ```dart - WebSocketClient({ - required String url, - required this.eventDecoder, - this.pingReguestBuilder, - this.onConnectionEstablished, - this.onConnected, - }) +final client = StreamWebSocketClient( + options: WebSocketOptions(url: 'wss://api.example.com'), + messageCodec: MyMessageCodec(), + onConnectionEstablished: () { + client.send(AuthRequest(token: authToken)); + }, +); ``` -The `WebSocketClient` always requires an `eventDecoder`. You should use this to map the websocket message to your own event. -It's important to also map to the `HealthCheckPongEvent` for health check events. +## WebSocketHealthMonitor + +A health monitor for WebSocket connections with ping/pong management. -When you need to authenticate for the websocket you should sent the authentication event in `onConnectionEstablished`. -The `onConnected` is called when the connection is fully established after (optional) authentication. +Manages the health checking mechanism for WebSocket connections by automatically sending ping requests and monitoring for pong responses to detect unhealthy connections. -## WebSocketPingController +The monitor integrates with [StreamWebSocketClient] to provide automatic connection health detection and recovery triggers. -The `WebSocketPingController` will use the `WebSocketClient` to send pings to the backend while the websocket is connected. By default it sends a ping every 25 seconds. It expects a pong from the backend within a certain interval, by default 3 seconds. If it doesn't get the pong it will request the `WebSocketClient` to disconnect using `disconnectNoPongReceived`. +### Health Check Process -By default the `WebSocketClient` will send a basic health check event for the ping with the connectionId. If you need a different health check event, for example for the SFU, you need to add a `pingReguestBuilder` in the `WebSocketClient`. +The health monitor follows a structured process: + +1. **Automatic Start**: Monitoring begins when connection state becomes [Connected] +2. **Ping Scheduling**: Sends periodic ping requests every 25 seconds by default +3. **Pong Monitoring**: Starts timeout timer after each ping request +4. **Unhealthy Detection**: Marks connection unhealthy if no pong received within 3 seconds by default +5. **Automatic Stop**: Stops monitoring when connection becomes inactive + +### Custom Ping Messages + +The default ping request includes the connection ID. For custom ping messages, provide a [pingRequestBuilder] to [StreamWebSocketClient]: + +```dart +StreamWebSocketClient( + pingRequestBuilder: (healthCheckInfo) { + return CustomPingRequest( + connectionId: healthCheckInfo?.connectionId, + timestamp: DateTime.now(), + ); + }, + // ... other parameters +); +``` ## ConnectionRecoveryHandler -The `ConnectionRecoveryHandler` manages the reconnection for all cases. Currently implemented are network related reconnection events -and reconnections for websocket errors. The reason in the disconnected state determines if the recovery will reconnect or not. +A connection recovery handler with intelligent reconnection management. + +Provides intelligent reconnection management with multiple policies and retry strategies for [StreamWebSocketClient] instances. Automatically handles reconnection based on various conditions like network state, app lifecycle, and connection errors. + +The handler monitors connection state changes and applies configurable policies to determine when reconnection should occur. -When creating a `WebSocketClient` you should also create a `ConnectionRecoveryHandler` yourself like this: +### Basic Setup ```dart -final client = WebSocketClient(...); +final client = StreamWebSocketClient(...); final recoveryHandler = ConnectionRecoveryHandler(client: client); ``` -The `WebSocketClient` itself just disconnects when there is an error and the `ConnectionRecoveryHandler` is responsible for reconnecting when needed. \ No newline at end of file +### Advanced Setup + +For mobile apps, include network and app lifecycle monitoring: + +```dart +final recoveryHandler = ConnectionRecoveryHandler( + client: client, + networkStateProvider: NetworkStateProvider(), + appLifecycleStateProvider: AppLifecycleStateProvider(), +); +``` + +### Reconnection Rules + +Automatic reconnection is **enabled** for: +- Server-initiated disconnections (except authentication/client errors) +- System-initiated disconnections (network changes, etc.) +- Unhealthy connections (missing pong responses) + +Automatic reconnection is **disabled** for: +- User-initiated disconnections +- Server errors with code 1000 (normal closure) +- Token invalid/expired errors +- Client errors (4xx status codes) + +## Event Resolvers + +Functions that transform or filter incoming events before emission. + +Event resolvers allow preprocessing of incoming [WsEvent] instances before they're emitted to listeners. Resolvers can transform events, filter them, or provide fallback handling. + +Multiple resolvers are evaluated in order until one returns a non-null result. + +```dart +WsEvent? myEventResolver(WsEvent event) { + if (event is RawMessageEvent) { + return MyCustomEvent.fromRaw(event); + } + return null; // Let other resolvers handle it +} + +final client = StreamWebSocketClient( + eventResolvers: [myEventResolver], + // ... other parameters +); +``` + +## Connection States + +The connection state management system for WebSocket lifecycle. + +The [StreamWebSocketClient] maintains several connection states that represent the current status of the WebSocket connection: + +- **[Initialized]**: Initial state, no connection attempt made +- **[Connecting]**: Attempting to establish WebSocket connection +- **[Authenticating]**: Connection established, authentication in progress +- **[Connected]**: Fully connected and authenticated +- **[Disconnecting]**: Connection is being closed +- **[Disconnected]**: Connection closed + +### Monitoring Connection State + +```dart +client.connectionState.on((state) { + switch (state) { + case Connected(): + print('Connected to WebSocket'); + case Disconnected(): + print('Disconnected from WebSocket'); + // ... handle other states + } +}); +``` + +## Usage + +### Sending Messages + +Sends a message through the WebSocket connection. + +```dart +final result = client.send(MyRequest(data: 'hello')); +if (result.isFailure) { + print('Failed to send message: ${result.error}'); +} +``` + +### Listening to Events + +The client provides type-safe event handling through the [events] emitter: + +```dart +// Listen to specific event types +client.events.on((event) { + print('Received: ${event.data}'); +}); + +// Listen to all events +client.events.on((event) { + print('Received event: $event'); +}); +``` + +## Complete Example + +A comprehensive example demonstrating WebSocket client setup with all components: + +```dart +// 1. Create message codec +final messageCodec = JsonMessageCodec(); + +// 2. Create WebSocket client +final client = StreamWebSocketClient( + options: WebSocketOptions( + url: 'wss://api.example.com/ws', + queryParameters: {'token': authToken}, + ), + messageCodec: messageCodec, + onConnectionEstablished: () { + client.send(AuthRequest(token: authToken)); + }, +); + +// 3. Set up connection recovery +final recoveryHandler = ConnectionRecoveryHandler( + client: client, + networkStateProvider: NetworkStateProvider(), + appLifecycleStateProvider: AppLifecycleStateProvider(), +); + +// 4. Listen to events +client.events.on((event) { + print('Received message: ${event.content}'); +}); + +// 5. Monitor connection state +client.connectionState.on((state) { + switch (state) { + case Connected(): + print('WebSocket connected'); + case Disconnected(): + print('WebSocket disconnected'); + } +}); + +// 6. Connect +await client.connect(); + +// 7. Send messages +final result = client.send(ChatMessage(content: 'Hello, World!')); +if (result.isFailure) { + print('Send failed: ${result.error}'); +} + +// 8. Clean up when done +await client.disconnect(); +await recoveryHandler.dispose(); +``` + diff --git a/packages/stream_core/lib/src/api.dart b/packages/stream_core/lib/src/api.dart index a7e65f9..56e7ec8 100644 --- a/packages/stream_core/lib/src/api.dart +++ b/packages/stream_core/lib/src/api.dart @@ -1,6 +1,10 @@ -export 'api/connection_id_provider.dart'; -export 'api/http_client.dart'; +export 'api/interceptors/api_error_interceptor.dart'; +export 'api/interceptors/api_key_interceptor.dart'; +export 'api/interceptors/auth_interceptor.dart'; +export 'api/interceptors/connection_id_interceptor.dart'; +export 'api/interceptors/headers_interceptor.dart'; +export 'api/interceptors/logging_interceptor.dart'; export 'api/stream_core_dio_error.dart'; +export 'api/stream_core_http_client.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 deleted file mode 100644 index 1f7925d..0000000 --- a/packages/stream_core/lib/src/api/connection_id_provider.dart +++ /dev/null @@ -1,4 +0,0 @@ -// 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 deleted file mode 100644 index 60d0858..0000000 --- a/packages/stream_core/lib/src/api/http_client.dart +++ /dev/null @@ -1,300 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:meta/meta.dart'; - -import '../../stream_core.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'; - -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 { - /// [CoreHttpClient] constructor - CoreHttpClient( - this.apiKey, { - Dio? dio, - HttpClientOptions? options, - TokenManager? tokenManager, - ConnectionIdProvider? connectionIdProvider, - required SystemEnvironmentManager systemEnvironmentManager, - StreamLogger? 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) - LoggingInterceptor( - requestHeader: true, - logPrint: (step, message) { - switch (step) { - case InterceptStep.request: - return logger.log( - Priority.info, - _tag, - message.toString, - ); - case InterceptStep.response: - return logger.log( - Priority.info, - _tag, - message.toString, - ); - case InterceptStep.error: - return logger.log( - Priority.error, - _tag, - message.toString, - ); - } - }, - ), - ], - ]); - 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 [CoreHttpClient]. - /// - /// 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 - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } - - /// 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, stackTrace) { - throw Error.throwWithStackTrace(_parseError(error), stackTrace); - } - } -} diff --git a/packages/stream_core/lib/src/api/http_client_options.dart b/packages/stream_core/lib/src/api/http_client_options.dart deleted file mode 100644 index a764733..0000000 --- a/packages/stream_core/lib/src/api/http_client_options.dart +++ /dev/null @@ -1,38 +0,0 @@ -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]. - 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/api_error_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart new file mode 100644 index 0000000..96eb534 --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/api_error_interceptor.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; + +import '../stream_core_dio_error.dart'; + +class ApiErrorInterceptor extends Interceptor { + /// Initializes a new instance of [ApiErrorInterceptor]. + const ApiErrorInterceptor(); + + @override + void onError( + DioException err, + ErrorInterceptorHandler handler, + ) { + if (err is StreamDioException) { + // If the error is already a StreamDioException, + // we can directly pass it to the handler. + return super.onError(err, handler); + } + + // Otherwise, we convert the DioException to a StreamDioException + final streamDioException = StreamDioException( + exception: err.toClientException(), + requestOptions: err.requestOptions, + response: err.response, + type: err.type, + stackTrace: err.stackTrace, + message: err.message, + ); + + return super.onError(streamDioException, handler); + } +} diff --git a/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart new file mode 100644 index 0000000..06975de --- /dev/null +++ b/packages/stream_core/lib/src/api/interceptors/api_key_interceptor.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; + +class ApiKeyInterceptor extends Interceptor { + /// Initialize a new API key interceptor + const ApiKeyInterceptor(this.apiKey); + + /// The API key to be added to the request headers + final String apiKey; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + options.headers['api_key'] = apiKey; + 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 index cda95c4..ea7a0ed 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -1,17 +1,16 @@ import 'package:dio/dio.dart'; import '../../errors.dart'; -import '../http_client.dart'; +import '../../user.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); + AuthInterceptor(this._dio, this._tokenManager); - final CoreHttpClient _client; + final Dio _dio; /// The token manager used in the client final TokenManager _tokenManager; @@ -22,16 +21,12 @@ class AuthInterceptor extends QueuedInterceptor { RequestInterceptorHandler handler, ) async { try { - final token = await _tokenManager.loadToken(); + final token = await _tokenManager.getToken(); + + options.queryParameters['user_id'] = _tokenManager.userId; + options.headers['Authorization'] = token.rawValue; + options.headers['stream-auth-type'] = token.authType.headerValue; - 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( @@ -39,11 +34,13 @@ class AuthInterceptor extends QueuedInterceptor { stackTrace: stackTrace, error: e, ); + final dioError = StreamDioException( exception: error, requestOptions: options, stackTrace: StackTrace.current, ); + return handler.reject(dioError, true); } } @@ -60,17 +57,21 @@ class AuthInterceptor extends QueuedInterceptor { final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - if (_tokenManager.isStatic) return handler.next(err); - await _tokenManager.loadToken(refresh: true); + // Don't try to refresh the token if we're using a static provider + if (_tokenManager.usesStaticProvider) return handler.next(err); + // Otherwise, mark the current token as expired. + _tokenManager.expireToken(); + try { final options = err.requestOptions; // ignore: inference_failure_on_function_invocation - final response = await _client.fetch(options); + final response = await _dio.fetch(options); return handler.resolve(response); } on DioException catch (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 66b4d8c..fcb436a 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 @@ -1,26 +1,26 @@ import 'package:dio/dio.dart'; -import '../connection_id_provider.dart'; +typedef ConnectionIdGetter = String? Function(); /// Interceptor that injects the connection id in the request params class ConnectionIdInterceptor extends Interceptor { - /// - ConnectionIdInterceptor(this.connectionIdProvider); + /// Initialize a new [ConnectionIdInterceptor]. + const ConnectionIdInterceptor(this._connectionId); - /// - final ConnectionIdProvider connectionIdProvider; + /// The getter for the connection id. + final ConnectionIdGetter _connectionId; @override Future onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { - final connectionId = connectionIdProvider(); - if (connectionId != null) { - options.queryParameters.addAll({ - 'connection_id': connectionId, - }); + final connectionId = _connectionId.call(); + if (connectionId != null && connectionId.isNotEmpty) { + // Add the connection id to the query parameters + options.queryParameters['connection_id'] = connectionId; } - handler.next(options); + + return handler.next(options); } } diff --git a/packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart similarity index 51% rename from packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart rename to packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart index 23986f2..f2b9487 100644 --- a/packages/stream_core/lib/src/api/interceptors/additional_headers_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/headers_interceptor.dart @@ -3,27 +3,19 @@ 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); +class HeadersInterceptor extends Interceptor { + /// Initialize a new [HeadersInterceptor]. + const HeadersInterceptor(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, - }; + options.headers['X-Stream-Client'] = userAgent; return handler.next(options); } } 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 index 1643d9f..66feb70 100644 --- a/packages/stream_core/lib/src/api/stream_core_dio_error.dart +++ b/packages/stream_core/lib/src/api/stream_core_dio_error.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:dio/dio.dart'; - import '../../stream_core.dart'; /// Error class specific to StreamChat and Dio diff --git a/packages/stream_core/lib/src/api/stream_core_http_client.dart b/packages/stream_core/lib/src/api/stream_core_http_client.dart new file mode 100644 index 0000000..c01f22e --- /dev/null +++ b/packages/stream_core/lib/src/api/stream_core_http_client.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import 'package:meta/meta.dart'; + +import '../utils.dart'; + +extension type const StreamCoreHttpClient._(Dio _client) implements Dio { + factory StreamCoreHttpClient({ + BaseOptions? options, + Iterable? interceptors, + HttpClientAdapter? httpClientAdapter, + }) { + final dio = Dio(options); + + // Add interceptors, error handlers, etc. + interceptors?.let(dio.interceptors.addAll); + + if (httpClientAdapter != null) { + dio.httpClientAdapter = httpClientAdapter; + } + + return StreamCoreHttpClient._(dio); + } + + @visibleForTesting + const StreamCoreHttpClient.fromDio(Dio client) : this._(client); +} diff --git a/packages/stream_core/lib/src/api/token_manager.dart b/packages/stream_core/lib/src/api/token_manager.dart deleted file mode 100644 index e48601d..0000000 --- a/packages/stream_core/lib/src/api/token_manager.dart +++ /dev/null @@ -1,50 +0,0 @@ -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/client_exception.dart b/packages/stream_core/lib/src/errors/client_exception.dart index a703315..a053fae 100644 --- a/packages/stream_core/lib/src/errors/client_exception.dart +++ b/packages/stream_core/lib/src/errors/client_exception.dart @@ -33,32 +33,32 @@ class HttpClientException extends ClientException { final bool isRequestCancelledError; } -class WebSocketException extends ClientException { - WebSocketException(this.serverException, {super.error}) - : super( - message: - (serverException ?? WebSocketEngineException.unknown()).reason, - ); - final WebSocketEngineException? serverException; -} - -class WebSocketEngineException extends ClientException { - WebSocketEngineException({ - required this.reason, - required this.code, - this.engineError, - }) : super(message: reason); - - WebSocketEngineException.unknown() - : this( - reason: 'Unknown', - code: 0, - engineError: null, - ); - - static const stopErrorCode = 1000; - - final String reason; - final int code; - final Object? engineError; -} +// class WebSocketException extends ClientException { +// WebSocketException(this.serverException, {super.error}) +// : super( +// message: +// (serverException ?? WebSocketEngineException.unknown()).reason, +// ); +// final WebSocketEngineException? serverException; +// } +// +// class WebSocketEngineException extends ClientException { +// WebSocketEngineException({ +// required this.reason, +// required this.code, +// this.engineError, +// }) : super(message: reason); +// +// WebSocketEngineException.unknown() +// : this( +// reason: 'Unknown', +// 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 aff2bab..4d33bcb 100644 --- a/packages/stream_core/lib/src/errors/stream_api_error.dart +++ b/packages/stream_core/lib/src/errors/stream_api_error.dart @@ -1,9 +1,20 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; part 'stream_api_error.g.dart'; +/// An API error response from the Stream API. +/// +/// Encapsulates all error information returned by the API when a request fails, +/// providing detailed context about what went wrong. This includes error codes, +/// status information, and additional metadata for debugging and error handling. +/// +/// Note: While this class can be generated from the OpenAPI specification, it is +/// defined here to allow usage across different Stream products without depending +/// on OpenAPI codegen. @JsonSerializable() -class StreamApiError { +class StreamApiError extends Equatable { + /// Creates a new [StreamApiError] instance. const StreamApiError({ required this.code, required this.details, @@ -15,59 +26,66 @@ class StreamApiError { this.unrecoverable, }); - /// API error code + /// The specific error code identifying the type of error. final int code; - /// Additional error-specific information + /// Additional error detail codes providing more context. final List details; - /// Request duration + /// The processing duration before the error occurred. final String duration; - /// Additional error info + /// Additional context about the exception as key-value pairs. final Map? exceptionFields; - /// Message describing an error + /// The human-readable error description. final String message; - /// URL with additional information + /// Additional information or documentation URL for this error. final String moreInfo; - /// Response HTTP status code + /// The HTTP status code associated with this error. @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 + /// Whether this error is unrecoverable and should not be retried. final bool? unrecoverable; Map toJson() => _$StreamApiErrorToJson(this); + /// Creates a [StreamApiError] from a JSON map. 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, ' - ')'; - } + List get props => [ + code, + details, + duration, + exceptionFields, + message, + moreInfo, + statusCode, + unrecoverable, + ]; } final _tokenInvalidErrorCodes = _range(40, 42); final _clientErrorCodes = _range(400, 499); +/// Extension methods for [StreamApiError] to provide convenient error type checks. extension StreamApiErrorExtension on StreamApiError { + /// Whether this error indicates an expired or invalid token. bool get isTokenExpiredError => _tokenInvalidErrorCodes.contains(code); + + /// Whether this error is a client-side error (4xx status codes). bool get isClientError => _clientErrorCodes.contains(code); + + /// Whether this error indicates rate limiting (429 status code). bool get isRateLimitError => statusCode == 429; } -List _range(int from, int to) => - List.generate(to - from + 1, (i) => i + from); +// Helper function to generate a range of integers from [from] to [to] inclusive. +List _range(int from, int to) { + return List.generate(to - from + 1, (i) => i + from); +} diff --git a/packages/stream_core/lib/src/errors/stream_error_code.dart b/packages/stream_core/lib/src/errors/stream_error_code.dart deleted file mode 100644 index 4415acd..0000000 --- a/packages/stream_core/lib/src/errors/stream_error_code.dart +++ /dev/null @@ -1,153 +0,0 @@ -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'), -}; - -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/lib/src/models.dart b/packages/stream_core/lib/src/models.dart deleted file mode 100644 index d9391f2..0000000 --- a/packages/stream_core/lib/src/models.dart +++ /dev/null @@ -1 +0,0 @@ -export 'models/pagination_result.dart'; diff --git a/packages/stream_core/lib/src/models/pagination_result.dart b/packages/stream_core/lib/src/models/pagination_result.dart deleted file mode 100644 index bb5781f..0000000 --- a/packages/stream_core/lib/src/models/pagination_result.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:equatable/equatable.dart'; - -class PaginationResult { - const PaginationResult({ - required this.items, - required this.pagination, - }); - - final List items; - final PaginationData pagination; -} - -class PaginationData extends Equatable { - const PaginationData({ - this.next, - this.previous, - }); - - /// 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 - final String? previous; - - @override - List get props => [next, previous]; -} diff --git a/packages/stream_core/lib/src/platform.dart b/packages/stream_core/lib/src/platform.dart new file mode 100644 index 0000000..5d4468c --- /dev/null +++ b/packages/stream_core/lib/src/platform.dart @@ -0,0 +1 @@ +export 'platform/current_platform.dart'; \ No newline at end of file diff --git a/packages/stream_core/lib/src/platform/current_platform.dart b/packages/stream_core/lib/src/platform/current_platform.dart new file mode 100644 index 0000000..7e0d133 --- /dev/null +++ b/packages/stream_core/lib/src/platform/current_platform.dart @@ -0,0 +1,99 @@ +import 'detector/platform_detector.dart' + if (dart.library.io) 'detector/platform_detector_io.dart'; + +/// Platform detection utility for identifying the current runtime environment. +/// +/// Provides static methods and properties to detect the current platform, +/// including mobile platforms (Android, iOS), desktop platforms (macOS, Windows, Linux), +/// web environments, and special environments like Fuchsia or Flutter tests. +/// +/// This utility automatically selects the appropriate platform detection implementation +/// based on the available Dart libraries, ensuring accurate platform identification +/// across all supported environments. +/// +/// Example usage: +/// ```dart +/// if (CurrentPlatform.isAndroid) { +/// // Android-specific code +/// } else if (CurrentPlatform.isIos) { +/// // iOS-specific code +/// } +/// ``` +class CurrentPlatform { + // Private constructor to prevent instantiation. + CurrentPlatform._(); + + /// The current platform type. + /// + /// Returns the detected [PlatformType] for the current runtime environment. + static PlatformType get type => currentPlatform; + + /// Whether the current platform is Android. + static bool get isAndroid => type == PlatformType.android; + + /// Whether the current platform is iOS. + static bool get isIos => type == PlatformType.ios; + + /// Whether the current platform is web. + static bool get isWeb => type == PlatformType.web; + + /// Whether the current platform is macOS. + static bool get isMacOS => type == PlatformType.macOS; + + /// Whether the current platform is Windows. + static bool get isWindows => type == PlatformType.windows; + + /// Whether the current platform is Linux. + static bool get isLinux => type == PlatformType.linux; + + /// Whether the current platform is Fuchsia. + static bool get isFuchsia => type == PlatformType.fuchsia; + + /// Whether the current environment is a Flutter test. + /// + /// Returns true when running in the Flutter test environment, + /// which is useful for test-specific behavior and mocking. + static bool get isFlutterTest => isFlutterTestEnvironment; + + /// The operating system name as a string. + /// + /// Returns the string representation of the current platform's + /// operating system (e.g., 'android', 'ios', 'web', 'macos'). + static String get operatingSystem => type.operatingSystem; +} + +/// Enumeration of supported platform types. +/// +/// Defines all platforms that can be detected by the Stream Core SDK, +/// including mobile, desktop, web, and specialized platforms. Each platform +/// type includes its corresponding operating system identifier string. +enum PlatformType { + /// Android: + android('android'), + + /// iOS: + ios('ios'), + + /// web: + web('web'), + + /// macOS: + macOS('macos'), + + /// Windows: + windows('windows'), + + /// Linux: + linux('linux'), + + /// Fuchsia: + fuchsia('fuchsia'); + + /// Creates a [PlatformType] with the specified [operatingSystem] identifier. + const PlatformType(this.operatingSystem); + + /// The operating system identifier string for this platform. + /// + /// Used for string-based platform identification and API communication. + final String operatingSystem; +} diff --git a/packages/stream_core/lib/src/platform/detector/platform_detector.dart b/packages/stream_core/lib/src/platform/detector/platform_detector.dart new file mode 100644 index 0000000..1c8090f --- /dev/null +++ b/packages/stream_core/lib/src/platform/detector/platform_detector.dart @@ -0,0 +1,13 @@ +import '../current_platform.dart'; + +/// The current platform type for web environments. +/// +/// This is the default web implementation that always returns [PlatformType.web] +/// since this file is used when dart:io is not available. +PlatformType get currentPlatform => PlatformType.web; + +/// Whether the current environment is a Flutter test. +/// +/// Always returns false in web environments since Flutter tests +/// run with dart:io available and use the IO platform detector. +bool get isFlutterTestEnvironment => false; diff --git a/packages/stream_core/lib/src/platform/detector/platform_detector_io.dart b/packages/stream_core/lib/src/platform/detector/platform_detector_io.dart new file mode 100644 index 0000000..076249f --- /dev/null +++ b/packages/stream_core/lib/src/platform/detector/platform_detector_io.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import '../current_platform.dart'; + +/// The current platform type for environments with dart:io available. +/// +/// Detects the platform using [Platform] class from dart:io, which is available +/// on mobile and desktop platforms. Returns the appropriate [PlatformType] based +/// on the detected operating system, defaulting to Android for unrecognized platforms. +PlatformType get currentPlatform { + if (Platform.isWindows) return PlatformType.windows; + if (Platform.isFuchsia) return PlatformType.fuchsia; + if (Platform.isMacOS) return PlatformType.macOS; + if (Platform.isLinux) return PlatformType.linux; + if (Platform.isIOS) return PlatformType.ios; + return PlatformType.android; +} + +/// Whether the current environment is a Flutter test. +/// +/// Detects Flutter test environments by checking for the 'FLUTTER_TEST' +/// environment variable, which is automatically set by the Flutter test framework. +bool get isFlutterTestEnvironment { + return Platform.environment.containsKey('FLUTTER_TEST'); +} diff --git a/packages/stream_core/lib/src/query.dart b/packages/stream_core/lib/src/query.dart new file mode 100644 index 0000000..6a11dbc --- /dev/null +++ b/packages/stream_core/lib/src/query.dart @@ -0,0 +1,3 @@ +export 'query/filter.dart'; +export 'query/filter_operator.dart'; +export 'query/sort.dart'; diff --git a/packages/stream_core/lib/src/query/filter.dart b/packages/stream_core/lib/src/query/filter.dart new file mode 100644 index 0000000..3219d57 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter.dart @@ -0,0 +1,198 @@ +import 'package:equatable/equatable.dart'; + +import 'filter_operator.dart'; + +/// Filter types are used to specify conditions for retrieving data from +/// Stream's API. Each filter consists of a field, an operator, and a value to +/// compare against. +/// +/// Filters can be combined using logical operators (AND/OR) to create complex +/// queries. +/// +/// ```dart +/// final filter = Filter.and( +/// Filter.equal('type', 'messaging'), +/// Filter.in_('members', [user.id]) +/// ) +/// ``` +class Filter extends Equatable { + const Filter.__({ + required this.value, + this.operator, + this.field, + }); + + const Filter._({ + required this.operator, + required this.value, + this.field, + }); + + /// An empty filter + factory Filter.empty() => const Filter.__(value: {}); + + /// Matches values that are equal to a specified value. + factory Filter.equal(String field, Object value) { + return Filter._(operator: FilterOperator.equal, field: field, value: value); + } + + /// Matches values that are greater than a specified value. + factory Filter.greater(String field, Object value) { + return Filter._( + operator: FilterOperator.greater, + field: field, + value: value, + ); + } + + /// Matches values that are greater than a specified value. + factory Filter.greaterOrEqual(String field, Object value) { + return Filter._( + operator: FilterOperator.greaterOrEqual, + field: field, + value: value, + ); + } + + /// Matches values that are less than a specified value. + factory Filter.less(String field, Object value) { + return Filter._(operator: FilterOperator.less, field: field, value: value); + } + + /// Matches values that are less than or equal to a specified value. + factory Filter.lessOrEqual(String field, Object value) { + return Filter._( + operator: FilterOperator.lessOrEqual, + field: field, + value: value, + ); + } + + /// Matches any of the values specified in an array. + factory Filter.in_(String field, List values) { + return Filter._(operator: FilterOperator.in_, field: field, value: values); + } + + /// Matches values by performing text search with the specified value. + factory Filter.query(String field, String text) { + return Filter._(operator: FilterOperator.query, field: field, value: text); + } + + /// Matches values with the specified prefix. + factory Filter.autoComplete(String field, String text) { + return Filter._( + operator: FilterOperator.autoComplete, + field: field, + value: text, + ); + } + + /// Matches values that exist/don't exist based on the specified boolean value. + factory Filter.exists(String field, {bool exists = true}) { + return Filter._( + operator: FilterOperator.exists, + field: field, + value: exists, + ); + } + + /// Combines the provided filters and matches the values + /// matched by all filters. + factory Filter.and(List filters) { + return Filter._(operator: FilterOperator.and, value: filters); + } + + /// Combines the provided filters and matches the values + /// matched by at least one of the filters. + factory Filter.or(List filters) { + return Filter._(operator: FilterOperator.or, value: filters); + } + + /// Matches any list that contains the specified values + factory Filter.contains(String field, Object value) { + return Filter._( + operator: FilterOperator.contains, + field: field, + value: value, + ); + } + + factory Filter.pathExists(String field, String path) { + return Filter._( + operator: FilterOperator.pathExists, + field: field, + value: path, + ); + } + + /// Creates a custom [Filter] if there isn't one already available. + const factory Filter.custom({ + required Object value, + FilterOperator? operator, + String? field, + }) = Filter.__; + + /// Creates a custom [Filter] from a raw map value + /// + /// ```dart + /// final filter = Filter.raw( + /// { + /// 'members': [user1.id, user2.id], + /// } + /// ) + /// ``` + const factory Filter.raw({ + required Map value, + }) = Filter.__; + + /// An operator used for the filter. The operator string must start with `$` + final FilterOperator? operator; + + /// The "left-hand" side of the filter. + /// Specifies the name of the field the filter should match. + /// + /// Some operators like `and` or `or`, + /// don't require the field value to be present. + final String? field; + + /// The "right-hand" side of the filter. + /// Specifies the [value] the filter should match. + final Object /*List|List|String*/ value; + + @override + List get props => [operator, field, value]; + + /// Serializes to json object + Map toJson() { + final field = this.field; + final operator = this.operator; + + // Handle raw filters (no operator or key) + if (operator == null && field == null) { + // We expect the value to be a Map + return value as Map; + } + + // Handle group operators (and, or, nor) + if (operator != null && operator.isGroup) { + // We encode them in the following format: + // { $: [ , ] } + return {operator.rawValue: value}; + } + + // Handle field-based operators + if (operator != null && field != null) { + // Normal filters are encoded in the following form: + // { field: { $: } } + return { + field: {operator.rawValue: value}, + }; + } + + // Handle simple key-value pairs (no operator) + if (field != null) return {field: value}; + + // Fallback for edge cases (shouldn't normally happen) + return {}; + } +} diff --git a/packages/stream_core/lib/src/query/filter_operator.dart b/packages/stream_core/lib/src/query/filter_operator.dart new file mode 100644 index 0000000..d824140 --- /dev/null +++ b/packages/stream_core/lib/src/query/filter_operator.dart @@ -0,0 +1,52 @@ +/// Possible operators to use in filters. +enum FilterOperator { + /// Matches values that are equal to a specified value. + equal(r'$eq'), + + /// Matches values that are greater than a specified value. + greater(r'$gt'), + + /// Matches values that are greater than a specified value. + greaterOrEqual(r'$gte'), + + /// Matches values that are less than a specified value. + less(r'$lt'), + + /// Matches values that are less than or equal to a specified value. + lessOrEqual(r'$lte'), + + /// Matches any of the values specified in an array. + in_(r'$in'), + + /// Matches values by performing text search with the specified value. + query(r'$q'), + + /// Matches values with the specified prefix. + autoComplete(r'$autocomplete'), + + /// Matches values that exist/don't exist based on the specified boolean value. + exists(r'$exists'), + + /// Matches all the values specified in an array. + and(r'$and'), + + /// Matches at least one of the values specified in an array. + or(r'$or'), + + /// Matches any list that contains the specified value + contains(r'$contains'), + + /// Matches if the value contains JSON with the given path. + pathExists(r'$path_exists'); + + const FilterOperator(this.rawValue); + final String rawValue; + + /// Returns `true` if the operator is a group operator (i.e., `and` or `or`). + bool get isGroup { + return switch (this) { + FilterOperator.and || FilterOperator.or => true, + _ => false, + }; + } +} diff --git a/packages/stream_core/lib/src/query/sort.dart b/packages/stream_core/lib/src/query/sort.dart new file mode 100644 index 0000000..ee4ccb8 --- /dev/null +++ b/packages/stream_core/lib/src/query/sort.dart @@ -0,0 +1,221 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../utils/standard.dart'; + +part 'sort.g.dart'; + +/// The direction of a sort operation. +/// +/// Defines whether a sort should be performed in ascending (forward) or +/// descending (reverse) order. +enum SortDirection { + /// Sort in ascending order (A to Z, 1 to 9, etc.). + @JsonValue(1) + asc(1), + + /// Sort in descending order (Z to A, 9 to 1, etc.). + @JsonValue(-1) + desc(-1); + + /// Creates a new [SortDirection] instance with the specified direction. + const SortDirection(this.value); + + /// The numeric value representing the sort direction. + final int value; +} + +/// Defines how null values should be ordered in a sort operation. +enum NullOrdering { + /// Null values appear at the beginning of the sorted list, + /// regardless of sort direction (ASC or DESC). + nullsFirst, + + /// Null values appear at the end of the sorted list, + /// regardless of sort direction (ASC or DESC). + nullsLast; +} + +/// Signature for a function that retrieves a sort field value of type [V] from +/// an instance of type [T]. +typedef SortFieldValueGetter = V? Function(T); + +/// A comparator function that compares two instances of type [T] based on +/// a specified field, sort direction, and null ordering. +typedef SortFieldComparator = int Function( + T? a, + T? b, + SortDirection direction, + NullOrdering nullOrdering, +); + +@JsonSerializable(createFactory: false) +class Sort { + const Sort.asc( + this.field, { + this.nullOrdering = NullOrdering.nullsLast, + }) : direction = SortDirection.asc; + + const Sort.desc( + this.field, { + this.nullOrdering = NullOrdering.nullsFirst, + }) : direction = SortDirection.desc; + + static String _fieldToJson(SortField field) => field.remote; + + @JsonKey(toJson: _fieldToJson) + final SortField field; + + @JsonKey(name: 'direction') + final SortDirection direction; + + @JsonKey(includeToJson: false, includeFromJson: false) + final NullOrdering nullOrdering; + + int compare(T? a, T? b) { + return field.comparator.call(a, b, direction, nullOrdering); + } +} + +class SortField { + factory SortField( + String remote, + SortFieldValueGetter localValue, + ) { + final comparator = SortComparator(localValue).toAny(); + return SortField._( + remote: remote, + comparator: comparator, + ); + } + + const SortField._({ + required this.remote, + required this.comparator, + }); + + final String remote; + + @JsonKey(includeToJson: false, includeFromJson: false) + final AnySortComparator comparator; +} + +/// A comparator that can sort model instances by extracting comparable values. +/// +/// This class provides the foundation for local sorting operations by wrapping +/// a lambda that extracts comparable values from model instances. It handles +/// the comparison logic and direction handling internally. +class SortComparator { + /// Creates a new [SortComparator] with the specified value extraction + /// function. + const SortComparator(this.value); + + /// The function that extracts a comparable value of type [V] from an instance + /// of type [T]. + final SortFieldValueGetter value; + + /// Compares two instances of type [T] based on the extracted values, + /// sort direction, and null ordering. + /// + /// Returns: + /// - 0 if both instances are equal + /// - 1 if the first instance is greater than the second + /// - -1 if the first instance is less than the second + /// + /// Handles null values according to the specified null ordering: + /// - `NullOrdering.nullsFirst`: Null values are considered less than any + /// non-null value, regardless of sort direction. + /// - `NullOrdering.nullsLast`: Null values are considered greater than any + /// non-null value, regardless of sort direction. + int call(T? a, T? b, SortDirection direction, NullOrdering nullOrdering) { + final aValue = a?.let(value)?.let(ComparableField.fromValue); + final bValue = b?.let(value)?.let(ComparableField.fromValue); + + // Handle nulls first, independent of sort direction + if (aValue == null && bValue == null) return 0; + if (aValue == null) return nullOrdering == NullOrdering.nullsFirst ? -1 : 1; + if (bValue == null) return nullOrdering == NullOrdering.nullsFirst ? 1 : -1; + + // Apply direction only to non-null comparisons + return direction.value * aValue.compareTo(bValue); + } + + AnySortComparator toAny() => AnySortComparator(call); +} + +/// A type-erased wrapper for sort comparators that can work with any model type. +/// +/// This class provides a way to store and use sort comparators without knowing their +/// specific generic type parameters. It's useful for creating collections of different +/// sort configurations that can all work with the same model type. +/// +/// Type erased type avoids making SortField generic while keeping the underlying +/// value type intact (no runtime type checks while sorting). +class AnySortComparator { + /// Creates a type-erased comparator from a specific comparator instance. + const AnySortComparator(this.compare); + + /// The comparator function that compares two instances of type [T]. + final SortFieldComparator compare; + + /// Compares two model instances using the wrapped comparator. + int call(T? a, T? b, SortDirection direction, NullOrdering nullOrdering) { + return compare(a, b, direction, nullOrdering); + } +} + +/// Extension that combines multiple [Sort] instances into a single comparator. +/// +/// Allows sorting of objects based on multiple criteria in sequence. +extension CompositeComparator on Iterable> { + /// Compares two objects using all sort options in sequence. + /// + /// Returns the first non-zero comparison result, or 0 if all comparisons + /// result in equality. + /// + /// ```dart + /// activities.sort(mySort.compare); + /// ``` + int compare(T? a, T? b) { + for (final comparator in this) { + final comparison = comparator.compare(a, b); + if (comparison != 0) return comparison; + } + + return 0; // All comparisons were equal + } +} + +/// A wrapper class for values that implements [Comparable]. +/// +/// This class is used to compare values of different types in a way that +/// allows for consistent ordering. +/// +/// This is useful when sorting or comparing values in a consistent manner. +/// +/// For example, when sorting a list of objects with different types of fields, +/// using this class will ensure that all values are compared correctly +/// regardless of their type. +class ComparableField implements Comparable> { + const ComparableField._(this.value); + + /// Creates a new [ComparableField] instance from a [value]. + static ComparableField? fromValue(T? value) { + if (value == null) return null; + return ComparableField._(value); + } + + /// The value to be compared. + final T value; + + @override + int compareTo(ComparableField other) { + return switch ((value, other.value)) { + (final num a, final num b) => a.compareTo(b), + (final String a, final String b) => a.compareTo(b), + (final DateTime a, final DateTime b) => a.compareTo(b), + (final bool a, final bool b) when a == b => 0, + (final bool a, final bool b) => a && !b ? 1 : -1, // true > false + _ => 0 // All comparisons were equal or incomparable types + }; + } +} diff --git a/packages/stream_core/lib/src/query/sort.g.dart b/packages/stream_core/lib/src/query/sort.g.dart new file mode 100644 index 0000000..3036cd4 --- /dev/null +++ b/packages/stream_core/lib/src/query/sort.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sort.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$SortToJson(Sort instance) => + { + 'field': Sort._fieldToJson(instance.field), + 'direction': _$SortDirectionEnumMap[instance.direction]!, + }; + +const _$SortDirectionEnumMap = { + SortDirection.asc: 1, + SortDirection.desc: -1, +}; diff --git a/packages/stream_core/lib/src/user.dart b/packages/stream_core/lib/src/user.dart index a1bee71..35050b2 100644 --- a/packages/stream_core/lib/src/user.dart +++ b/packages/stream_core/lib/src/user.dart @@ -1,3 +1,6 @@ export 'user/connect_user_details_request.dart'; +export 'user/token_manager.dart'; +export 'user/token_provider.dart'; export 'user/user.dart'; +export 'user/user_token.dart'; export 'user/ws_auth_message_request.dart'; 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 ca8a071..553ba9d 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 @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'connect_user_details_request.g.dart'; -@JsonSerializable() +@JsonSerializable(createFactory: false) class ConnectUserDetailsRequest { const ConnectUserDetailsRequest({ required this.id, @@ -10,7 +10,7 @@ class ConnectUserDetailsRequest { this.invisible, this.language, this.name, - this.customData, + this.custom, }); final String id; @@ -18,9 +18,7 @@ class ConnectUserDetailsRequest { final bool? invisible; final String? language; final String? name; - final Map? customData; + final Map? custom; Map toJson() => _$ConnectUserDetailsRequestToJson(this); - static ConnectUserDetailsRequest fromJson(Map json) => - _$ConnectUserDetailsRequestFromJson(json); } diff --git a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart index c9207ac..3b4fe26 100644 --- a/packages/stream_core/lib/src/user/connect_user_details_request.g.dart +++ b/packages/stream_core/lib/src/user/connect_user_details_request.g.dart @@ -6,17 +6,6 @@ part of 'connect_user_details_request.dart'; // JsonSerializableGenerator // ************************************************************************** -ConnectUserDetailsRequest _$ConnectUserDetailsRequestFromJson( - Map json) => - ConnectUserDetailsRequest( - id: json['id'] as String, - image: json['image'] as String?, - invisible: json['invisible'] as bool?, - language: json['language'] as String?, - name: json['name'] as String?, - customData: json['custom_data'] as Map?, - ); - Map _$ConnectUserDetailsRequestToJson( ConnectUserDetailsRequest instance) => { @@ -25,5 +14,5 @@ Map _$ConnectUserDetailsRequestToJson( 'invisible': instance.invisible, 'language': instance.language, 'name': instance.name, - 'custom_data': instance.customData, + 'custom': instance.custom, }; diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart new file mode 100644 index 0000000..aa9c6dc --- /dev/null +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -0,0 +1,95 @@ +import 'package:synchronized/extension.dart'; + +import 'token_provider.dart'; +import 'user_token.dart'; + +/// Manages user authentication tokens with caching and thread-safe access. +/// +/// Provides token caching and automatic loading for user authentication tokens. +/// Ensures thread-safe access to tokens and handles token lifecycle efficiently. +/// +/// ## Usage +/// +/// ```dart +/// final manager = TokenManager( +/// userId: 'user-123', +/// tokenProvider: TokenProvider.static(UserToken('jwt-token')), +/// ); +/// +/// // Get a token (loads and caches if needed) +/// final token = await manager.getToken(); +/// +/// // Peek at cached token without loading +/// final cachedToken = manager.peekToken(); +/// +/// // Expire the cached token +/// manager.expireToken(); +/// ``` +class TokenManager { + /// Creates a [TokenManager] for the specified [userId] with the given [tokenProvider]. + /// + /// The [userId] identifies the user for whom tokens will be managed. + /// The [tokenProvider] is used to load tokens when needed. + TokenManager({ + required this.userId, + required TokenProvider tokenProvider, + }) : _tokenProvider = tokenProvider; + + /// The unique identifier of the user whose tokens are managed. + final String userId; + + // The provider used to load tokens when needed. + final TokenProvider _tokenProvider; + set tokenProvider(TokenProvider provider) { + // If the provider changes, expire the current token. + if (_tokenProvider != provider) expireToken(); + } + + // The currently cached token, if any. + UserToken? _cachedToken; + + /// Returns the currently cached token without loading a new one. + /// + /// Returns the cached [UserToken] if available, or null if no token + /// is currently cached or if the token has been expired. + UserToken? peekToken() => _cachedToken; + + /// Whether this manager uses a static token provider. + /// + /// Returns true if the token provider is static (doesn't refresh tokens), + /// false if it's dynamic (fetches fresh tokens on each call). + bool get usesStaticProvider => _tokenProvider is StaticTokenProvider; + + /// Gets a valid token for the user, loading one if necessary. + /// + /// Returns the cached token if available, otherwise loads a new token + /// from the [TokenProvider] and caches it for future use. This method + /// is thread-safe and ensures only one token loading operation occurs + /// at a time. + /// + /// Returns a [Future] that resolves to a [UserToken] for the user. + Future getToken() async { + final snapshot = _cachedToken; + return synchronized(() async { + // If the snapshot is no longer equal to the cached token, it means + // that the token has been updated by another thread, so we use the + // updated value. + final currentToken = _cachedToken; + if (snapshot != currentToken && currentToken != null) { + return currentToken; + } + + // Otherwise, we load a new token from the provider and cache it. + final updatedToken = await _tokenProvider.loadToken(userId); + _cachedToken = updatedToken; + return updatedToken; + }); + } + + /// Expires the currently cached token. + /// + /// Clears the cached token, forcing the next call to [getToken] to + /// load a fresh token from the provider. This is useful when a token + /// becomes invalid or needs to be refreshed. + void expireToken() => _cachedToken = null; +} diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart new file mode 100644 index 0000000..1f6ddb6 --- /dev/null +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -0,0 +1,115 @@ +import 'user_token.dart'; + +/// A provider for loading user authentication tokens. +/// +/// Defines the interface for token providers that can load [UserToken] instances +/// for users. Supports static tokens (pre-defined JWT or anonymous tokens) and +/// dynamic JWT tokens (loaded via custom functions). +/// +/// ## Usage +/// +/// Create a static token provider: +/// ```dart +/// final jwtToken = UserToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); +/// final provider = TokenProvider.static(jwtToken); +/// final token = await provider.loadToken('user-123'); +/// ``` +/// +/// Create a dynamic token provider: +/// ```dart +/// final provider = TokenProvider.dynamic((userId) async { +/// return await fetchTokenFromServer(userId); +/// }); +/// final token = await provider.loadToken('user-456'); +/// ``` +abstract interface class TokenProvider { + /// Creates a static token provider with a pre-defined [token]. + /// + /// The [token] can be either a JWT token or an anonymous token that will be + /// used for a specific user. This is useful for scenarios where tokens don't + /// expire or for testing purposes. + factory TokenProvider.static(UserToken token) = StaticTokenProvider; + + /// Creates a dynamic token provider with a custom [loader] function. + /// + /// The [loader] function will be called with a user ID to fetch a fresh + /// JWT token for that user. This is useful for scenarios where JWT tokens + /// expire and need to be refreshed from external services. + factory TokenProvider.dynamic(UserTokenLoader loader) = DynamicTokenProvider; + + /// Loads a [UserToken] for the specified [userId]. + /// + /// Returns a [Future] that resolves to a [UserToken] configured for either + /// JWT authentication or anonymous access, depending on the provider type. + /// + /// Throws an [ArgumentError] if the loaded token is not valid (for JWT providers) + /// or if the 'user_id' claim is missing or empty (for JWT tokens). + Future loadToken(String userId); +} + +/// A token provider that uses a static token for a specific user. +/// +/// This implementation returns the same pre-configured token and validates +/// that the token's user ID matches the requested user ID. This ensures +/// consistent behavior for both JWT and anonymous tokens. +/// +/// Useful for scenarios where tokens don't expire, long-lived tokens, +/// or for testing purposes. +class StaticTokenProvider implements TokenProvider { + /// Creates a static token provider with the given [_rawToken]. + const StaticTokenProvider(this._rawToken); + + // The pre-configured token. + final UserToken _rawToken; + + /// Loads the static token for the specified [userId]. + /// + /// Returns a [Future] that resolves to the pre-configured [UserToken]. + /// Validates that the token's user ID matches the requested [userId] + /// for consistent behavior across both JWT and anonymous tokens. + /// + /// Throws an [ArgumentError] if the token's user ID does not match + /// the requested [userId]. + @override + Future loadToken(String userId) async { + // Validate that the token's user_id matches the requested userId + if (_rawToken.userId == userId) return _rawToken; + + throw ArgumentError( + 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', + ); + } +} + +/// A token provider that dynamically loads fresh JWT tokens using a custom function. +/// +/// This implementation uses a [UserTokenLoader] function to fetch fresh JWT tokens +/// for users when needed. The loader function is called with the user ID +/// and must return a fresh JWT token, typically used for token refresh scenarios. +class DynamicTokenProvider implements TokenProvider { + /// Creates a dynamic token provider with the given [_loader] function. + const DynamicTokenProvider(this._loader); + + // The function used to load tokens for users. + final UserTokenLoader _loader; + + /// Loads a fresh JWT token for the specified [userId] using the configured loader. + /// + /// Calls the [_loader] function with the [userId] to fetch a fresh JWT token + /// and returns the [UserToken] instance from the result. + /// + /// Returns a [Future] that resolves to a [UserToken] configured for JWT authentication. + /// + /// Throws an [ArgumentError] if the token returned by the loader is not a JWT token + /// or if the 'user_id' claim is missing or empty. + @override + Future loadToken(String userId) async { + final token = await _loader.call(userId); + // Validate that the returned token is a JWT token + if (token.authType == AuthType.jwt) return token; + + throw ArgumentError( + 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + ); + } +} diff --git a/packages/stream_core/lib/src/user/user.dart b/packages/stream_core/lib/src/user/user.dart index 91b4d15..9d022e1 100644 --- a/packages/stream_core/lib/src/user/user.dart +++ b/packages/stream_core/lib/src/user/user.dart @@ -2,31 +2,32 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import 'package:equatable/equatable.dart'; import 'package:meta/meta.dart'; /// Model for the user's info. @immutable -class User { +class User extends Equatable { /// Creates a user with the provided id. const User({ required this.id, String? name, this.imageUrl, this.role = 'user', - this.type = UserAuthType.regular, - Map? customData, + this.type = UserType.regular, + Map? custom, }) : originalName = name, - customData = customData ?? const {}; + custom = custom ?? const {}; /// Creates a guest user with the provided id. /// - Parameter userId: the id of the user. /// - Returns: a guest `User`. const User.guest(String userId) - : this(id: userId, name: userId, type: UserAuthType.guest); + : this(id: userId, name: userId, type: UserType.guest); /// Creates an anonymous user. /// - Returns: an anonymous `User`. - const User.anonymous() : this(id: '!anon', type: UserAuthType.anonymous); + const User.anonymous() : this(id: '!anon', type: UserType.anonymous); /// The user's id. final String id; @@ -38,10 +39,10 @@ class User { final String role; /// The user authorization type. - final UserAuthType type; + final UserType type; /// The user's custom data. - final Map customData; + final Map custom; /// User's name that was provided when the object was created. It will be used when communicating /// with the API and in cases where it doesn't make sense to override `null` values with the @@ -53,47 +54,18 @@ class User { String get name => originalName ?? id; @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is User && - other.id == id && - other.imageUrl == imageUrl && - other.role == role && - other.type == type && - other.originalName == originalName && - _mapEquals(other.customData, customData); - } - - bool _mapEquals(Map? a, Map? b) { - if (a == null && b == null) return true; - if (a == null || b == null) return false; - if (a.length != b.length) return false; - for (final key in a.keys) { - if (!b.containsKey(key) || a[key] != b[key]) return false; - } - return true; - } - - @override - int get hashCode { - return Object.hash( - id, - imageUrl, - role, - type, - originalName, - Object.hashAll(customData.entries), - ); - } - - @override - String toString() { - return 'User(id: $id, name: $name, imageURL: $imageUrl, role: $role, type: $type, customData: $customData)'; - } + List get props => [ + id, + imageUrl, + role, + type, + originalName, + custom, + ]; } /// The user authorization type. -enum UserAuthType { +enum UserType { /// A regular user. regular, diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart new file mode 100644 index 0000000..63fe1c6 --- /dev/null +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -0,0 +1,125 @@ +import 'package:equatable/equatable.dart'; +import 'package:jose/jose.dart'; + +/// A function that loads user tokens. +/// +/// Takes a [userId] and returns a [Future] that resolves to a [UserToken]. +/// This loader can return either JWT tokens or anonymous tokens depending +/// on the authentication requirements. Typically used to fetch tokens from +/// a backend service or authentication provider. +typedef UserTokenLoader = Future Function(String userId); + +/// A user authentication token for Stream Core API access. +/// +/// Represents user authentication credentials that can be either JWT-based +/// or anonymous. The token encapsulates the authentication type, user identity, +/// and raw token value needed for API requests. +/// +/// ## Usage +/// +/// Create a JWT token: +/// ```dart +/// final token = UserToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); +/// print(token.userId); // Extracted from JWT claims +/// ``` +/// +/// Create an anonymous token: +/// ```dart +/// final token = UserToken.anonymous(userId: 'guest-123'); +/// print(token.authType); // AuthType.anonymous +/// ``` +class UserToken extends Equatable { + /// Creates a JWT-based user token from the provided [rawValue]. + /// + /// Parses the JWT token to extract the user ID from the 'user_id' claim. + /// The token is validated for structure but not for signature verification. + /// + /// Returns a [UserToken] configured for JWT authentication. + /// + /// Throws an [ArgumentError] if the [rawValue] is not a valid JWT token + /// or if the 'user_id' claim is missing or empty. + factory UserToken(String rawValue) { + final jwtBody = JsonWebToken.unverified(rawValue); + final userId = jwtBody.claims.getTyped('user_id'); + if (userId == null || userId.isEmpty) { + throw ArgumentError.value( + rawValue, + 'rawValue', + 'Invalid JWT token: missing or empty user_id claim', + ); + } + + return UserToken._( + rawValue: rawValue, + userId: userId, + authType: AuthType.jwt, + ); + } + + /// Creates an anonymous user token. + /// + /// Creates a token for anonymous authentication with the specified [userId]. + /// When [userId] is not provided, defaults to '!anon' for anonymous users. + /// + /// Returns a [UserToken] configured for anonymous access. + factory UserToken.anonymous({String? userId}) { + return UserToken._( + rawValue: '', + userId: userId ?? '!anon', + authType: AuthType.anonymous, + ); + } + + const UserToken._({ + required this.rawValue, + required this.userId, + required this.authType, + }); + + /// The raw token value. + /// + /// For JWT tokens, contains the complete JWT string. For anonymous tokens, + /// this field is empty as no token value is required. + final String rawValue; + + /// The unique identifier of the user. + /// + /// For JWT tokens, this value is extracted from the 'user_id' claim. + /// For anonymous tokens, this can be a custom identifier or defaults to '!anon'. + final String userId; + + /// The authentication type of this token. + /// + /// Indicates whether this token uses JWT authentication or anonymous access. + final AuthType authType; + + @override + List get props => [rawValue, userId, authType]; +} + +/// Represents the types of authentication available for API access. +/// +/// Defines the authentication methods supported by the Stream Core SDK +/// for securing API requests and establishing user identity. +enum AuthType { + /// JSON Web Token authentication. + /// + /// Uses JWT tokens for authenticated requests with user identity verification. + /// The token contains user claims and is validated by the server. + jwt('jwt'), + + /// Anonymous authentication. + /// + /// Allows unauthenticated access with limited permissions. + /// Used for public content access or guest user scenarios. + anonymous('anonymous'); + + /// Constructs an [AuthType] with the associated header value. + const AuthType(this.headerValue); + + /// The string value used in authentication headers. + /// + /// This value is sent in HTTP headers to identify the authentication + /// method being used for API requests. + final String headerValue; +} diff --git a/packages/stream_core/lib/src/user/ws_auth_message_request.dart b/packages/stream_core/lib/src/user/ws_auth_message_request.dart index 0fb925b..03b3ecc 100644 --- a/packages/stream_core/lib/src/user/ws_auth_message_request.dart +++ b/packages/stream_core/lib/src/user/ws_auth_message_request.dart @@ -1,14 +1,12 @@ -import 'dart:convert'; - import 'package:json_annotation/json_annotation.dart'; -import '../user.dart'; -import '../ws/events/sendable_event.dart'; +import '../ws.dart'; +import 'connect_user_details_request.dart'; part 'ws_auth_message_request.g.dart'; -@JsonSerializable() -class WsAuthMessageRequest implements SendableEvent { +@JsonSerializable(createFactory: false) +class WsAuthMessageRequest extends WsRequest { const WsAuthMessageRequest({ this.products, required this.token, @@ -19,11 +17,9 @@ class WsAuthMessageRequest implements SendableEvent { final String token; final ConnectUserDetailsRequest? userDetails; - Map toJson() => _$WsAuthMessageRequestToJson(this); - - static WsAuthMessageRequest fromJson(Map json) => - _$WsAuthMessageRequestFromJson(json); + @override + List get props => [products, token, userDetails]; @override - Object toSerializedData() => json.encode(toJson()); + Map toJson() => _$WsAuthMessageRequestToJson(this); } diff --git a/packages/stream_core/lib/src/user/ws_auth_message_request.g.dart b/packages/stream_core/lib/src/user/ws_auth_message_request.g.dart index 9c6e57f..9e4f2ae 100644 --- a/packages/stream_core/lib/src/user/ws_auth_message_request.g.dart +++ b/packages/stream_core/lib/src/user/ws_auth_message_request.g.dart @@ -6,23 +6,13 @@ part of 'ws_auth_message_request.dart'; // JsonSerializableGenerator // ************************************************************************** -WsAuthMessageRequest _$WsAuthMessageRequestFromJson( - Map json) => - WsAuthMessageRequest( - products: (json['products'] as List?) - ?.map((e) => e as String) - .toList(), - token: json['token'] as String, - userDetails: json['user_details'] == null - ? null - : ConnectUserDetailsRequest.fromJson( - json['user_details'] as Map), - ); - Map _$WsAuthMessageRequestToJson( WsAuthMessageRequest instance) => { + 'stringify': instance.stringify, + 'hash_code': instance.hashCode, 'products': instance.products, 'token': instance.token, 'user_details': instance.userDetails?.toJson(), + 'props': instance.props, }; diff --git a/packages/stream_core/lib/src/utils.dart b/packages/stream_core/lib/src/utils.dart index c59af26..961cea5 100644 --- a/packages/stream_core/lib/src/utils.dart +++ b/packages/stream_core/lib/src/utils.dart @@ -1,3 +1,9 @@ -export 'utils/network_monitor.dart'; +export 'utils/app_lifecycle_state_provider.dart'; +export 'utils/comparable_extensions.dart'; +export 'utils/disposable.dart'; +export 'utils/list_extensions.dart'; +export 'utils/network_state_provider.dart'; export 'utils/result.dart'; export 'utils/shared_emitter.dart'; +export 'utils/standard.dart'; +export 'utils/state_emitter.dart'; diff --git a/packages/stream_core/lib/src/utils/app_lifecycle_state_provider.dart b/packages/stream_core/lib/src/utils/app_lifecycle_state_provider.dart new file mode 100644 index 0000000..13ca5d7 --- /dev/null +++ b/packages/stream_core/lib/src/utils/app_lifecycle_state_provider.dart @@ -0,0 +1,22 @@ +import 'state_emitter.dart'; + +typedef AppLifecycleStateEmitter = StateEmitter; + +/// A utility class for monitoring application lifecycle state changes. +/// +/// This interface defines the contract for an application lifecycle state provider +/// that can provide the current state of the application and a stream of state changes. +abstract interface class AppLifecycleStateProvider { + /// A emitter that provides updates on the application lifecycle state. + AppLifecycleStateEmitter get state; +} + +/// Enum representing the lifecycle state of the application. +/// +/// This enum defines two possible states for the application: +/// `foreground` and `background`. +enum AppLifecycleState { + foreground, + + background, +} diff --git a/packages/stream_core/lib/src/utils/comparable_extensions.dart b/packages/stream_core/lib/src/utils/comparable_extensions.dart new file mode 100644 index 0000000..43fa8c8 --- /dev/null +++ b/packages/stream_core/lib/src/utils/comparable_extensions.dart @@ -0,0 +1,14 @@ +/// Extension for comparing objects that implement the Comparable interface. +extension ComparableExtension> on T { + /// Whether this object is less than the [other] object. + bool operator <(T other) => compareTo(other) < 0; + + /// Whether this object is less than or equal to the [other] object. + bool operator <=(T other) => compareTo(other) <= 0; + + /// Whether this object is greater than the [other] object. + bool operator >(T other) => compareTo(other) > 0; + + /// Whether this object is greater than or equal to the [other] object. + bool operator >=(T other) => compareTo(other) >= 0; +} diff --git a/packages/stream_core/lib/src/utils/disposable.dart b/packages/stream_core/lib/src/utils/disposable.dart new file mode 100644 index 0000000..1ef2462 --- /dev/null +++ b/packages/stream_core/lib/src/utils/disposable.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +/// A convenience class to represent a disposable object. +mixin class Disposable { + /// Returns `true` if this object has been disposed. + bool get isDisposed => _disposed; + bool _disposed = false; + + /// Disposes of this object. + @mustCallSuper + @mustBeOverridden + FutureOr dispose() async { + if (_disposed) return; + _disposed = true; + } +} diff --git a/packages/stream_core/lib/src/utils/list_extensions.dart b/packages/stream_core/lib/src/utils/list_extensions.dart new file mode 100644 index 0000000..a639a2c --- /dev/null +++ b/packages/stream_core/lib/src/utils/list_extensions.dart @@ -0,0 +1,449 @@ +import 'package:collection/collection.dart'; + +import 'standard.dart'; + +/// Extensions for basic iterable operations that work with any object type. +extension IterableExtensions on Iterable { + /// Returns the sum of all values produced by [selector] function applied to + /// each element in the collection. + int sumOf(int Function(T element) selector) { + return fold(0, (sum, element) => sum + selector(element)); + } +} + +/// Extensions for basic list operations that work with any object type. +/// +/// These extensions return new lists rather than modifying existing ones, +/// following immutable patterns for safer concurrent programming. +extension ListExtensions on List { + /// Inserts or replaces an element in the list based on a key. + /// + /// If an element with the same key already exists, it will be replaced. + /// Otherwise, the new element will be appended to the end of the list. + /// Time complexity: O(n) for search, O(n) for list creation. + /// + /// ```dart + /// final users = [User(id: '1', name: 'Alice'), User(id: '2', name: 'Bob')]; + /// final updated = users.upsert( + /// User(id: '1', name: 'Alice Updated'), + /// key: (user) => user.id, + /// ); + /// // Result: [User(id: '1', name: 'Alice Updated'), User(id: '2', name: 'Bob')] + /// + /// // Adding new element + /// final withNew = users.upsert( + /// User(id: '3', name: 'Charlie'), + /// key: (user) => user.id, + /// ); + /// // Result: [User(id: '1', name: 'Alice'), User(id: '2', name: 'Bob'), User(id: '3', name: 'Charlie')] + /// ``` + List upsert( + T element, { + required K Function(T item) key, + }) { + final index = indexWhere((e) => key(e) == key(element)); + + // Add the element if it does not exist + if (index == -1) return [...this, element]; + + // Otherwise, replace the existing element at the found index + return [...this].also((it) => it[index] = element); + } + + List batchReplace( + List other, { + required K Function(T item) key, + }) { + if (isEmpty || other.isEmpty) return this; + + final lookup = {for (final item in other) key(item): item}; + + // Find replacements - enumerate existing items and check lookup + final replacements = <(int, T)>[]; + for (var index = 0; index < length; index++) { + final existing = this[index]; + final updated = lookup[key(existing)]; + if (updated != null) { + replacements.add((index, updated)); + } + } + + // Create result list and apply replacements + final result = [...this]; + for (final (index, replacement) in replacements) { + result[index] = replacement; + } + + return result; + } +} + +/// Extensions for operations on sorted lists. +/// +/// These extensions maintain list order and provide efficient operations +/// for sorted collections using binary search algorithms where applicable. +extension SortedListExtensions on List { + /// Inserts an element into a sorted list at the correct position. + /// + /// Uses binary search to find the insertion point and inserts the element + /// while maintaining the sorted order. Uses stable insertion behavior where + /// new elements are inserted after existing equal elements. + /// Time complexity: O(log n) for search, O(n) for insertion. + /// + /// ```dart + /// final numbers = [1, 3, 5, 7]; + /// final result = numbers.sortedInsert(4, compare: (a, b) => a.compareTo(b)); + /// // Result: [1, 3, 4, 5, 7] + /// + /// final names = ['Alice', 'Charlie']; + /// final withBob = names.sortedInsert('Bob', compare: (a, b) => a.compareTo(b)); + /// // Result: ['Alice', 'Bob', 'Charlie'] + /// + /// // Stable insertion: new elements go after existing equal elements + /// final users = [User(age: 20, name: 'Alice'), User(age: 25, name: 'Bob')]; + /// final withCharlie = users.sortedInsert(User(age: 20, name: 'Charlie'), compare: (a, b) => a.age.compareTo(b.age)); + /// // Result: [User(age: 20, name: 'Alice'), User(age: 20, name: 'Charlie'), User(age: 25, name: 'Bob')] + /// ``` + List sortedInsert( + T element, { + required Comparator compare, + }) { + final insertionIndex = _upperBound(this, element, compare); + return [...this].also((it) => it.insert(insertionIndex, element)); + } + + // Finds the first position where all elements before it compare less than [element]. + // This implements upperBound behavior for stable insertion. + static int _upperBound(List list, T element, Comparator compare) { + var start = 0; + var end = list.length; + + while (start < end) { + final mid = start + ((end - start) >> 1); + final comparison = compare(list[mid], element); + + if (comparison <= 0) { + // list[mid] <= element, so insertion point is after mid + start = mid + 1; + } else { + // list[mid] > element, so insertion point is at or before mid + end = mid; + } + } + + return start; + } + + /// Inserts or replaces an element in a sorted list based on a key. + /// + /// First searches for an existing element with the same key. If found, + /// replaces it and re-sorts the list. If not found, inserts the element + /// at the correct sorted position using binary search. + /// Time complexity: O(n) for key search + O(n log n) for sorting if replacing, + /// O(log n) for binary search + O(n) for insertion if adding new. + /// + /// ```dart + /// final users = [ + /// User(id: '1', name: 'Alice', score: 100), + /// User(id: '3', name: 'Charlie', score: 80) + /// ]; + /// + /// // Replace existing user + /// final updated = users.sortedUpsert( + /// User(id: '1', name: 'Alice', score: 150), + /// key: (user) => user.id, + /// compare: (a, b) => b.score.compareTo(a.score), // Sort by score desc + /// ); + /// // Result: [User(id: '1', score: 150), User(id: '3', score: 80)] + /// + /// // Add new user + /// final withNew = users.sortedUpsert( + /// User(id: '2', name: 'Bob', score: 90), + /// key: (user) => user.id, + /// compare: (a, b) => b.score.compareTo(a.score), + /// ); + /// // Result: [User(id: '1', score: 100), User(id: '2', score: 90), User(id: '3', score: 80)] + /// ``` + List sortedUpsert( + T element, { + required K Function(T item) key, + required Comparator compare, + }) { + final index = indexWhere((e) => key(e) == key(element)); + + // If the element does not exist, insert it at the correct position + if (index == -1) return sortedInsert(element, compare: compare); + + // Otherwise, replace the existing element at the found index + // and re-sort the list if necessary. + + final updatedList = [...this]; + + updatedList.removeAt(index); + updatedList.sortedInsert(element, compare: compare); + + return updatedList; + } + + /// Merges this list with another list, handling duplicates based on a key. + /// + /// Elements from both lists are combined into a map using the provided key + /// function. Duplicates are resolved by the `update` callback, defaulting + /// to preferring the element from the `other` list. The result can + /// optionally be sorted. Time complexity: O(n + m) for merging + O(k log k) + /// for sorting if compare is provided, where n, m are list sizes and k is result size. + /// + /// ```dart + /// final oldScores = [ + /// Score(userId: '1', points: 100), + /// Score(userId: '2', points: 80), + /// ]; + /// final newScores = [ + /// Score(userId: '1', points: 50), // Update existing + /// Score(userId: '3', points: 120), // New user + /// ]; + /// + /// // Default behavior: prefer new values + /// final merged = oldScores.merge( + /// newScores, + /// key: (score) => score.userId, + /// compare: (a, b) => b.points.compareTo(a.points), // Sort by points desc + /// ); + /// // Result: [Score(userId: '3', points: 120), Score(userId: '1', points: 50), Score(userId: '2', points: 80)] + /// + /// // Custom merge logic: add points together + /// final combined = oldScores.merge( + /// newScores, + /// key: (score) => score.userId, + /// compare: (a, b) => b.points.compareTo(a.points), + /// update: (original, updated) => Score( + /// userId: original.userId, + /// points: original.points + updated.points, + /// ), + /// ); + /// // Result: [Score(userId: '1', points: 150), Score(userId: '3', points: 120), Score(userId: '2', points: 80)] + /// ``` + List merge( + List other, { + required K Function(T item) key, + T Function(T original, T updated)? update, + Comparator? compare, + }) { + if (other.isEmpty) return this; + + T handleUpdate(T original, T updated) { + if (update != null) return update(original, updated); + return updated; // Default behavior: prefer the updated + } + + final itemMap = {for (final item in this) key(item): item}; + + for (final item in other) { + itemMap.update( + key(item), + (original) => handleUpdate(original, item), + ifAbsent: () => item, + ); + } + + final result = itemMap.values; + return compare?.let(result.sorted) ?? result.toList(); + } + + /// Recursively removes elements from a nested tree structure. + /// + /// Searches for elements matching the test condition at any level of + /// nesting. When an element is found and removed, parent elements are + /// updated through the provided callback functions. Uses copy-on-write to + /// avoid unnecessary object creation. Time complexity: O(n * d) where n is + /// total number of nodes and d is average depth. + /// + /// ```dart + /// final comments = [ + /// Comment( + /// id: '1', + /// text: 'Great post!', + /// author: 'alice', + /// replies: [ + /// Comment(id: '2', text: 'Thanks!', author: 'bob', replies: []), + /// Comment( + /// id: '3', + /// text: 'I disagree', + /// author: 'charlie', + /// replies: [ + /// Comment(id: '4', text: 'Why?', author: 'alice', replies: []), + /// Comment(id: '5', text: 'Spam message', author: 'spammer', replies: []), + /// ], + /// ), + /// ], + /// ), + /// ]; + /// + /// // Remove spam comment from nested replies + /// final cleaned = comments.removeNested( + /// (comment) => comment.author == 'spammer', + /// children: (comment) => comment.replies, + /// update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), + /// ); + /// // Result: Spam comment removed, parent comments updated with modifiedAt timestamp + /// + /// // Remove entire comment thread + /// final withoutThread = comments.removeNested( + /// (comment) => comment.text == 'I disagree', + /// children: (comment) => comment.replies, + /// update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), + /// ); + /// // Result: Entire disagreement thread removed + /// ``` + List removeNested( + bool Function(T element) test, { + required List Function(T) children, + required T Function(T, List) updateChildren, + }) { + if (isEmpty) return this; + + final index = indexWhere(test); + // Try to remove the element at the root level if it matches the test + if (index != -1) return [...this].apply((it) => it.removeAt(index)); + + // Otherwise, recurse into children; copy-on-write only if something changes. + for (var i = 0; i < length; i++) { + final parent = this[i]; + final kids = children(parent); + if (kids.isEmpty) continue; + + final newKids = kids.removeNested( + test, + children: children, + updateChildren: updateChildren, + ); + + if (!identical(newKids, kids)) { + // If children were updated, rebuild the parent and apply update hook. + final rebuilt = updateChildren(parent, newKids); + return [...this].apply((it) => it[i] = rebuilt); + } + } + + // If no changes were made, return the original list + return this; + } + + /// Recursively updates elements in a nested tree structure. + /// + /// Searches for an element with a matching key at any level of nesting. + /// When found, the element is updated and parent elements are rebuilt + /// through the provided callback functions. Uses copy-on-write to avoid + /// unnecessary object creation. Time complexity: O(n * d) where n is + /// total number of nodes and d is average depth. + /// + /// ```dart + /// final post = [ + /// Comment( + /// id: '1', + /// text: 'What do you think about the new Flutter release?', + /// author: 'flutter_dev', + /// upvotes: 45, + /// replies: [ + /// Comment( + /// id: '2', + /// text: 'Love the performance improvements!', + /// author: 'mobile_dev', + /// upvotes: 12, + /// replies: [ + /// Comment( + /// id: '3', + /// text: 'Agreed, much faster now', + /// author: 'senior_dev', + /// upvotes: 8, + /// replies: [], + /// ), + /// ], + /// ), + /// Comment( + /// id: '4', + /// text: 'Still has some bugs', + /// author: 'skeptic_user', + /// upvotes: 3, + /// replies: [], + /// ), + /// ], + /// ), + /// ]; + /// + /// // User upvotes a deeply nested comment + /// final upvotedComment = Comment( + /// id: '3', + /// text: 'Agreed, much faster now', + /// author: 'senior_dev', + /// upvotes: 9, // Incremented + /// replies: [], + /// ); + /// final updated = post.updateNested( + /// upvotedComment, + /// key: (comment) => comment.id, + /// children: (comment) => comment.replies, + /// update: (comment) => comment, // Use the updated comment as-is + /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), + /// ); + /// // Result: Deeply nested comment upvote count updated + /// + /// // Edit comment text + /// final editedComment = Comment(id: '4', text: 'Actually, bugs are fixed now', author: 'skeptic_user', upvotes: 3); + /// final withEdit = post.updateNested( + /// editedComment, + /// key: (comment) => comment.id, + /// children: (comment) => comment.replies, + /// update: (comment) => comment.copyWith(editedAt: DateTime.now()), // Mark as edited + /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), + /// compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort replies by upvotes + /// ); + /// // Result: Comment text updated, marked as edited, replies sorted by upvotes + /// ``` + List updateNested( + T element, { + required K Function(T item) key, + required List Function(T) children, + required T Function(T) update, + required T Function(T, List) updateChildren, + Comparator? compare, + }) { + if (isEmpty) return this; + + final index = indexWhere((e) => key(e) == key(element)); + // If the element is found at the root level, update and sort the list + if (index != -1) { + final updatedElement = update(element); + final updated = [...this].apply((it) => it[index] = updatedElement); + return compare?.let(updated.sorted) ?? updated; + } + + // Otherwise, recurse into children; copy-on-write only if something changes. + for (var i = 0; i < length; i++) { + final parent = this[i]; + final kids = children(parent); + if (kids.isEmpty) continue; + + final newKids = kids.updateNested( + element, + key: key, + children: children, + update: update, + updateChildren: updateChildren, + compare: compare, + ); + + if (!identical(newKids, kids)) { + // If children were updated, rebuild the parent. + final rebuilt = updateChildren(parent, newKids); + final updated = [...this].apply((it) => it[i] = rebuilt); + return compare?.let(updated.sorted) ?? updated; + } + } + + // If no changes were made, return the original list + return this; + } +} diff --git a/packages/stream_core/lib/src/utils/network_monitor.dart b/packages/stream_core/lib/src/utils/network_monitor.dart deleted file mode 100644 index 0a246e1..0000000 --- a/packages/stream_core/lib/src/utils/network_monitor.dart +++ /dev/null @@ -1,12 +0,0 @@ -abstract class NetworkMonitor { - NetworkStatus get currentStatus; - Stream get onStatusChange; -} - -enum NetworkStatus { - /// Internet is available because at least one of the HEAD requests succeeded. - connected, - - /// None of the HEAD requests succeeded. Basically, no internet. - disconnected, -} diff --git a/packages/stream_core/lib/src/utils/network_state_provider.dart b/packages/stream_core/lib/src/utils/network_state_provider.dart new file mode 100644 index 0000000..ed31216 --- /dev/null +++ b/packages/stream_core/lib/src/utils/network_state_provider.dart @@ -0,0 +1,24 @@ +import 'state_emitter.dart'; + +typedef NetworkStateEmitter = StateEmitter; + +/// A utility class for monitoring network connectivity changes. +/// +/// This interface defines the contract for a network monitor that can provide +/// the current network state and a stream of state changes. +abstract interface class NetworkStateProvider { + /// A emitter that provides updates on the network state. + NetworkStateEmitter get state; +} + +/// Enum representing the state of network connectivity. +/// +/// This enum defines two possible values to represent the state of network +/// connectivity: `connected` and `disconnected`. +enum NetworkState { + /// Internet is available because at least one of the HEAD requests succeeded. + connected, + + /// None of the HEAD requests succeeded. Basically, no internet. + disconnected, +} diff --git a/packages/stream_core/lib/src/utils/result.dart b/packages/stream_core/lib/src/utils/result.dart index 3dcadcd..4f6c639 100644 --- a/packages/stream_core/lib/src/utils/result.dart +++ b/packages/stream_core/lib/src/utils/result.dart @@ -1,29 +1,31 @@ -import 'package:equatable/equatable.dart'; +import 'dart:async'; -enum _ResultType { success, failure } +import 'package:equatable/equatable.dart'; -/// A class which encapsulates a successful outcome with a value of type [T] -/// or a [Failure] with error. -abstract class Result extends Equatable { - const Result._(this._type); +/// A discriminated union that encapsulates a successful outcome with a value of type [T] +/// or a failure with an arbitrary [Object] error. +sealed class Result extends Equatable { + const Result._(); 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; + /// Returns `true` if this instance represents a successful outcome. + /// In this case [isFailure] returns `false`. + bool get isSuccess => this is Success; - /// Checks if the result is a [Success]. - bool get isSuccess => _type == _ResultType.success; - - /// Check if the result is a [Failure]. - bool get isFailure => _type == _ResultType.failure; + /// Returns `true` if this instance represents a failed outcome. + /// In this case [isSuccess] returns `false`. + bool get isFailure => this is Failure; } /// Represents successful result. class Success extends Result { - const Success._(this.data) : super._(_ResultType.success); + const Success._(this.data) : super._(); /// The [T] data associated with the result. final T data; @@ -32,14 +34,12 @@ class Success extends Result { List get props => [data]; @override - String toString() { - return 'Result.Success{data: $data}'; - } + String toString() => 'Result.Success{data: $data}'; } /// Represents failed result. class Failure extends Result { - const Failure._(this.error, [this.stackTrace]) : super._(_ResultType.failure); + const Failure._(this.error, [this.stackTrace]) : super._(); /// The [error] associated with the result. final Object error; @@ -51,121 +51,238 @@ class Failure extends Result { List get props => [error, stackTrace]; @override - String toString() { - return 'Result.Failure{error: $error, stackTrace: $stackTrace}'; - } + String toString() => 'Result.Failure{error: $error, stackTrace: $stackTrace}'; } extension PatternMatching on Result { - /// The [when] method is the equivalent to pattern matching. - /// Its prototype depends on the _Result [_type]s defined. - R when({ - required R Function(T data) success, - required R Function(Object error) failure, - }) { - switch (_type) { - case _ResultType.success: - return success((this as Success).data); - case _ResultType.failure: - return failure((this as Failure).error); - } + /// Returns the encapsulated value if this instance represents [Success] or `null` + /// if it is [Failure]. + /// + /// This function is a shorthand for `getOrElse(() => null)` or + /// `fold(onSuccess: (it) => it, onFailure: (_) => null)`. + T? getOrNull() { + return switch (this) { + Success(:final data) => data, + Failure() => null, + }; } - /// The [whenOrElse] method is equivalent to [when], but doesn't require - /// all callbacks to be specified. + /// Returns the encapsulated [Object] error if this instance represents [Failure] or `null` + /// if it is [Success]. /// - /// On the other hand, it adds an extra orElse required parameter, - /// for fallback behavior. - R whenOrElse({ - R Function(T data)? success, - R Function(Object error)? failure, - required R Function(Result) orElse, - }) { - switch (_type) { - case _ResultType.success: - return success?.call((this as Success).data) ?? orElse(this); - case _ResultType.failure: - return failure?.call((this as Failure).error) ?? orElse(this); - } + /// This function is a shorthand for `fold(onSuccess: (_) => null, onFailure: (error) => error)`. + Object? exceptionOrNull() { + return switch (this) { + Success() => null, + Failure(:final error) => error, + }; } - /// The [whenOrNull] method is equivalent to [whenOrElse], - /// but non-exhaustive. - R? whenOrNull({ - R Function(T data)? success, - R Function(Object error)? failure, - }) { - switch (_type) { - case _ResultType.success: - return success?.call((this as Success).data); - case _ResultType.failure: - return failure?.call((this as Failure).error); - } + /// Returns the encapsulated [StackTrace] if this instance represents [Failure] or `null` + /// if it is [Success]. + StackTrace? stackTraceOrNull() { + return switch (this) { + Success() => null, + Failure(:final stackTrace) => stackTrace, + }; } - /// The [map] method is the equivalent to pattern matching. - /// Its prototype depends on the _Result [_type]s defined. - Result map(R Function(T data) convert) { - switch (_type) { - case _ResultType.success: - final origin = this as Success; - return Result.success(convert(origin.data)); - case _ResultType.failure: - return this as Failure; - } + /// Returns the encapsulated value if this instance represents [Success] or throws the encapsulated error + /// if it is [Failure]. + /// + /// This function is a shorthand for `getOrElse((error) => throw error)`. + T getOrThrow() { + return switch (this) { + Success(:final data) => data, + Failure(:final error, :final stackTrace) => + Error.throwWithStackTrace(error, stackTrace ?? StackTrace.current), + }; } - /// The [fold] method is the equivalent to pattern matching. - /// Its prototype depends on the _Result [_type]s defined. - R fold({ - required R Function(Success success) success, - required R Function(Failure failure) failure, - }) { - switch (_type) { - case _ResultType.success: - return success(this as Success); - case _ResultType.failure: - return failure(this as Failure); - } + /// Returns the encapsulated value if this instance represents [Success] or the + /// result of [onFailure] function for the encapsulated error if it is [Failure]. + /// + /// Note, that this function rethrows any error thrown by [onFailure] function. + /// + /// This function is a shorthand for `fold(onSuccess: (it) => it, onFailure: onFailure)`. + R getOrElse(R Function(Object error, StackTrace? stackTrace) onFailure) { + return switch (this) { + Success(:final data) => data as R, + Failure(:final error, :final stackTrace) => onFailure(error, stackTrace), + }; } - /// The [foldOrElse] method is equivalent to [fold], but doesn't require - /// all callbacks to be specified. + /// Returns the encapsulated value if this instance represents [Success] or the + /// [defaultValue] if it is [Failure]. /// - /// On the other hand, it adds an extra orElse required parameter, - /// for fallback behavior. - R foldOrElse({ - R Function(Success success)? success, - R Function(Failure failure)? failure, - required R Function(Result) orElse, + /// This function is a shorthand for `getOrElse((_) => defaultValue)`. + R getOrDefault(R defaultValue) { + return switch (this) { + Success(:final data) => data as R, + Failure() => defaultValue, + }; + } + + /// Returns the result of [onSuccess] for the encapsulated value if this instance represents [Success] + /// or the result of [onFailure] function for the encapsulated error if it is [Failure]. + /// + /// Note, that this function rethrows any error thrown by [onSuccess] or by [onFailure] function. + R fold({ + required R Function(T value) onSuccess, + required R Function(Object error, StackTrace? stackTrace) onFailure, }) { - switch (_type) { - case _ResultType.success: - return success?.call(this as Success) ?? orElse(this); - case _ResultType.failure: - return failure?.call(this as Failure) ?? orElse(this); + return switch (this) { + Success(:final data) => onSuccess(data), + Failure(:final error, :final stackTrace) => onFailure(error, stackTrace), + }; + } + + /// Returns the encapsulated result of the given [transform] function applied to the encapsulated value + /// if this instance represents [Success] or the original encapsulated error if it is [Failure]. + /// + /// Note, that this function rethrows any error thrown by [transform] function. + /// See [mapCatching] for an alternative that encapsulates errors. + Result map(R Function(T value) transform) { + return switch (this) { + Success(:final data) => Result.success(transform(data)), + final Failure f => f, + }; + } + + /// Asynchronously returns the encapsulated result of the given [transform] function applied to the encapsulated value + /// if this instance represents [Success] or the original encapsulated error if it is [Failure]. + /// + /// Note, that this function rethrows any error thrown by [transform] function. + /// See [mapCatching] for an alternative that encapsulates errors. + Future> mapAsync(Future Function(T value) transform) { + return switch (this) { + Success(:final data) => runSafely(() => transform(data)), + final Failure f => Future.value(f), + }; + } + + /// Returns the encapsulated result of the given [transform] function applied to the encapsulated value + /// if this instance represents [Success] or the original encapsulated error if it is [Failure]. + /// + /// This function catches any error thrown by [transform] function and encapsulates it as a failure. + /// See [map] for an alternative that rethrows errors from [transform] function. + Result mapCatching(R Function(T value) transform) { + return switch (this) { + Success(:final data) => runSafelySync(() => transform(data)), + final Failure f => f, + }; + } + + /// Returns the encapsulated result of the given [transform] function applied to the encapsulated error + /// if this instance represents [Failure] or the original encapsulated value if it is [Success]. + /// + /// Note, that this function rethrows any error thrown by [transform] function. + /// See [recoverCatching] for an alternative that encapsulates errors. + Result recover( + R Function(Object error, StackTrace? stackTrace) transform, + ) { + return switch (this) { + Success(:final data) => Result.success(data as R), + Failure(:final error, :final stackTrace) => Result.success( + transform(error, stackTrace), + ), + }; + } + + /// Returns the encapsulated result of the given [transform] function applied to the encapsulated error + /// if this instance represents [Failure] or the original encapsulated value if it is [Success]. + /// + /// This function catches any error thrown by [transform] function and encapsulates it as a failure. + /// See [recover] for an alternative that rethrows errors. + Result recoverCatching( + R Function(Object error, StackTrace? stackTrace) transform, + ) { + return switch (this) { + Success(:final data) => Result.success(data as R), + Failure(:final error, :final stackTrace) => runSafelySync( + () => transform(error, stackTrace), + ), + }; + } + + /// Performs the given [action] on the encapsulated error if this instance represents [Failure]. + /// Returns the original [Result] unchanged. + Result onFailure( + void Function(Object error, StackTrace? stackTrace) action, + ) { + if (this case Failure(:final error, :final stackTrace)) { + action(error, stackTrace); } + return this; } - /// The [foldOrNull] method is equivalent to [whenOrElse], - /// but non-exhaustive. - R? foldOrNull({ - R Function(Success success)? success, - R Function(Failure failure)? failure, - }) { - switch (_type) { - case _ResultType.success: - return success?.call(this as Success); - case _ResultType.failure: - return failure?.call(this as Failure); + /// Performs the given [action] on the encapsulated value if this instance represents [Success]. + /// Returns the original [Result] unchanged. + Result onSuccess(void Function(T value) action) { + if (this case Success(:final data)) { + action(data); } + return this; } - /// Returns the encapsulated value if this instance represents success - /// or null of it is failure. - T? getDataOrNull() => whenOrNull(success: _identity); + /// Flattens a nested [Result] of [Result] to a single [Result]. + /// Only works when [T] is of type [Result]. + Result flatten() { + if (this case Success>(:final data)) { + return data; + } + return this as Result; + } + + /// Transforms this [Result] to [Result] by applying [transform] if this is a [Success], + /// or returns the [Failure] unchanged if this is a [Failure]. + /// + /// This is similar to [map] but the [transform] function returns a [Result] instead of [R]. + Result flatMap(Result Function(T value) transform) { + return switch (this) { + Success(:final data) => transform(data), + final Failure f => f, + }; + } + + /// Asynchronously transforms this [Result] to [Result] by applying [transform] if this is a [Success], + /// or returns the [Failure] unchanged if this is a [Failure]. + /// + /// This is similar to [flatMap] but the [transform] function returns a [Future>] instead of [Result]. + Future> flatMapAsync( + Future> Function(T value) transform, + ) { + return switch (this) { + Success(:final data) => transform(data), + final Failure f => Future.value(f), + }; + } +} - Object? getErrorOrNull() => whenOrNull(failure: _identity); +/// Runs a block of code and returns a [Result] containing the outcome. +/// +/// If the block completes successfully, the result is a success with the value +/// returned by the block. Otherwise, if an exception is thrown, it is caught +/// and wrapped in a failure result. +Future> runSafely(FutureOr Function() block) async { + try { + final result = await block(); + return Result.success(result); + } catch (e, stackTrace) { + return Result.failure(e, stackTrace); + } } -T _identity(T x) => x; +/// Runs a block of code and returns a [Result] containing the outcome. +/// +/// If the block completes successfully, the result is a success with the value +/// returned by the block. Otherwise, if an exception is thrown, it is caught +/// and wrapped in a failure result. +Result runSafelySync(R Function() block) { + try { + final result = block(); + return Result.success(result); + } catch (e, stackTrace) { + return Result.failure(e, stackTrace); + } +} diff --git a/packages/stream_core/lib/src/utils/shared_emitter.dart b/packages/stream_core/lib/src/utils/shared_emitter.dart index cf4acfd..2fd8f79 100644 --- a/packages/stream_core/lib/src/utils/shared_emitter.dart +++ b/packages/stream_core/lib/src/utils/shared_emitter.dart @@ -2,76 +2,199 @@ import 'dart:async'; import 'package:rxdart/rxdart.dart'; -abstract class SharedEmitter { - Future waitFor({ - required Duration timeLimit, - }); - - StreamSubscription on(void Function(E event) onEvent); - +/// A read-only emitter that allows listening for events of type [T]. +/// +/// Listeners can subscribe to receive events, wait for specific event types, +/// and register handlers for certain event types. The emitter supports +/// type filtering, allowing listeners to only receive events of a specific +/// subtype of [T]. +/// +/// See also: +/// - [MutableSharedEmitter] for the mutable interface that allows emitting events. +abstract interface class SharedEmitter { + /// The stream of events emitted by this emitter. + /// + /// Returns a [Stream] that emits events of type [T]. + Stream get stream; + + /// Waits for an event of type [E] to be emitted within the specified [timeLimit]. + /// + /// If such an event is emitted, it is returned. If the time limit + /// is exceeded without receiving the event, a [TimeoutException] is thrown. + /// + /// Returns a [Future] that completes with the first event of type [E]. + Future waitFor({Duration? timeLimit}); + + /// Registers a handler [onEvent] that will be invoked whenever an event of type [E] is emitted. + /// + /// Returns a [StreamSubscription] that can be used to manage the subscription. + StreamSubscription on( + void Function(E event) onEvent, + ); + + /// Returns the first element that satisfies the given [test]. + /// + /// If no such element is found and [orElse] is provided, calls [orElse] and returns its result. + /// If no element is found and [orElse] is not provided, throws a [StateError]. + /// + /// Returns a [Future] that completes with the first matching element. Future firstWhere( bool Function(T element) test, { - required Duration timeLimit, + T Function()? orElse, }); - /// Adds a subscription to this emitter. + /// Subscribes to the emitter to receive events of type [T]. + /// + /// The [onData] callback is invoked for each emitted value. + /// Optional callbacks for [onError], [onDone], and [cancelOnError] can also be provided. + /// + /// Returns a [StreamSubscription] that can be used to manage the subscription. StreamSubscription listen( void Function(T value)? onData, { Function? onError, void Function()? onDone, bool? cancelOnError, }); - - Stream asStream(); } -abstract class MutableSharedEmitter extends SharedEmitter { +/// A mutable emitter that allows emitting events of type [T] to multiple listeners. +/// +/// Listeners can subscribe to receive events, wait for specific event types, +/// and register handlers for certain event types. The emitter supports +/// type filtering, allowing listeners to only receive events of a specific +/// subtype of [T]. +/// +/// The emitter can be closed to release resources, after which no further +/// events can be emitted or listened to. +/// +/// Example usage: +/// ```dart +/// final emitter = MutableSharedEmitter(); +/// +/// emitter.on((event) { +/// // Handle SpecificEvent +/// }); +/// +/// emitter.emit(MyEvent()); +/// ``` +/// +/// Make sure to call [close] when the emitter is no longer needed +/// to avoid memory leaks. +/// +/// See also: +/// - [SharedEmitter] for the read-only interface. +abstract interface class MutableSharedEmitter extends SharedEmitter { + /// Creates a new instance of [MutableSharedEmitter]. + /// + /// When [replay] is greater than 0, the emitter will replay the last [replay] events + /// to new subscribers. When [sync] is `true`, events are emitted synchronously. + factory MutableSharedEmitter({ + int replay, + bool sync, + }) = SharedEmitterImpl; + + /// Emits the [value] to the listeners. void emit(T value); + /// Attempts to emit the [value] to the listeners. + /// + /// This method is similar to [emit], but does not throw exceptions + /// if the emitter is closed. + /// + /// Returns `true` if the value was successfully emitted, `false` otherwise. + bool tryEmit(T value); + + /// Closes the emitter and releases all resources. + /// + /// No further events can be emitted or listened to after calling this method. + /// + /// Returns a [Future] that completes when the emitter is fully closed. Future close(); } -/// TODO -class MutableSharedEmitterImpl extends MutableSharedEmitter { - /// Creates a new instance. - MutableSharedEmitterImpl({bool sync = false}) - : _shared = PublishSubject(sync: sync); - - final PublishSubject _shared; +/// The default implementation of [MutableSharedEmitter] using RxDart subjects. +/// +/// This implementation supports synchronous or asynchronous event emission +/// and can optionally replay recent events to new subscribers. Uses [PublishSubject] +/// for normal operation or [ReplaySubject] when replay functionality is needed. +/// +/// Example: +/// ```dart +/// final emitter = MutableSharedEmitter(); +/// +/// emitter.on((value) { +/// print('Received: $value'); +/// }); +/// +/// emitter.emit(42); // Will emit 42 to all listeners +/// emitter.emit(10); // Will emit 10 to all listeners +/// ``` +/// +/// For replay functionality: +/// ```dart +/// final replayEmitter = MutableSharedEmitter(replay: 2); +/// replayEmitter.emit(1); +/// replayEmitter.emit(2); +/// +/// // New subscribers will immediately receive the last 2 values (1, 2) +/// replayEmitter.listen((value) => print(value)); +/// ``` +/// +/// Make sure to call [close] when done to avoid memory leaks. +/// +/// See also: +/// - [MutableSharedEmitter] for the interface. +/// - [PublishSubject] from `rxdart` for the underlying stream implementation. +class SharedEmitterImpl implements MutableSharedEmitter { + /// Creates a new instance of [SharedEmitterImpl]. + SharedEmitterImpl({ + int replay = 0, + bool sync = false, + }) : _shared = switch (replay) { + 0 => PublishSubject(sync: sync), + > 0 => ReplaySubject(maxSize: replay, sync: sync), + _ => throw ArgumentError('Replay count cannot be negative'), + }; + + final Subject _shared; @override - Future close() { - return _shared.close(); - } + Stream get stream => _shared.stream; - /// Emit the new value. @override void emit(T value) => _shared.add(value); @override - Future waitFor({ - required Duration timeLimit, - }) { - return firstWhere( - (it) => it is E, - timeLimit: timeLimit, - ).then((it) => it as E); + bool tryEmit(T value) { + if (_shared.isClosed) return false; + + emit(value); + return true; + } + + @override + Future waitFor({Duration? timeLimit}) { + final future = _shared.whereType().first; + if (timeLimit == null) return future; + + return future.timeout(timeLimit); } @override - StreamSubscription on(void Function(E event) onEvent) { - return _shared.where((it) => it is E).cast().listen(onEvent); + StreamSubscription on( + void Function(E event) onEvent, + ) { + return _shared.whereType().listen(onEvent); } @override Future firstWhere( bool Function(T element) test, { - required Duration timeLimit, + T Function()? orElse, }) { - return _shared.firstWhere(test).timeout(timeLimit); + return _shared.firstWhere(test, orElse: orElse); } - /// Adds a subscription to this emitter. @override StreamSubscription listen( void Function(T value)? onData, { @@ -88,5 +211,5 @@ class MutableSharedEmitterImpl extends MutableSharedEmitter { } @override - Stream asStream() => _shared; + Future close() => _shared.close(); } diff --git a/packages/stream_core/lib/src/utils/standard.dart b/packages/stream_core/lib/src/utils/standard.dart index c81ace6..2a5d028 100644 --- a/packages/stream_core/lib/src/utils/standard.dart +++ b/packages/stream_core/lib/src/utils/standard.dart @@ -1,10 +1,51 @@ +/// A set of standard extension methods for any [Object] type. extension Standard on T { - R let(R Function(T it) convert) { - return convert(this); + /// Calls the specified function [block] with `this` value as its argument + /// and returns its result. + @pragma('vm:prefer-inline') + R let(R Function(T it) block) { + return block(this); } + /// Calls the specified function [block] with `this` value as its argument + /// and returns `this` value. + @pragma('vm:prefer-inline') T also(void Function(T it) block) { block(this); return this; } + + /// Calls the specified function [block] with `this` value as its receiver + /// and returns `this` value. + @pragma('vm:prefer-inline') + T apply(void Function(T it) block) { + block(this); + return this; + } + + /// Returns `this` value if it satisfies the given [predicate] or `null`, + /// if it doesn't. + @pragma('vm:prefer-inline') + T? takeIf(bool Function(T it) predicate) { + return predicate(this) ? this : null; + } + + /// Returns `this` value if it satisfies the given [predicate] or `null`, + /// if it doesn't. + @pragma('vm:prefer-inline') + T? takeUnless(bool Function(T it) predicate) { + return !predicate(this) ? this : null; + } +} + +/// Executes the given function [action] specified number of [times]. +/// +/// A zero-based index of current iteration is passed as a parameter to the [action] function. +/// +/// If the [times] parameter is negative or equal to zero, the [action] function is not invoked. +@pragma('vm:prefer-inline') +void repeat(int times, void Function(int) action) { + for (var i = 0; i < times; i++) { + action(i); + } } diff --git a/packages/stream_core/lib/src/utils/state_emitter.dart b/packages/stream_core/lib/src/utils/state_emitter.dart new file mode 100644 index 0000000..9c2576a --- /dev/null +++ b/packages/stream_core/lib/src/utils/state_emitter.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:rxdart/rxdart.dart'; + +import 'shared_emitter.dart'; + +/// A state-aware emitter that maintains the current value and emits state changes. +/// +/// Extends [SharedEmitter] with state management capabilities, allowing access to +/// the current value, error state, and providing a [ValueStream] for reactive programming. +/// Unlike regular emitters, state emitters always have a current value (after initialization) +/// and new subscribers immediately receive the current state. +abstract interface class StateEmitter implements SharedEmitter { + /// The stream of state changes as a [ValueStream]. + /// + /// A [ValueStream] is a special stream that always has a current value available + /// and provides immediate access to the latest emitted value. + /// + /// Returns a [ValueStream] of type [T]. + @override + ValueStream get stream; + + /// The current value of the state. + /// + /// Throws a [StateError] if no value has been emitted yet or if the state is in an error state. + /// + /// Returns the current state value of type [T]. + T get value; + + /// The current value of the state, or `null` if no value is available. + /// + /// This is a safe alternative to [value] that returns `null` instead of throwing + /// when no value is available. + /// + /// Returns the current state value or `null`. + T? get valueOrNull; + + /// Whether the state emitter currently has a value. + /// + /// Returns `true` if a value has been emitted and is available, `false` otherwise. + bool get hasValue; + + /// The current error of the state. + /// + /// Throws a [StateError] if the state is not in an error state. + /// + /// Returns the current error object. + Object get error; + + /// The current error of the state, or `null` if no error is present. + /// + /// This is a safe alternative to [error] that returns `null` instead of throwing + /// when no error is present. + /// + /// Returns the current error object or `null`. + Object? get errorOrNull; + + /// Whether the state emitter currently has an error. + /// + /// Returns `true` if the state is in an error condition, `false` otherwise. + bool get hasError; +} + +/// A mutable state emitter that allows updating the current state value. +/// +/// Combines the capabilities of [StateEmitter] and [MutableSharedEmitter] to provide +/// both state management and the ability to emit new values. The emitter maintains +/// the current state and notifies listeners when the state changes. +/// +/// Example usage: +/// ```dart +/// final stateEmitter = MutableStateEmitter(0); +/// +/// stateEmitter.listen((value) { +/// print('State changed to: $value'); +/// }); +/// +/// stateEmitter.value = 42; // Triggers listener with value 42 +/// print(stateEmitter.value); // Prints: 42 +/// ``` +abstract interface class MutableStateEmitter + implements StateEmitter, MutableSharedEmitter { + /// Creates a new [MutableStateEmitter] with the given [initialValue]. + /// + /// When [sync] is `true`, state changes are emitted synchronously. + factory MutableStateEmitter( + T initialValue, { + bool sync, + }) = StateEmitterImpl; + + /// Sets the current state value. + /// + /// This is equivalent to calling [emit] with the new value. + /// Listeners will be notified of the state change if the new value + /// is different from the current value. + set value(T newValue); +} + +/// The default implementation of [MutableStateEmitter] using a [BehaviorSubject]. +/// +/// This implementation uses RxDart's [BehaviorSubject] to maintain state and emit +/// changes to subscribers. The behavior subject ensures that new subscribers +/// immediately receive the current state value. +/// +/// The emitter only emits new values when they differ from the current value, +/// preventing unnecessary notifications for identical state updates. +/// +/// Example: +/// ```dart +/// final emitter = StateEmitterImpl('initial'); +/// +/// emitter.listen((value) { +/// print('State: $value'); +/// }); // Immediately prints: State: initial +/// +/// emitter.value = 'updated'; // Prints: State: updated +/// emitter.value = 'updated'; // No output (same value) +/// ``` +class StateEmitterImpl implements MutableStateEmitter { + /// Creates a new instance of [StateEmitterImpl]. + StateEmitterImpl( + T initialValue, { + bool sync = false, + }) : _state = BehaviorSubject.seeded(initialValue, sync: sync); + + final BehaviorSubject _state; + + @override + ValueStream get stream => _state.stream; + + @override + T get value => _state.value; + + @override + set value(T newValue) => emit(newValue); + + @override + bool get hasValue => _state.hasValue; + + @override + T? get valueOrNull => _state.valueOrNull; + + @override + Object get error => _state.error; + + @override + Object? get errorOrNull => _state.errorOrNull; + + @override + bool get hasError => _state.hasError; + + @override + void emit(T newValue) { + if (value == newValue) return; + _state.add(newValue); + } + + @override + bool tryEmit(T value) { + if (_state.isClosed) return false; + + emit(value); + return true; + } + + @override + Future waitFor({Duration? timeLimit}) { + final future = _state.whereType().first; + if (timeLimit == null) return future; + + return future.timeout(timeLimit); + } + + @override + StreamSubscription on( + void Function(E event) onEvent, + ) { + return _state.whereType().listen(onEvent); + } + + @override + Future firstWhere( + bool Function(T element) test, { + T Function()? orElse, + }) { + return _state.firstWhere(test, orElse: orElse); + } + + @override + StreamSubscription listen( + void Function(T value)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _state.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + @override + Future close() => _state.close(); +} diff --git a/packages/stream_core/lib/src/ws.dart b/packages/stream_core/lib/src/ws.dart index 131c858..aa0c5ee 100644 --- a/packages/stream_core/lib/src/ws.dart +++ b/packages/stream_core/lib/src/ws.dart @@ -1,12 +1,12 @@ -export 'ws/client/connection_recovery_handler.dart'; -export 'ws/client/default_connection_recovery_handler.dart'; -export 'ws/client/web_socket_client.dart' - show - CloseCode, - EventDecoder, - PingReguestBuilder, - VoidCallback, - WebSocketClient; +export 'ws/client/engine/stream_web_socket_engine.dart'; +export 'ws/client/engine/web_socket_engine.dart'; +export 'ws/client/engine/web_socket_options.dart'; +export 'ws/client/reconnect/automatic_reconnection_policy.dart'; +export 'ws/client/reconnect/connection_recovery_handler.dart'; +export 'ws/client/reconnect/retry_strategy.dart'; +export 'ws/client/stream_web_socket_client.dart'; export 'ws/client/web_socket_connection_state.dart'; -export 'ws/events/sendable_event.dart'; +export 'ws/client/web_socket_health_monitor.dart'; +export 'ws/events/event_emitter.dart'; export 'ws/events/ws_event.dart'; +export 'ws/events/ws_request.dart'; 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 deleted file mode 100644 index 46bc432..0000000 --- a/packages/stream_core/lib/src/ws/client/connection_recovery_handler.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:meta/meta.dart'; - -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(); -} - -class ConnectionRecoveryHandler { - ConnectionRecoveryHandler({ - required this.retryStrategy, - required this.client, - required this.policies, - }); - - final WebSocketClient client; - final List policies; - final List> subscriptions = []; - final RetryStrategy retryStrategy; - Timer? _reconnectionTimer; - - @protected - void reconnectIfNeeded() { - if (!_canReconnectAutomatically()) return; - - client.connect(); - } - - @protected - void disconnectIfNeeded() { - final canBeDisconnected = switch (client.connectionState) { - Connecting() || Connected() || Authenticating() => true, - _ => false, - }; - - if (canBeDisconnected) { - print('Disconnecting automatically'); - client.disconnect(source: DisconnectionSource.systemInitiated()); - } - } - - @protected - void scheduleReconnectionTimerIfNeeded() { - if (!_canReconnectAutomatically()) return; - - final delay = retryStrategy.getDelayAfterFailure(); - print('Scheduling reconnection in ${delay.inSeconds} seconds'); - _reconnectionTimer = Timer(delay, reconnectIfNeeded); - } - - @protected - void cancelReconnectionTimer() { - if (_reconnectionTimer == null) return; - - print('Cancelling reconnection timer'); - _reconnectionTimer?.cancel(); - _reconnectionTimer = null; - } - - Future dispose() async { - await Future.wait( - subscriptions.map((subscription) => subscription.cancel()), - ); - subscriptions.clear(); - cancelReconnectionTimer(); - } - - bool _canReconnectAutomatically() => - policies.every((policy) => policy.canBeReconnected()); -} - -class WebSocketAutomaticReconnectionPolicy - implements AutomaticReconnectionPolicy { - WebSocketAutomaticReconnectionPolicy({required this.client}); - - final WebSocketClient client; - - @override - bool canBeReconnected() { - return client.connectionState.isAutomaticReconnectionEnabled; - } -} - -class InternetAvailableReconnectionPolicy - implements AutomaticReconnectionPolicy { - InternetAvailableReconnectionPolicy({required this.networkMonitor}); - final NetworkMonitor networkMonitor; - - @override - bool canBeReconnected() { - return networkMonitor.currentStatus == NetworkStatus.connected; - } -} - -abstract class RetryStrategy { - /// Returns the # of consecutively failed retries. - int get consecutiveFailuresCount; - - /// Increments the # of consecutively failed retries making the next delay longer. - void incrementConsecutiveFailures(); - - /// Resets the # of consecutively failed retries making the next delay be the shortest one. - void resetConsecutiveFailures(); - - /// Calculates and returns the delay for the next retry. - /// - /// Consecutive calls after the same # of failures may return different delays. This randomization is done to - /// make the retry intervals slightly different for different callers to avoid putting the backend down by - /// making all the retries at the same time. - Duration get nextRetryDelay; - - /// Returns the delay and then increments # of consecutively failed retries. - Duration getDelayAfterFailure() { - final delay = nextRetryDelay; - incrementConsecutiveFailures(); - return delay; - } -} - -class DefaultRetryStrategy extends RetryStrategy { - DefaultRetryStrategy(); - static const maximumReconnectionDelayInSeconds = 25; - - @override - Duration get nextRetryDelay { - /// The first time we get to retry, we do it without any delay. Any subsequent time will - /// be delayed by a random interval. - if (consecutiveFailuresCount == 0) return Duration.zero; - - final maxDelay = math.min( - 0.5 + consecutiveFailuresCount * 2, - maximumReconnectionDelayInSeconds, - ); - final minDelay = math.min( - math.max(0.25, (consecutiveFailuresCount - 1) * 2), - maximumReconnectionDelayInSeconds, - ); - - final delayInSeconds = - math.Random().nextDouble() * (maxDelay - minDelay) + minDelay; - - return Duration(milliseconds: (delayInSeconds * 1000).toInt()); - } - - @override - int consecutiveFailuresCount = 0; - - @override - void incrementConsecutiveFailures() { - consecutiveFailuresCount++; - } - - @override - void resetConsecutiveFailures() { - consecutiveFailuresCount = 0; - } -} 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 deleted file mode 100644 index f6eb824..0000000 --- a/packages/stream_core/lib/src/ws/client/default_connection_recovery_handler.dart +++ /dev/null @@ -1,69 +0,0 @@ -import '../../utils/network_monitor.dart'; -import 'connection_recovery_handler.dart'; -import 'web_socket_connection_state.dart'; - -class DefaultConnectionRecoveryHandler extends ConnectionRecoveryHandler - with - WebSocketAwareConnectionRecoveryHandler, - NetworkAwareConnectionRecoveryHandler { - DefaultConnectionRecoveryHandler({ - RetryStrategy? retryStrategy, - required super.client, - NetworkMonitor? networkMonitor, - }) : super( - retryStrategy: retryStrategy ?? DefaultRetryStrategy(), - policies: [ - WebSocketAutomaticReconnectionPolicy(client: client), - if (networkMonitor case final networkMonitor?) - InternetAvailableReconnectionPolicy( - networkMonitor: networkMonitor, - ), - ], - ) { - _subscribe(networkMonitor: networkMonitor); - } - - void _subscribe({NetworkMonitor? networkMonitor}) { - subscribeToNetworkChanges(networkMonitor); - subscribeToWebSocketConnectionChanges(); - } -} - -mixin NetworkAwareConnectionRecoveryHandler on ConnectionRecoveryHandler { - void _networkStatusChanged(NetworkStatus status) { - if (status == NetworkStatus.disconnected) { - disconnectIfNeeded(); - } else { - reconnectIfNeeded(); - } - } - - void subscribeToNetworkChanges(NetworkMonitor? networkMonitor) { - if (networkMonitor case final networkMonitor?) { - subscriptions - .add(networkMonitor.onStatusChange.listen(_networkStatusChanged)); - } - } -} - -mixin WebSocketAwareConnectionRecoveryHandler on ConnectionRecoveryHandler { - void _websocketConnectionStateChanged(WebSocketConnectionState state) { - switch (state) { - case Connecting(): - cancelReconnectionTimer(); - case Connected(): - retryStrategy.resetConsecutiveFailures(); - case Disconnected(): - scheduleReconnectionTimerIfNeeded(); - case Initialized() || Authenticating() || Disconnecting(): - // Don't do anything - break; - } - } - - void subscribeToWebSocketConnectionChanges() { - subscriptions.add( - client.connectionStateStream.listen(_websocketConnectionStateChanged), - ); - } -} diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart new file mode 100644 index 0000000..c308fcf --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../../../logger/logger.dart'; +import '../../../utils.dart'; +import 'web_socket_engine.dart'; + +/// Signature for a function that creates a [WebSocketChannel] based on [WebSocketOptions]. +typedef WebSocketProvider = WebSocketChannel Function(WebSocketOptions options); + +// Creates a [WebSocketChannel] based on the provided [WebSocketOptions]. +// +// Parses the URL from [options] and establishes a WebSocket connection with the specified +// query parameters and protocols. +WebSocketChannel _createWebSocket(WebSocketOptions options) { + final baseUrl = options.url.trim(); + + final uri = Uri.parse(baseUrl).replace( + queryParameters: options.queryParameters, + ); + + return WebSocketChannel.connect(uri, protocols: options.protocols); +} + +/// A WebSocket engine implementation that handles low-level WebSocket operations. +/// +/// Manages the WebSocket connection lifecycle, message encoding/decoding, and event notification. +/// This engine provides the foundation for higher-level WebSocket client functionality by handling +/// the transport layer concerns. +/// +/// The engine supports both text and binary message formats through the [WebSocketMessageCodec] +/// and provides callbacks through [WebSocketEngineListener] for connection events. +class StreamWebSocketEngine implements WebSocketEngine { + /// Creates a new instance of [StreamWebSocketEngine]. + StreamWebSocketEngine({ + String tag = 'StreamWebSocketEngine', + WebSocketProvider? wsProvider, + WebSocketEngineListener? listener, + required WebSocketMessageCodec messageCodec, + }) : _logger = TaggedLogger(tag), + _wsProvider = wsProvider ?? _createWebSocket, + _messageCodec = messageCodec, + _listener = listener; + + final TaggedLogger _logger; + final WebSocketProvider _wsProvider; + final WebSocketMessageCodec _messageCodec; + + /// Sets the listener for WebSocket events. + /// + /// The [l] parameter is the new listener that will receive connection events and messages. + set listener(WebSocketEngineListener? l) => _listener = l; + WebSocketEngineListener? _listener; + + WebSocketChannel? _ws; + StreamSubscription? _wsSubscription; + + @override + Future> open(WebSocketOptions options) { + return runSafely(() async { + if (_ws != null) { + throw StateError('Web socket is already open. Call close() first.'); + } + + _ws = _wsProvider.call(options); + + await _ws?.ready.then((_) => _listener?.onOpen()); + + _wsSubscription = _ws?.stream.listen( + _onData, + onError: _listener?.onError, + onDone: () => _listener?.onClose(_ws?.closeCode, _ws?.closeReason), + cancelOnError: false, + ); + }); + } + + void _onData(Object? data) { + // If data is null, we ignore it. + if (data == null) return; + + final result = runSafelySync(() => _messageCodec.decode(data)); + final message = result.getOrNull(); + + // If decoding failed, we ignore the message. + if (message == null) return; + + // Otherwise, we notify the listener. + return _listener?.onMessage(message); + } + + @override + Future> close([ + int? closeCode = CloseCode.normalClosure, + String? closeReason = 'Closed by client', + ]) { + return runSafely(() async { + if (_ws == null) { + throw StateError('WebSocket is not open. Call open() first.'); + } + + await _ws?.sink.close(closeCode, closeReason); + _ws = null; + + await _wsSubscription?.cancel(); + _wsSubscription = null; + }); + } + + @override + Result sendMessage(Out message) { + return runSafelySync(() { + final ws = _ws; + if (ws == null) { + throw StateError('WebSocket is not open. Call open() first.'); + } + + final data = _messageCodec.encode(message); + return ws.sink.add(data); + }); + } +} diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart new file mode 100644 index 0000000..9553267 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_engine.dart @@ -0,0 +1,202 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; + +import '../../../errors.dart'; +import '../../../utils.dart'; +import 'web_socket_options.dart'; + +export 'dart:typed_data'; +export 'web_socket_options.dart'; + +/// Interface for WebSocket engine implementations. +/// +/// Defines the core operations that a WebSocket engine must support for managing +/// WebSocket connections, including opening, closing, and sending messages. +abstract interface class WebSocketEngine { + /// Opens a WebSocket connection with the specified options. + /// + /// Creates a new WebSocket connection using the provided [options] and sets up + /// event listeners. + /// + /// Returns a [Result] indicating success or failure of the connection attempt. + Future> open(WebSocketOptions options); + + /// Closes the WebSocket connection. + /// + /// Closes the active WebSocket connection with the specified [closeCode] and [closeReason]. + /// + /// Returns a [Result] indicating success or failure of the close operation. + Future> close([int? closeCode, String? closeReason]); + + /// Sends a message through the WebSocket connection. + /// + /// Encodes and sends the [message] through the WebSocket connection. + /// + /// Returns a [Result] indicating success or failure of the send operation. + Result sendMessage(Outgoing message); +} + +/// Interface for WebSocket message encoding and decoding. +/// +/// Handles the serialization and deserialization of messages between WebSocket +/// transport format and application-specific types. Supports both text and binary formats. +abstract interface class WebSocketMessageCodec { + /// Encodes an outgoing message for WebSocket transmission. + /// + /// Converts the [message] to either a [String] or [Uint8List] for transmission + /// over the WebSocket connection. The encoding format depends on the implementation. + /// + /// Returns the encoded message as either a [String] or [Uint8List]. + Object /*String|Uint8List*/ encode(Outgoing message); + + /// Decodes an incoming WebSocket message. + /// + /// Converts the [message] received from the WebSocket (either [String] or [Uint8List]) + /// to the application-specific incoming message type. + /// + /// Returns the decoded message of type [Incoming]. + Incoming decode(Object /*String|Uint8List*/ message); +} + +/// Interface for receiving WebSocket engine events. +/// +/// Implementations of this interface receive callbacks for WebSocket connection +/// lifecycle events and message handling from the [WebSocketEngine]. +abstract interface class WebSocketEngineListener { + /// Called when the WebSocket connection is successfully opened. + /// + /// This indicates that the WebSocket connection has been established and is ready + /// for message transmission. Implementations should update their connection state + /// and perform any necessary initialization. + void onOpen(); + + /// Called when a message is received from the WebSocket. + /// + /// The [message] is the decoded incoming message that should be processed by + /// the implementation. The message type depends on the configured message codec. + void onMessage(Incoming message); + + /// Called when an error occurs on the WebSocket connection. + /// + /// The [error] contains the error details and [stackTrace] provides debugging + /// information. Implementations should handle the error appropriately, typically + /// by updating connection state and potentially triggering reconnection logic. + void onError(Object error, [StackTrace? stackTrace]); + + /// Called when the WebSocket connection is closed. + /// + /// The [closeCode] and [closeReason] provide details about why the connection + /// was closed. Implementations should update their connection state and determine + /// whether reconnection is appropriate based on the close reason. + void onClose([int? closeCode, String? closeReason]); +} + +/// A strongly-typed wrapper around an integer WebSocket close code, as defined +/// by the [RFC 6455 WebSocket Protocol](https://datatracker.ietf.org/doc/html/rfc6455#section-7.4). +/// +/// This type behaves like an `int` at runtime, but gives you named constants for +/// the standard codes while remaining open-ended for custom codes. +/// +/// Example: +/// ```dart +/// socket.close(CloseCode.normalClosure); +/// if (closeCode == CloseCode.goingAway) { +/// print('Server is shutting down.'); +/// } +/// ``` +extension type const CloseCode(int code) implements int { + /// `0` – The connection was closed without a specific reason. + static const invalid = CloseCode(0); + + /// `1000` – The purpose for which the connection was established + /// has been fulfilled. + static const normalClosure = CloseCode(1000); + + /// `1001` – An endpoint is "going away", such as: + /// - a server going down, + /// - a browser navigating away from a page. + static const goingAway = CloseCode(1001); + + /// `1002` – An endpoint is terminating the connection due to a protocol error. + static const protocolError = CloseCode(1002); + + /// `1003` – An endpoint is terminating the connection because it has received + /// a type of data it cannot accept. + /// + /// For example: an endpoint that only accepts text data may send this if it + /// receives a binary message. + static const unsupportedData = CloseCode(1003); + + /// `1005` – No status code was present. + /// + /// This **must not** be set explicitly by an endpoint. + static const noStatusReceived = CloseCode(1005); + + /// `1006` – The connection was closed abnormally, without sending or receiving + /// a Close control frame. + /// + /// This **must not** be set explicitly by an endpoint. + static const abnormalClosure = CloseCode(1006); + + /// `1007` – An endpoint is terminating the connection because it has received + /// data within a message that was not consistent with the type of the message. + /// + /// For example: receiving non-UTF-8 data in a text message. + static const invalidFramePayloadData = CloseCode(1007); + + /// `1008` – An endpoint is terminating the connection because it has received + /// a message that violates its policy. + /// + /// This is a generic status code that can be returned when no other more + /// suitable code applies (such as [unsupportedData] or [messageTooBig]), or if + /// details must be hidden. + static const policyViolation = CloseCode(1008); + + /// `1009` – An endpoint is terminating the connection because it has received + /// a message that is too big to process. + static const messageTooBig = CloseCode(1009); + + /// `1010` – The client is terminating the connection because it expected the + /// server to negotiate one or more extensions, but the server did not return + /// them in the handshake response. + /// + /// The list of required extensions should appear in the close reason. + /// **Note:** This is not used by servers (they can fail the handshake instead). + static const mandatoryExtensionMissing = CloseCode(1010); + + /// `1011` – The server is terminating the connection because it encountered an + /// unexpected condition that prevented it from fulfilling the request. + static const internalServerError = CloseCode(1011); + + /// `1015` – The connection was closed due to a failure to perform a TLS handshake. + /// + /// For example: the server certificate could not be verified. + /// This **must not** be set explicitly by an endpoint. + static const tlsHandshakeFailure = CloseCode(1015); +} + +class WebSocketEngineException with EquatableMixin implements Exception { + const WebSocketEngineException({ + String? reason, + int? code = 0, + this.error, + }) : reason = reason ?? 'Unknown', + code = code ?? 0; + + final String reason; + final int code; + final Object? error; + + /// Returns the error as a StreamApiError if it is of that type or + /// null otherwise. + StreamApiError? get apiError { + if (error case final StreamApiError error) return error; + return null; + } + + static const stopErrorCode = 1000; + + @override + List get props => [reason, code, error]; +} diff --git a/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart new file mode 100644 index 0000000..0d2f86c --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/engine/web_socket_options.dart @@ -0,0 +1,55 @@ +import 'web_socket_engine.dart'; + +/// Configuration options for establishing WebSocket connections. +/// +/// Defines the connection parameters including URL, timeout settings, protocols, +/// and query parameters for WebSocket connections. Used by [WebSocketEngine] +/// implementations to establish connections with the specified configuration. +/// +/// ## Example +/// ```dart +/// final options = WebSocketOptions( +/// url: 'wss://api.example.com/ws', +/// connectTimeout: Duration(seconds: 10), +/// protocols: ['chat', 'superchat'], +/// queryParameters: { +/// 'token': 'abc123', +/// 'version': '1.0', +/// }, +/// ); +/// ``` +class WebSocketOptions { + /// Creates a new instance of [WebSocketOptions]. + const WebSocketOptions({ + required this.url, + this.connectTimeout, + this.protocols, + this.queryParameters, + }); + + /// The WebSocket server URL to connect to. + /// + /// Must be a valid WebSocket URL using either `ws://` or `wss://` scheme. + /// The URL should include the host, port (if non-standard), and path. + final String url; + + /// Maximum time allowed for establishing the WebSocket connection. + /// + /// When specified, the connection attempt will timeout if not completed + /// within this duration. If `null`, uses the platform default timeout. + final Duration? connectTimeout; + + /// WebSocket sub-protocols to negotiate during the handshake. + /// + /// Specifies the list of sub-protocols that the client supports. + /// The server will select one of these protocols during the handshake + /// if it supports any of them. + final Iterable? protocols; + + /// Query parameters to append to the connection URL. + /// + /// These parameters are added to the WebSocket URL during connection + /// establishment. Commonly used for authentication tokens, API versions, + /// or other connection-specific configuration. + final Map? queryParameters; +} diff --git a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart new file mode 100644 index 0000000..1ae4b85 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart @@ -0,0 +1,97 @@ +import '../../../utils.dart'; +import '../web_socket_connection_state.dart'; + +/// An interface that defines a policy for determining whether the WebSocket +/// should attempt an automatic reconnection based on specific conditions. +abstract interface class AutomaticReconnectionPolicy { + /// Determines whether the WebSocket should attempt to reconnect automatically. + /// + /// Returns `true` if the WebSocket should attempt to reconnect, `false` otherwise. + bool canBeReconnected(); +} + +/// A reconnection policy that checks if automatic reconnection is enabled +/// based on the current state of the WebSocket connection. +class WebSocketAutomaticReconnectionPolicy implements AutomaticReconnectionPolicy { + /// Creates a [WebSocketAutomaticReconnectionPolicy]. + WebSocketAutomaticReconnectionPolicy({required this.connectionState}); + + /// The WebSocket client to check for reconnection settings. + final ConnectionStateEmitter connectionState; + + @override + bool canBeReconnected() { + final state = connectionState.value; + return state.isAutomaticReconnectionEnabled; + } +} + +/// A reconnection policy that checks for internet connectivity before allowing +/// reconnection. This prevents unnecessary reconnection attempts when there's no +/// network available. +class InternetAvailabilityReconnectionPolicy implements AutomaticReconnectionPolicy { + /// Creates an [InternetAvailabilityReconnectionPolicy]. + InternetAvailabilityReconnectionPolicy({required this.networkState}); + + final NetworkStateEmitter networkState; + + @override + bool canBeReconnected() { + final state = networkState.value; + return state == NetworkState.connected; + } +} + +/// A reconnection policy that checks the application's lifecycle state before +/// allowing reconnection. This prevents reconnection when the app is in the +/// background to save battery and resources. +class BackgroundStateReconnectionPolicy implements AutomaticReconnectionPolicy { + /// Creates a [BackgroundStateReconnectionPolicy]. + BackgroundStateReconnectionPolicy({required this.appLifecycleState}); + + /// The provider that gives the current app lifecycle state. + final AppLifecycleStateEmitter appLifecycleState; + + @override + bool canBeReconnected() { + final state = appLifecycleState.value; + return state == AppLifecycleState.foreground; + } +} + +/// Defines logical operators for combining multiple reconnection policies. +enum Operator { + /// Requires ALL policies to return `true` for reconnection to be allowed. + /// If any policy returns `false`, reconnection will be prevented. + and, + + /// Requires ANY policy to return `true` for reconnection to be allowed. + /// If at least one policy returns `true`, reconnection will be attempted. + or; +} + +/// A composite reconnection policy that combines multiple [policies] using a +/// logical [operator] (AND/OR). This allows for complex reconnection +/// logic by combining multiple conditions. +class CompositeReconnectionPolicy implements AutomaticReconnectionPolicy { + /// Creates a [CompositeReconnectionPolicy]. + CompositeReconnectionPolicy({ + required this.operator, + required this.policies, + }); + + /// The logical operator to use when combining policies + /// ([Operator.and] or [Operator.or]). + final Operator operator; + + /// List of reconnection policies to evaluate. + final List policies; + + @override + bool canBeReconnected() { + return switch (operator) { + Operator.and => policies.every((it) => it.canBeReconnected()), + Operator.or => policies.any((it) => it.canBeReconnected()), + }; + } +} diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart new file mode 100644 index 0000000..3959ac0 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:rxdart/utils.dart'; + +import '../../../logger/impl/tagged_logger.dart'; +import '../../../utils.dart'; +import '../stream_web_socket_client.dart'; +import '../web_socket_connection_state.dart'; +import 'automatic_reconnection_policy.dart'; +import 'retry_strategy.dart'; + +/// A connection recovery handler with intelligent reconnection management. +/// +/// Provides intelligent reconnection management with multiple policies and retry strategies +/// for [StreamWebSocketClient] instances. Automatically handles reconnection based on various +/// conditions like network state, app lifecycle, and connection errors. +/// +/// The handler monitors connection state changes and applies configurable policies to determine +/// when reconnection should occur, implementing exponential backoff with jitter for optimal +/// retry behavior. +/// +/// ## Built-in Policies +/// +/// The handler automatically includes several reconnection policies: +/// - [WebSocketAutomaticReconnectionPolicy]: Checks whether reconnection is enabled based on disconnection source +/// - [InternetAvailabilityReconnectionPolicy]: Only allows reconnection when network is available +/// - [BackgroundStateReconnectionPolicy]: Prevents reconnection when app is in background +/// +/// ## Example +/// ```dart +/// final recoveryHandler = ConnectionRecoveryHandler( +/// client: client, +/// networkStateProvider: NetworkStateProvider(), +/// appLifecycleStateProvider: AppLifecycleStateProvider(), +/// ); +/// ``` +class ConnectionRecoveryHandler extends Disposable { + /// Creates a new instance of [ConnectionRecoveryHandler]. + ConnectionRecoveryHandler({ + required StreamWebSocketClient client, + NetworkStateProvider? networkStateProvider, + AppLifecycleStateProvider? appLifecycleStateProvider, + List? policies, + RetryStrategy? retryStrategy, + }) : _client = client, + _reconnectStrategy = retryStrategy ?? RetryStrategy(), + _policies = [ + if (policies != null) ...policies, + WebSocketAutomaticReconnectionPolicy( + connectionState: client.connectionState, + ), + if (networkStateProvider case final provider?) + InternetAvailabilityReconnectionPolicy( + networkState: provider.state, + ), + if (appLifecycleStateProvider case final provider?) + BackgroundStateReconnectionPolicy( + appLifecycleState: provider.state, + ), + ] { + // Listen to connection state changes. + _client.connectionState.on(_onConnectionStateChanged).addTo(_subscriptions); + + // Listen to network state changes if a provider is given. + if (networkStateProvider case final provider?) { + provider.state.on(_onNetworkStateChanged).addTo(_subscriptions); + } + + // Listen to app lifecycle state changes if a provider is given. + if (appLifecycleStateProvider case final provider?) { + provider.state.on(_onAppLifecycleStateChanged).addTo(_subscriptions); + } + } + + final StreamWebSocketClient _client; + final RetryStrategy _reconnectStrategy; + final List _policies; + late final _logger = taggedLogger(tag: 'ConnectionRecoveryHandler'); + + late final _subscriptions = CompositeSubscription(); + + /// Attempts reconnection if policies allow it. + /// + /// Evaluates all configured policies and initiates reconnection when conditions are met. + /// Called automatically by the handler based on state changes. + void reconnectIfNeeded() { + if (!_canBeReconnected()) return; + _client.connect(); + } + + /// Disconnects the client if policies require it. + /// + /// Evaluates policies and disconnects when conditions indicate disconnection is needed + /// (e.g., app backgrounded, network unavailable). + void disconnectIfNeeded() { + if (!_canBeDisconnected()) return; + _client.disconnect(source: const DisconnectionSource.systemInitiated()); + } + + void _scheduleReconnectionIfNeeded() { + if (!_canBeReconnected()) return; + _scheduleReconnection(); + } + + Timer? _reconnectionTimer; + void _scheduleReconnection() { + final delay = _reconnectStrategy.getDelayAfterTheFailure(); + + _reconnectionTimer?.cancel(); + _reconnectionTimer = Timer(delay, reconnectIfNeeded); + } + + void _cancelReconnection() { + if (_reconnectionTimer == null) return; + + _reconnectionTimer?.cancel(); + _reconnectionTimer = null; + } + + bool _canBeReconnected() { + return _policies.every((policy) => policy.canBeReconnected()); + } + + bool _canBeDisconnected() { + return switch (_client.connectionState.value) { + Connecting() || Authenticating() || Connected() => true, + _ => false, + }; + } + + void _onNetworkStateChanged(NetworkState status) { + return switch (status) { + NetworkState.connected => reconnectIfNeeded(), + NetworkState.disconnected => disconnectIfNeeded(), + }; + } + + void _onAppLifecycleStateChanged(AppLifecycleState state) { + return switch (state) { + AppLifecycleState.foreground => reconnectIfNeeded(), + AppLifecycleState.background => disconnectIfNeeded(), + }; + } + + void _onConnectionStateChanged(WebSocketConnectionState state) { + return switch (state) { + Connecting() => _cancelReconnection(), + Connected() => _reconnectStrategy.resetConsecutiveFailures(), + Disconnected() => _scheduleReconnectionIfNeeded(), + // These states do not require any action. + Initialized() || Authenticating() || Disconnecting() => null, + }; + } + + @override + Future dispose() async { + _cancelReconnection(); + await _subscriptions.dispose(); + return super.dispose(); + } +} diff --git a/packages/stream_core/lib/src/ws/client/reconnect/retry_strategy.dart b/packages/stream_core/lib/src/ws/client/reconnect/retry_strategy.dart new file mode 100644 index 0000000..43e6256 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/reconnect/retry_strategy.dart @@ -0,0 +1,97 @@ +import 'dart:math' as math; + +/// Interface that encapsulates the logic for computing delays for failed actions that need to be +/// retried. +/// +/// This strategy manages the retry delay calculation with exponential backoff and jitter to avoid +/// overwhelming the backend service with simultaneous retry attempts from multiple clients. +abstract interface class RetryStrategy { + /// Creates a [RetryStrategy] instance. + factory RetryStrategy() = DefaultRetryStrategy; + + /// The number of consecutively failed retries. + int get consecutiveFailuresCount; + + /// Increments the number of consecutively failed retries, making the next + /// delay longer. + /// + /// This method should be called each time a retry attempt fails in order to + /// gradually increase the delay between subsequent attempts. + void incrementConsecutiveFailures(); + + /// Resets the number of consecutively failed retries, making the next delay + /// be the shortest one. + /// + /// This method should be called when a retry attempt succeeds in order to + /// reset the backoff strategy for future retry attempts. + void resetConsecutiveFailures(); + + /// Calculates and returns the delay for the next retry. + /// + /// Consecutive calls after the same number of failures may return different + /// delays due to randomization (jitter). This randomization helps avoid + /// overwhelming the backend by preventing all clients from retrying at + /// exactly the same time. + /// + /// Returns the delay for the next retry. + Duration getNextRetryDelay(); +} + +/// Extension methods for the [RetryStrategy] interface. +extension RetryStrategyExtensions on RetryStrategy { + /// Returns the delay for the next retry and then increments the number of + /// consecutively failed retries. + /// + /// This method combines the functionality of getting the next retry delay + /// and incrementing the failure count, making it convenient to use after a + /// failed retry attempt. + /// + /// Returns the delay for the next retry. + Duration getDelayAfterTheFailure() { + final delay = getNextRetryDelay(); + incrementConsecutiveFailures(); + return delay; + } +} + +/// Default implementation of [RetryStrategy] that uses exponential backoff +/// with jitter. +/// +/// The delay increases with each consecutive failure, up to a maximum limit +/// of [maximumReconnectionDelayInSeconds] seconds. The delay is randomized +/// within a range to prevent synchronized retries from multiple clients. +class DefaultRetryStrategy implements RetryStrategy { + /// Maximum delay between reconnection attempts. + static const maximumReconnectionDelayInSeconds = 25; + + @override + int consecutiveFailuresCount = 0; + + @override + void incrementConsecutiveFailures() => consecutiveFailuresCount++; + + @override + void resetConsecutiveFailures() => consecutiveFailuresCount = 0; + + @override + Duration getNextRetryDelay() { + // The first time we get to retry, we do it without any delay. + // Any subsequent time will be delayed by a random interval. + if (consecutiveFailuresCount <= 0) return Duration.zero; + + final maxDelay = math.min( + 0.5 + consecutiveFailuresCount * 2, + maximumReconnectionDelayInSeconds, + ); + + final minDelay = math.min( + math.max(0.25, (consecutiveFailuresCount - 1) * 2), + maximumReconnectionDelayInSeconds, + ); + + final rand = math.Random().nextDouble(); + final delayInSeconds = (rand * (maxDelay - minDelay) + minDelay).floor(); + + return Duration(milliseconds: delayInSeconds * 1000); + } +} diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart new file mode 100644 index 0000000..f0ca25b --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import '../../logger/logger.dart'; +import '../../utils.dart'; +import '../events/event_emitter.dart'; +import '../events/ws_event.dart'; +import '../events/ws_request.dart'; +import 'engine/stream_web_socket_engine.dart'; +import 'engine/web_socket_engine.dart'; +import 'web_socket_connection_state.dart'; +import 'web_socket_health_monitor.dart'; + +/// A function that builds ping requests for health checks. +/// +/// The [info] parameter contains health check information from the current connection. +/// +/// Returns a [WsRequest] that will be sent as a ping message. +typedef PingRequestBuilder = WsRequest Function([HealthCheckInfo? info]); +WsRequest _defaultPingRequestBuilder([HealthCheckInfo? info]) { + return HealthCheckPingEvent(connectionId: info?.connectionId); +} + +/// A WebSocket client with connection management and event handling. +/// +/// The primary interface for WebSocket connections in the Stream Core SDK that provides +/// functionality for real-time communication with automatic reconnection, health monitoring, +/// and sophisticated state management. +/// +/// Each [StreamWebSocketClient] instance manages its own connection lifecycle and maintains +/// state that can be observed for real-time updates. The client handles message encoding/decoding, +/// connection recovery, and event distribution. +/// +/// ## Example +/// ```dart +/// final client = StreamWebSocketClient( +/// options: WebSocketOptions(url: 'wss://api.example.com'), +/// messageCodec: JsonMessageCodec(), +/// onConnectionEstablished: () { +/// client.send(AuthRequest(token: authToken)); +/// }, +/// ); +/// +/// await client.connect(); +/// ``` +class StreamWebSocketClient + implements WebSocketHealthListener, WebSocketEngineListener { + /// Creates a new instance of [StreamWebSocketClient]. + StreamWebSocketClient({ + String tag = 'StreamWebSocketClient', + required this.options, + this.onConnectionEstablished, + WebSocketProvider? wsProvider, + this.pingRequestBuilder = _defaultPingRequestBuilder, + required WebSocketMessageCodec messageCodec, + Iterable>? eventResolvers, + }) : _logger = TaggedLogger(tag) { + _events = MutableEventEmitter(resolvers: eventResolvers); + _engine = StreamWebSocketEngine( + listener: this, + wsProvider: wsProvider, + messageCodec: messageCodec, + ); + } + + final TaggedLogger _logger; + + /// The WebSocket connection options including URL and configuration. + final WebSocketOptions options; + + /// The function used to build ping requests for health checks. + final PingRequestBuilder pingRequestBuilder; + + /// Called when the WebSocket connection is established and ready for authentication. + final void Function()? onConnectionEstablished; + + late final StreamWebSocketEngine _engine; + late final _healthMonitor = WebSocketHealthMonitor(listener: this); + + /// The event emitter for WebSocket events. + /// + /// Use this to listen to incoming WebSocket events with type-safe event handling. + EventEmitter get events => _events; + late final MutableEventEmitter _events; + + /// The current connection state of the WebSocket. + /// + /// Emits state changes as the WebSocket transitions through different connection states. + ConnectionStateEmitter get connectionState => _connectionStateEmitter; + late final _connectionStateEmitter = MutableConnectionStateEmitter( + const WebSocketConnectionState.initialized(), + ); + + set _connectionState(WebSocketConnectionState connectionState) { + // Return early if the state hasn't changed. + if (_connectionStateEmitter.value == connectionState) return; + + print('WebSocketClient: Connection state changed to $connectionState'); + _connectionStateEmitter.value = connectionState; + _healthMonitor.onConnectionStateChanged(connectionState); + } + + /// Sends a message through the WebSocket connection. + /// + /// The [request] is encoded using the configured message codec and sent to the server. + /// + /// Returns a [Result] indicating success or failure of the send operation. + Result send(WsRequest request) => _engine.sendMessage(request); + + /// Establishes a WebSocket connection. + /// + /// The connection state can be monitored through [connectionState] for real-time updates. + /// If the connection is already established or in progress, this method returns immediately. + /// + /// Returns a [Future] that completes when the connection attempt finishes. + Future connect() async { + // If the connection is already established or in the process of connecting, + // do not initiate a new connection. + if (connectionState.value is Connecting) return; + if (connectionState.value is Authenticating) return; + if (connectionState.value is Connected) return; + + // Update the connection state to 'connecting'. + _connectionState = const WebSocketConnectionState.connecting(); + + // Open the connection using the engine. + final result = await _engine.open(options); + + // If some failure occurs, disconnect and rethrow the error. + return result.onFailure((_, __) => disconnect()).getOrThrow(); + } + + /// Closes the WebSocket connection. + /// + /// When [closeCode] is provided, uses the specified close code for the disconnection. + /// The [source] indicates the reason for disconnection and affects reconnection behavior. + /// + /// Returns a [Future] that completes when the disconnection finishes. + Future disconnect({ + CloseCode closeCode = CloseCode.normalClosure, + DisconnectionSource source = const UserInitiated(), + }) async { + // If the connection is already disconnected, do nothing. + if (connectionState.value is Disconnected) return; + + // Update the connection state to 'disconnecting'. + _connectionState = WebSocketConnectionState.disconnecting(source: source); + + // Close the connection using the engine. + unawaited(_engine.close(closeCode, source.closeReason)); + } + + @override + void onOpen() { + // Update the connection state to 'authenticating'. + _connectionState = const WebSocketConnectionState.authenticating(); + + // Notify that the connection has been established and we are ready + // to authenticate. + onConnectionEstablished?.call(); + } + + @override + void onClose([int? closeCode, String? closeReason]) { + final source = switch (connectionState.value) { + // If we were already disconnecting, keep the caller-provided source. + Disconnecting(:final source) => source, + + // Any active state that wasn’t user/system initiated becomes server initiated. + Connecting() || Authenticating() || Connected() => ServerInitiated( + error: WebSocketEngineException( + code: closeCode, + reason: closeReason, + ), + ), + + // Not meaningful to transition from these; just log and bail. + Initialized() || Disconnected() => null, + }; + + if (source == null) return; + + // Update the connection state to 'disconnected' with the source. + _connectionState = WebSocketConnectionState.disconnected(source: source); + } + + @override + void onError(Object error, [StackTrace? stackTrace]) { + final source = ServerInitiated( + error: WebSocketEngineException(error: error), + ); + + // Update the connection state to 'disconnecting' with the source. + // + // Note: We don't have to use `Disconnected` state here because the socket + // automatically closes the connection after sending the error. + _connectionState = WebSocketConnectionState.disconnecting(source: source); + } + + @override + void onMessage(WsEvent event) { + // If the event is an error event, handle it. + if (event.error case final error?) { + return _handleErrorEvent(event, error); + } + + // If the event is a health check event, handle it. + if (event.healthCheckInfo case final healthCheckInfo?) { + return _handleHealthCheckEvent(event, healthCheckInfo); + } + + // Emit the decoded event. + _events.emit(event); + } + + void _handleErrorEvent(WsEvent event, Object error) { + final source = ServerInitiated( + error: WebSocketEngineException(error: error), + ); + + // Update the connection state to 'disconnecting'. + _connectionState = WebSocketConnectionState.disconnecting(source: source); + } + + void _handleHealthCheckEvent(WsEvent event, HealthCheckInfo info) { + print('WebSocketClient: Health check pong received: $info'); + + // Update the connection state with health check info. + _connectionState = WebSocketConnectionState.connected(healthCheck: info); + + // Notify the health monitor that a pong has been received. + _healthMonitor.onPongReceived(); + + // Emit the health check event. + // + // Note: We send the event even after handling it to allow + // listeners to react to it if needed. + _events.emit(event); + } + + @override + void onPingRequested() { + // Send a ping request if the connection is established. + if (connectionState.value case Connected(:final healthCheck)) { + final pingRequest = pingRequestBuilder(healthCheck); + + // Send the ping request. + send(pingRequest); + print('WebSocketClient: Ping request sent: $pingRequest'); + } + } + + @override + void onUnhealthy() { + // Disconnect the socket if it becomes unhealthy. + const source = DisconnectionSource.unHealthyConnection(); + return unawaited(disconnect(source: source)); + } +} 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 deleted file mode 100644 index 001212f..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'dart:async'; - -import 'package:web_socket_channel/web_socket_channel.dart'; - -class WebSocketChannelFactory { - const WebSocketChannelFactory(); - 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 deleted file mode 100644 index fa2071d..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_html.dart +++ /dev/null @@ -1,33 +0,0 @@ -// ignore_for_file: avoid_web_libraries_in_flutter - -import 'dart:async'; - -import 'package:web/web.dart' as web; -import 'package:web_socket_channel/html.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class WebSocketChannelFactory { - const WebSocketChannelFactory(); - Future connect( - Uri uri, { - Iterable? protocols, - }) async { - final completer = Completer(); - final webSocket = web.WebSocket(uri.toString()) - ..binaryType = BinaryType.list.value; - - unawaited( - webSocket.onOpen.first.then((value) { - completer.complete(HtmlWebSocketChannel(webSocket)); - }), - ); - - unawaited( - webSocket.onError.first.then((err) { - completer.completeError(WebSocketChannelException.from(err)); - }), - ); - - return completer.future; - } -} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart b/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart deleted file mode 100644 index e0ef815..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_channel_factory/web_socket_channel_factory_io.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:web_socket_channel/io.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class WebSocketChannelFactory { - const WebSocketChannelFactory(); - Future connect( - Uri uri, { - Iterable? protocols, - }) async { - try { - final webSocket = WebSocket.connect( - uri.toString(), - protocols: protocols, - ); - return IOWebSocketChannel(await webSocket); - } on SocketException catch (err) { - throw WebSocketChannelException.from(err); - } - } -} 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 deleted file mode 100644 index 4f1e4b5..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_client.dart +++ /dev/null @@ -1,216 +0,0 @@ -import '../../errors/client_exception.dart'; -import '../../utils/shared_emitter.dart'; -import '../events/sendable_event.dart'; -import '../events/ws_event.dart'; -import 'web_socket_connection_state.dart'; -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; - final VoidCallback? onConnectionEstablished; - final VoidCallback? onConnected; - final EventDecoder eventDecoder; - - WebSocketConnectionState _connectionStateValue = - WebSocketConnectionState.initialized(); - - set _connectionState(WebSocketConnectionState connectionState) { - if (connectionState == _connectionStateValue) return; - - print('Connection state changed to ${connectionState.runtimeType}'); - pingController.connectionStateChanged(connectionState); - _connectionStateStreamController.emit(connectionState); - _connectionStateValue = connectionState; - } - - WebSocketConnectionState get connectionState => _connectionStateValue; - SharedEmitter get connectionStateStream => - _connectionStateStreamController; - final _connectionStateStreamController = - MutableSharedEmitterImpl(); - - SharedEmitter get events => _events; - final _events = MutableSharedEmitterImpl(); - - String? get connectionId => _connectionId; - String? _connectionId; - - void send(SendableEvent message) { - engine.send(message: message); - } - - //#region Connection - void connect() { - if (connectionState is Connecting || - connectionState is Authenticating || - connectionState is Connected) { - return; - } - - _connectionState = WebSocketConnectionState.connecting(); - engine.connect(); - } - - void disconnect({ - CloseCode code = CloseCode.normalClosure, - DisconnectionSource source = const UserInitiated(), - }) { - _connectionState = WebSocketConnectionState.disconnecting( - source: source, - ); - - engine.disconnect(code.code, source.toString()); - } - - void dispose() { - pingController.dispose(); - _connectionStateStreamController.close(); - _events.close(); - } - //#endregion - - //#region WebSocketEngineListener - @override - void webSocketDidConnect() { - print('Web socket connection established'); - _connectionState = WebSocketConnectionState.authenticating(); - onConnectionEstablished?.call(); - } - - @override - void webSocketDidDisconnect(WebSocketEngineException? exception) { - switch (connectionState) { - case Connecting() || Authenticating() || Connected(): - _connectionState = WebSocketConnectionState.disconnected( - source: DisconnectionSource.serverInitiated( - error: WebSocketException(exception), - ), - ); - case final Disconnecting disconnecting: - _connectionState = - WebSocketConnectionState.disconnected(source: disconnecting.source); - case Initialized() || Disconnected(): - print( - 'Web socket can not be disconnected when in ${connectionState.runtimeType} state', - ); - } - } - - void _handleHealthCheckEvent(HealthCheckInfo healthCheckInfo) { - final wasAuthenticating = connectionState is Authenticating; - - _connectionState = WebSocketConnectionState.connected( - healthCheckInfo: healthCheckInfo, - ); - _connectionId = healthCheckInfo.connectionId; - pingController.pongReceived(); - - if (wasAuthenticating) { - onConnected?.call(); - } - } - - @override - void webSocketDidReceiveMessage(Object message) { - final event = eventDecoder(message); - if (event == null) { - print('Received message is an unhandled event: $message'); - return; - } - - if (event.healthCheckInfo case final healthCheckInfo?) { - _handleHealthCheckEvent(healthCheckInfo); - } - - if (event.error case final error?) { - _connectionState = WebSocketConnectionState.disconnecting( - source: ServerInitiated(error: ClientException(error: error)), - ); - } - - _events.emit(event); - } - - @override - void webSocketDidReceiveError(Object error, StackTrace stackTrace) { - _connectionState = WebSocketConnectionState.disconnecting( - source: ServerInitiated(error: ClientException(error: error)), - ); - } - //#endregion - - //#region Ping client - @override - void sendPing() { - if (connectionState.isConnected) { - final healthCheckEvent = pingReguestBuilder?.call() ?? - HealthCheckPingEvent(connectionId: _connectionId); - engine.send(message: healthCheckEvent); - } - } - - @override - void disconnectNoPongReceived() { - print('disconnecting from ${engine.url}'); - disconnect( - source: DisconnectionSource.noPongReceived(), - ); - } - //#endregion -} - -class WebSocketEnvironment { - const WebSocketEnvironment(); - - WebSocketEngine createEngine({ - required String url, - required WebSocketEngineListener listener, - }) => - URLSessionWebSocketEngine(url: url, listener: listener); - - WebSocketPingController createPingController({ - required WebSocketPingClient client, - }) => - WebSocketPingController(client: client); -} - -typedef EventDecoder = WsEvent? Function(Object message); -typedef PingReguestBuilder = SendableEvent Function(); -typedef VoidCallback = void Function(); - -enum CloseCode { - invalid(0), - normalClosure(1000), - goingAway(1001), - protocolError(1002), - unsupportedData(1003), - noStatusReceived(1005), - abnormalClosure(1006), - invalidFramePayloadData(1007), - policyViolation(1008), - messageTooBig(1009), - mandatoryExtensionMissing(1010), - internalServerError(1011), - tlsHandshakeFailure(1015); - - const CloseCode(this.code); - final int code; -} 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 6623f3f..0ea03e4 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 @@ -1,49 +1,122 @@ import 'package:equatable/equatable.dart'; -import '../../errors/client_exception.dart'; +import '../../errors.dart' show StreamApiErrorExtension; +import '../../utils.dart'; import '../events/ws_event.dart'; -import 'web_socket_ping_controller.dart'; - -/// A web socket connection state. +import 'engine/web_socket_engine.dart'; +import 'stream_web_socket_client.dart'; + +/// A state emitter for WebSocket connection state changes. +/// +/// Provides read-only access to the current [WebSocketConnectionState] and allows +/// listening to state changes over time. +typedef ConnectionStateEmitter = StateEmitter; + +/// A mutable state emitter for WebSocket connection state changes. +/// +/// Extends [ConnectionStateEmitter] with the ability to update the current state. +/// Used internally by WebSocket client implementations to manage state transitions. +typedef MutableConnectionStateEmitter = MutableStateEmitter; + +/// Represents the current state of a WebSocket connection. +/// +/// A sealed class hierarchy that defines all possible states a WebSocket connection +/// can be in during its lifecycle. Each state provides specific information about +/// the connection status and determines available operations. +/// +/// The connection progresses through states in this typical order: +/// 1. [Initialized] - Initial state before any connection attempt +/// 2. [Connecting] - Attempting to establish WebSocket connection +/// 3. [Authenticating] - Connection established, authenticating with server +/// 4. [Connected] - Fully connected and authenticated +/// 5. [Disconnecting] - Gracefully closing the connection +/// 6. [Disconnected] - Connection closed +/// +/// States can transition directly to [Disconnected] from any other state in case +/// of errors or unexpected disconnections. sealed class WebSocketConnectionState extends Equatable { const WebSocketConnectionState(); - factory WebSocketConnectionState.initialized() => const Initialized(); - factory WebSocketConnectionState.connecting() => const Connecting(); - factory WebSocketConnectionState.authenticating() => const Authenticating(); - factory WebSocketConnectionState.connected({ - HealthCheckInfo? healthCheckInfo, - }) => - Connected(healthCheckInfo: healthCheckInfo); - factory WebSocketConnectionState.disconnecting({ + /// Creates an [Initialized] connection state. + /// + /// This is the initial state before any connection attempt has been made. + const factory WebSocketConnectionState.initialized() = Initialized; + + /// Creates a [Connecting] connection state. + /// + /// Indicates that a connection attempt is currently in progress. + const factory WebSocketConnectionState.connecting() = Connecting; + + /// Creates an [Authenticating] connection state. + /// + /// Indicates that the WebSocket connection is established and authentication is in progress. + const factory WebSocketConnectionState.authenticating() = Authenticating; + + /// Creates a [Connected] connection state. + /// + /// Indicates that the WebSocket is fully connected and authenticated with active health monitoring. + const factory WebSocketConnectionState.connected({ + required HealthCheckInfo healthCheck, + }) = Connected; + + /// Creates a [Disconnecting] connection state. + /// + /// Indicates that the connection is in the process of being gracefully closed. + const factory WebSocketConnectionState.disconnecting({ required DisconnectionSource source, - }) => - Disconnecting(source: source); - factory WebSocketConnectionState.disconnected({ + }) = Disconnecting; + + /// Creates a [Disconnected] connection state. + /// + /// Indicates that the connection is closed and not available for communication. + const factory WebSocketConnectionState.disconnected({ required DisconnectionSource source, - }) => - Disconnected(source: source); + }) = Disconnected; - /// Checks if the connection state is connected. + /// Whether the connection state is in `connected` state. + /// + /// Returns `true` if the current state is [Connected], `false` otherwise. bool get isConnected => this is Connected; - /// Returns false if the connection state is in the `notConnected` state. + /// Whether the connection state is currently active. + /// + /// An active connection is any state except [Disconnected]. This includes + /// transitional states like [Connecting], [Authenticating], and [Disconnecting]. + /// + /// Returns `true` if the connection is not in [Disconnected] state. bool get isActive => this is! Disconnected; - /// Returns `true` is the state requires and allows automatic reconnection. + /// Whether automatic reconnection is enabled for this connection state. + /// + /// Determines if the connection should automatically attempt to reconnect based on + /// the current state and disconnection source. Only applies to [Disconnected] states. + /// + /// ## Reconnection is enabled for: + /// - Server-initiated disconnections (except authentication and client errors) + /// - System-initiated disconnections (network changes, app lifecycle, etc.) + /// - Unhealthy connection disconnections (missing pong responses) + /// + /// ## Reconnection is disabled for: + /// - User-initiated disconnections (explicit disconnect calls) + /// - Server errors with code 1000 (normal closure) + /// - Token expired/invalid errors + /// - Client errors (4xx status codes) + /// + /// Returns `true` if automatic reconnection should be attempted. bool get isAutomaticReconnectionEnabled { - if (this is! Disconnected) { - return false; - } - - final source = (this as Disconnected).source; - - return switch (source) { - final ServerInitiated serverInitiated => - serverInitiated.error != null, //TODO: Implement - UserInitiated() => false, - SystemInitiated() => true, - NoPongReceived() => true, + return switch (this) { + Disconnected(:final source) => switch (source) { + ServerInitiated() => switch (source.error?.apiError) { + final error? when error.code == 1000 => false, + final error? when error.isTokenExpiredError => false, + final error? when error.isClientError => false, + _ => true, // Reconnect on other server initiated disconnections + }, + UnHealthyConnection() => true, + SystemInitiated() => true, + UserInitiated() => false, + }, + _ => false, // No automatic reconnection for other states }; } @@ -51,115 +124,199 @@ sealed class WebSocketConnectionState extends Equatable { List get props => []; } -/// The initial state meaning that there was no atempt to connect yet. +/// The initial state before any connection attempt has been made. +/// +/// This is the default state when a [StreamWebSocketClient] is first created. +/// No network operations have been initiated and the client is ready to begin +/// a connection attempt. final class Initialized extends WebSocketConnectionState { - /// The initial state meaning that there was no atempt to connect yet. + /// Creates an [Initialized] connection state. const Initialized(); } -/// The web socket is connecting. +/// The WebSocket is attempting to establish a connection. +/// +/// This state indicates that a connection attempt is in progress. The client +/// is trying to establish a WebSocket connection to the server but has not +/// yet received confirmation that the connection is open. final class Connecting extends WebSocketConnectionState { - /// The web socket is connecting. + /// Creates a [Connecting] connection state. const Connecting(); } -/// The web socket is connected, client is authenticating. +/// The WebSocket connection is established and authentication is in progress. +/// +/// This state indicates that the low-level WebSocket connection has been +/// successfully established, but the client is still in the process of +/// authenticating with the server before it can send and receive messages. final class Authenticating extends WebSocketConnectionState { - /// The web socket is connected, client is authenticating. + /// Creates an [Authenticating] connection state. const Authenticating(); } -/// The web socket was connected. +/// The WebSocket is fully connected and authenticated. +/// +/// This state indicates that the connection is fully established and the client +/// can send and receive messages. Health monitoring is active and the connection +/// is considered stable. final class Connected extends WebSocketConnectionState { - /// The web socket was connected. - const Connected({this.healthCheckInfo}); + /// Creates a [Connected] connection state. + const Connected({required this.healthCheck}); - /// Health check info on the websocket connection. - final HealthCheckInfo? healthCheckInfo; + /// Health check information for the active WebSocket connection. + /// + /// Contains details about the connection health monitoring, including + /// connection ID and timing information used for ping/pong health checks. + final HealthCheckInfo healthCheck; @override - List get props => [healthCheckInfo]; + List get props => [healthCheck]; } -/// The web socket is disconnecting. +/// The WebSocket connection is in the process of being closed. +/// +/// This state indicates that a disconnection has been initiated and is in progress. +/// The connection is being gracefully closed and will transition to [Disconnected] +/// once the closure is complete. final class Disconnecting extends WebSocketConnectionState { - /// The web socket is disconnecting. [source] contains more info about the source of the event. + /// Creates a [Disconnecting] connection state. const Disconnecting({required this.source}); - /// Contains more info about the source of the event. + /// The source that initiated the disconnection. + /// + /// Provides information about what triggered the disconnection, which affects + /// whether automatic reconnection will be attempted. final DisconnectionSource source; @override List get props => [source]; } -/// The web socket is not connected. Contains the source/reason why the disconnection has happened. +/// The WebSocket connection is closed and not available for communication. +/// +/// This is the final state after a connection has been terminated. The connection +/// cannot send or receive messages and may be eligible for automatic reconnection +/// depending on the disconnection source. final class Disconnected extends WebSocketConnectionState { - /// The web socket is not connected. Contains the source/reason why the disconnection has happened. + /// Creates a [Disconnected] connection state. const Disconnected({required this.source}); - /// Provides additional information about the source of disconnecting. + /// The source that caused the disconnection. + /// + /// Provides detailed information about why the connection was closed, including + /// whether it was user-initiated, server-initiated, or due to system conditions. + /// This information determines reconnection eligibility. final DisconnectionSource source; @override List get props => [source]; } -/// Provides additional information about the source of disconnecting. +/// Represents the source or cause of a WebSocket disconnection. +/// +/// A sealed class hierarchy that categorizes different reasons why a WebSocket +/// connection was closed. The disconnection source determines whether automatic +/// reconnection should be attempted and provides context for error handling. +/// +/// Each source type provides specific information about the disconnection cause: +/// - [UserInitiated]: Explicit disconnection requested by the application +/// - [ServerInitiated]: Server closed the connection, possibly with an error +/// - [SystemInitiated]: System-level disconnection (network, app lifecycle) +/// - [UnHealthyConnection]: Connection closed due to failed health checks sealed class DisconnectionSource extends Equatable { const DisconnectionSource(); - factory DisconnectionSource.userInitiated() => const UserInitiated(); - factory DisconnectionSource.serverInitiated({ClientException? error}) => - ServerInitiated(error: error); - factory DisconnectionSource.systemInitiated() => const SystemInitiated(); - factory DisconnectionSource.noPongReceived() => const NoPongReceived(); - - /// Returns the underlaying error if connection cut was initiated by the server. - ClientException? get serverError => - this is ServerInitiated ? (this as ServerInitiated).error : null; + /// Creates a [UserInitiated] disconnection source. + /// + /// Indicates that the disconnection was explicitly requested by the application. + /// Automatic reconnection is disabled for user-initiated disconnections. + const factory DisconnectionSource.userInitiated() = UserInitiated; + + /// Creates a [ServerInitiated] disconnection source. + /// + /// Indicates that the server closed the connection, optionally with error details. + /// Reconnection eligibility depends on the specific error type. + const factory DisconnectionSource.serverInitiated({ + WebSocketEngineException? error, + }) = ServerInitiated; + + /// Creates a [SystemInitiated] disconnection source. + /// + /// Indicates that the connection was closed due to system-level conditions + /// such as network changes or application lifecycle events. + const factory DisconnectionSource.systemInitiated() = SystemInitiated; + + /// Creates an [UnHealthyConnection] disconnection source. + /// + /// Indicates that the connection was closed due to failed health checks, + /// typically when ping requests do not receive pong responses. + const factory DisconnectionSource.unHealthyConnection() = UnHealthyConnection; + + /// A human-readable description of the disconnection source. + /// + /// Provides a descriptive string that explains why the connection was closed. + /// This is typically used for logging and debugging purposes. + /// + /// Returns a descriptive string for the disconnection cause. + String get closeReason { + return switch (this) { + UserInitiated() => 'User initiated disconnection', + ServerInitiated() => 'Server initiated disconnection', + SystemInitiated() => 'System initiated disconnection', + UnHealthyConnection() => 'Unhealthy connection (no pong received)', + }; + } @override List get props => []; } -/// A user initiated web socket disconnecting. +/// A disconnection that was explicitly requested by the application. +/// +/// This source indicates that the disconnection was intentionally triggered +/// by application code, typically through a call to `disconnect()`. Automatic +/// reconnection is disabled for user-initiated disconnections. final class UserInitiated extends DisconnectionSource { - /// A user initiated web socket disconnecting. + /// Creates a [UserInitiated] disconnection source. const UserInitiated(); } -/// A server initiated web socket disconnecting, an optional error object is provided. +/// A disconnection that was initiated by the server. +/// +/// This source indicates that the server closed the WebSocket connection, +/// either gracefully or due to an error condition. The optional [error] +/// provides additional context about the disconnection cause. final class ServerInitiated extends DisconnectionSource { - /// A server initiated web socket disconnecting, an optional error object is provided. + /// Creates a [ServerInitiated] disconnection source. const ServerInitiated({this.error}); - /// The error that caused the disconnection. - final ClientException? error; + /// The error that caused the server to close the connection. + /// + /// When present, contains details about the server error that led to + /// disconnection. This can include authentication failures, protocol + /// violations, or other server-side issues. + final WebSocketEngineException? error; @override List get props => [error]; - - bool get isAutomaticReconnectionEnabled { - if (error case final WebSocketEngineException webSocketEngineException) { - if (webSocketEngineException.code == - WebSocketEngineException.stopErrorCode) { - return false; - } - } - - return true; - } } -/// The system initiated web socket disconnecting. +/// A disconnection that was initiated by system-level conditions. +/// +/// This source indicates that the connection was closed due to system events +/// such as network connectivity changes, application lifecycle transitions, +/// or other environmental factors outside of direct user or server control. final class SystemInitiated extends DisconnectionSource { - /// The system initiated web socket disconnecting. + /// Creates a [SystemInitiated] disconnection source. const SystemInitiated(); } -/// [WebSocketPingController] didn't get a pong response. -final class NoPongReceived extends DisconnectionSource { - /// [WebSocketPingController] didn't get a pong response. - const NoPongReceived(); +/// A disconnection caused by failed connection health checks. +/// +/// This source indicates that the connection was closed because health +/// monitoring detected an unresponsive connection, typically when ping +/// requests do not receive corresponding pong responses within the timeout. +final class UnHealthyConnection extends DisconnectionSource { + /// Creates an [UnHealthyConnection] disconnection source. + const UnHealthyConnection(); } 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 deleted file mode 100644 index 7390912..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_engine.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:async'; - -import 'package:web_socket_channel/web_socket_channel.dart'; - -import '../../errors/client_exception.dart'; -import '../../utils.dart'; -import '../events/sendable_event.dart'; - -import 'web_socket_channel_factory/web_socket_channel_factory.dart' - if (dart.library.html) 'web_socket_channel_factory/web_socket_channel_factory_html.dart' - if (dart.library.io) 'web_socket_channel_factory/web_socket_channel_factory_io.dart' - as ws_platform; - -abstract interface class WebSocketEngine { - String get url; - Future connect(); - Future disconnect([int? closeCode, String? closeReason]); - void send({required SendableEvent message}); - Future sendPing(); -} - -abstract interface class WebSocketEngineListener { - void webSocketDidConnect(); - void webSocketDidDisconnect(WebSocketEngineException? exception); - void webSocketDidReceiveMessage(Object message); - void webSocketDidReceiveError(Object error, StackTrace stackTrace); -} - -class URLSessionWebSocketEngine implements WebSocketEngine { - URLSessionWebSocketEngine({ - required this.url, - required this.listener, - this.protocols, - this.wsChannelFactory = const ws_platform.WebSocketChannelFactory(), - }); - - /// The URI to connect to. - @override - final String url; - - /// The protocols to use. - final Iterable? protocols; - - final WebSocketEngineListener listener; - final ws_platform.WebSocketChannelFactory wsChannelFactory; - - var _connectRequestInProgress = false; - - WebSocketChannel? _ws; - StreamSubscription? _wsSubscription; - - @override - Future> connect() async { - print( - '[connect] connectRequestInProgress: ' - '$_connectRequestInProgress, url: $url', - ); - try { - if (_connectRequestInProgress) { - print('Connect request already in progress'); - return Result.failure( - WebSocketEngineException( - reason: 'Connect request already in progress', - code: 0, - ), - ); - } - _connectRequestInProgress = true; - - final uri = Uri.parse(url); - _ws = await wsChannelFactory.connect(uri, protocols: protocols); - - listener.webSocketDidConnect(); - - _wsSubscription = _ws!.stream.listen( - (message) => listener.webSocketDidReceiveMessage(message as Object), - onError: listener.webSocketDidReceiveError, - onDone: () => listener.webSocketDidDisconnect( - WebSocketEngineException( - code: _ws?.closeCode ?? 0, - reason: _ws?.closeReason ?? 'Unknown', - ), - ), - ); - return const Result.success(null); - } catch (error, stackTrace) { - print(() => '[connect] failed: $error'); - listener.webSocketDidReceiveError(error, stackTrace); - return Result.failure(error, stackTrace); - } finally { - _connectRequestInProgress = false; - } - } - - @override - Future> disconnect([int? closeCode, String? closeReason]) async { - try { - print( - '[disconnect] connectRequestInProgress: ' - '$_connectRequestInProgress, url: $url', - ); - await _ws?.sink.close(closeCode, closeReason); - _ws = null; - await _wsSubscription?.cancel(); - _wsSubscription = null; - return const Result.success(null); - } catch (error, stackTrace) { - print(() => '[disconnect] failed: $error'); - return Result.failure(error, stackTrace); - } - } - - @override - void send({required SendableEvent message}) { - print('[send] hasWS: ${_ws != null}'); - _ws?.sink.add(message.toSerializedData()); - } - - @override - Future sendPing() { - // TODO: implement sendPing - throw UnimplementedError(); - } -} diff --git a/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart new file mode 100644 index 0000000..ea74467 --- /dev/null +++ b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'web_socket_connection_state.dart'; + +/// Interface for receiving WebSocket health monitoring events. +/// +/// Implementations of this interface receive callbacks when health monitoring +/// events occur, such as when a ping should be sent or when the connection +/// is determined to be unhealthy. +abstract interface class WebSocketHealthListener { + /// Called when the WebSocket connection is determined to be unhealthy. + /// + /// This typically occurs when no pong response is received within the + /// configured timeout threshold after sending a ping request. Implementations + /// should initiate a disconnection with an unhealthy connection source. + void onUnhealthy(); + + /// Called when it's time to send a ping request for health checking. + /// + /// The listener should send a ping message through the WebSocket connection + /// to verify that the connection is still active and responsive. Implementations + /// should build and send a ping request if the connection is currently established. + void onPingRequested(); +} + +/// A health monitor for WebSocket connections with ping/pong management. +/// +/// Manages the health checking mechanism for WebSocket connections by automatically +/// sending ping requests and monitoring for pong responses to detect unhealthy connections. +/// +/// The monitor integrates with [WebSocketHealthListener] to provide automatic connection +/// health detection and recovery triggers. +/// +/// ## Health Check Process +/// +/// 1. **Automatic Start**: Monitoring begins when connection state becomes connected +/// 2. **Ping Scheduling**: Sends periodic ping requests at the configured interval +/// 3. **Pong Monitoring**: Starts timeout timer after each ping request +/// 4. **Unhealthy Detection**: Marks connection unhealthy if no pong received within threshold +/// 5. **Automatic Stop**: Stops monitoring when connection becomes inactive +class WebSocketHealthMonitor { + /// Creates a new instance of [WebSocketHealthMonitor]. + WebSocketHealthMonitor({ + required WebSocketHealthListener listener, + this.pingInterval = const Duration(seconds: 25), + this.timeoutThreshold = const Duration(seconds: 3), + }) : _listener = listener; + + /// The interval between ping requests for health checking. + final Duration pingInterval; + + /// The maximum time to wait for a pong response before considering the connection unhealthy. + final Duration timeoutThreshold; + + final WebSocketHealthListener _listener; + + Timer? _pingTimer; + Timer? _pongTimer; + + /// Starts health monitoring with periodic ping requests. + /// + /// Called automatically when the WebSocket connection becomes active. + /// Schedules the first ping request immediately if no monitoring is already active. + void start() { + if (_pingTimer?.isActive ?? false) return; + + _pongTimer?.cancel(); + return _schedulePing(); + } + + /// Handles pong response reception. + /// + /// Cancels the current pong timeout timer, indicating the connection is healthy. + /// Called automatically when pong events are received from the WebSocket. + void onPongReceived() => _pongTimer?.cancel(); + + /// Handles connection state changes. + /// + /// Starts monitoring when the connection becomes active and stops monitoring + /// when the connection becomes inactive. Called automatically by the WebSocket client. + void onConnectionStateChanged(WebSocketConnectionState state) { + if (state.isConnected) return start(); + return stop(); + } + + void _schedulePing() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(pingInterval, _sendPing); + } + + void _sendPing(Timer pingTimer) { + if (!pingTimer.isActive) return; + + _listener.onPingRequested(); + + _pongTimer?.cancel(); + _pongTimer = Timer(timeoutThreshold, _listener.onUnhealthy); + } + + /// Stops health monitoring and cancels all timers. + /// + /// Called automatically when the WebSocket connection becomes inactive or is closed. + /// Cancels both the ping scheduling timer and any active pong timeout timer. + void stop() { + _pingTimer?.cancel(); + _pongTimer?.cancel(); + } +} 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 deleted file mode 100644 index dccc0c5..0000000 --- a/packages/stream_core/lib/src/ws/client/web_socket_ping_controller.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:async'; - -import 'web_socket_connection_state.dart'; - -/// WebSocketPingController is used to monitor the health of the websocket connection. -/// It will send a ping message to the server every [_pingTimeInterval] and wait for a pong response. -/// If the pong response is not received within [_pongTimeout], the connection is considered disconnected. -/// -/// The controller will automatically resume the ping timer when the connection is resumed. -/// -/// The controller will automatically pause the ping timer when the connection is disconnected. -/// -/// 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), - Duration pongTimeout = const Duration(seconds: 3), - }) : _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(); - - if (connectionState.isConnected) { - print('Resume Websocket Ping timer'); - _pingTimer = Timer.periodic(_pingTimeInterval, (_) { - sendPing(); - }); - } else { - _pingTimer?.cancel(); - } - } - - void sendPing() { - print('WebSocket Ping'); - _schedulePongTimeoutTimer(); - _client.sendPing(); - } - - void pongReceived() { - print('WebSocket Pong'); - _cancelPongTimeoutTimer(); - } - - void _schedulePongTimeoutTimer() { - _pongTimeoutTimer?.cancel(); - _pongTimeoutTimer = Timer(_pongTimeout, _client.disconnectNoPongReceived); - } - - void _cancelPongTimeoutTimer() { - _pongTimeoutTimer?.cancel(); - } - - void dispose() { - _pongTimeoutTimer?.cancel(); - _pingTimer?.cancel(); - } -} - -abstract interface class WebSocketPingClient { - void sendPing(); - void disconnectNoPongReceived(); -} diff --git a/packages/stream_core/lib/src/ws/events/event_emitter.dart b/packages/stream_core/lib/src/ws/events/event_emitter.dart new file mode 100644 index 0000000..350e3ca --- /dev/null +++ b/packages/stream_core/lib/src/ws/events/event_emitter.dart @@ -0,0 +1,68 @@ +import '../../utils.dart'; +import 'ws_event.dart'; + +/// A function that inspects an event and optionally resolves it into a +/// more specific or refined version of the same type. +/// +/// If the resolver does not recognize or handle the event, +/// it returns `null`, allowing other resolvers to attempt resolution. +typedef EventResolver = T? Function(T event); + +/// A read-only event emitter for WebSocket events. +/// +/// Provides the same functionality as [SharedEmitter] but with type constraints +/// to ensure only [WsEvent] subtypes can be emitted. This is the read-only +/// interface used by consumers to listen for WebSocket events. +typedef EventEmitter = SharedEmitter; + +/// A mutable event emitter for WebSocket events with resolver support. +/// +/// Extends [SharedEmitterImpl] to provide event resolution capabilities for WebSocket events. +/// Before emitting an event, the emitter applies a series of [EventResolver]s to inspect +/// and potentially transform the event. The first resolver that returns a non-null result +/// determines the event that will be emitted. +/// +/// This is particularly useful for: +/// - Converting generic events to more specific event types +/// - Adding metadata or context to events +/// - Filtering or transforming events before emission +/// +/// Example usage: +/// ```dart +/// final emitter = MutableEventEmitter( +/// resolvers: [ +/// (event) => event is GenericEvent ? SpecificEvent(event.data) : null, +/// ], +/// ); +/// +/// emitter.on((event) { +/// // Handle SpecificEvent +/// }); +/// +/// emitter.emit(GenericEvent(data)); // Will be resolved to SpecificEvent +/// ``` +class MutableEventEmitter extends SharedEmitterImpl { + /// Creates a new [MutableEventEmitter] with optional event resolvers. + /// + /// When [resolvers] are provided, they will be applied to each emitted event + /// in order until one returns a non-null result. The [replay] and [sync] + /// parameters are passed to the underlying [SharedEmitterImpl]. + MutableEventEmitter({ + super.replay = 0, + super.sync = false, + Iterable>? resolvers, + }) : _resolvers = resolvers ?? const {}; + + final Iterable> _resolvers; + + @override + void emit(T value) { + for (final resolver in _resolvers) { + final result = resolver(value); + if (result != null) return super.emit(result); + } + + // No resolver matched — emit the event as-is. + return super.emit(value); + } +} diff --git a/packages/stream_core/lib/src/ws/events/sendable_event.dart b/packages/stream_core/lib/src/ws/events/sendable_event.dart deleted file mode 100644 index 2115354..0000000 --- a/packages/stream_core/lib/src/ws/events/sendable_event.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -// ignore: one_member_abstracts -abstract interface class SendableEvent { - /// Serialize the object to `String` or `Uint8List`. - Object toSerializedData(); -} - -final class HealthCheckPingEvent implements SendableEvent { - const HealthCheckPingEvent({ - required this.connectionId, - }); - - final String? connectionId; - - @override - Object toSerializedData() => json.encode( - [ - { - 'type': 'health.check', - 'client_id': connectionId, - } - ], - ); -} diff --git a/packages/stream_core/lib/src/ws/events/ws_event.dart b/packages/stream_core/lib/src/ws/events/ws_event.dart index f3ab03b..c93a88b 100644 --- a/packages/stream_core/lib/src/ws/events/ws_event.dart +++ b/packages/stream_core/lib/src/ws/events/ws_event.dart @@ -6,25 +6,10 @@ abstract class WsEvent extends Equatable { Object? get error => null; HealthCheckInfo? get healthCheckInfo => null; - @override - bool? get stringify => true; - @override List get props => []; } -class HealthCheckPongEvent extends WsEvent { - const HealthCheckPongEvent({ - required this.healthCheckInfo, - }); - - @override - final HealthCheckInfo healthCheckInfo; - - @override - List get props => [healthCheckInfo]; -} - final class HealthCheckInfo extends Equatable { const HealthCheckInfo({ this.connectionId, @@ -37,18 +22,3 @@ final class HealthCheckInfo extends Equatable { @override List get props => [connectionId, participantCount]; } - -final class WsErrorEvent extends WsEvent { - const WsErrorEvent({ - required this.error, - required this.message, - }); - - final Object message; - - @override - final Object error; - - @override - List get props => [error, message]; -} diff --git a/packages/stream_core/lib/src/ws/events/ws_request.dart b/packages/stream_core/lib/src/ws/events/ws_request.dart new file mode 100644 index 0000000..2feb688 --- /dev/null +++ b/packages/stream_core/lib/src/ws/events/ws_request.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +abstract class WsRequest extends Equatable { + const WsRequest(); + + Map toJson(); +} + +final class HealthCheckPingEvent extends WsRequest { + const HealthCheckPingEvent({required this.connectionId}); + + final String? connectionId; + + @override + List get props => [connectionId]; + + @override + Map toJson() { + return { + 'type': 'health.check', + if (connectionId case final id?) 'client_id': id, + }; + } +} diff --git a/packages/stream_core/lib/stream_core.dart b/packages/stream_core/lib/stream_core.dart index d58b303..ea5f14a 100644 --- a/packages/stream_core/lib/stream_core.dart +++ b/packages/stream_core/lib/stream_core.dart @@ -1,6 +1,10 @@ +export 'package:dio/dio.dart'; + export 'src/api.dart'; export 'src/errors.dart'; -export 'src/models.dart'; +export 'src/logger.dart'; +export 'src/platform.dart'; +export 'src/query.dart'; export 'src/user.dart'; -export 'src/utils.dart'; +export 'src/utils.dart' hide SharedEmitterImpl, StateEmitterImpl; export 'src/ws.dart'; diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index 4a40dcc..8bcdcd2 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -4,6 +4,18 @@ version: 0.0.1 repository: https://github.com/GetStream/stream-core-flutter publish_to: none # Delete when ready to publish +# Note: The environment configuration and dependency versions are managed by Melos. +# +# Do not edit them manually. +# +# Steps to update dependencies: +# 1. Modify the version in the melos.yaml file. +# 2. Run `melos bootstrap` to apply changes. +# +# Steps to add a new dependency: +# 1. Add the dependency to this list. +# 2. Add it to the melos.yaml file for future updates. + environment: sdk: ^3.6.2 @@ -16,6 +28,7 @@ dependencies: json_annotation: ^4.9.0 meta: ^1.15.0 rxdart: ^0.28.0 + synchronized: ^3.3.0 web: ^1.1.1 web_socket_channel: ^3.0.1 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 index 1f048c4..569b1d2 100644 --- a/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart @@ -1,51 +1,54 @@ -// 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'; -} +// // ignore_for_file: invalid_use_of_protected_member +// +// import 'package:dio/dio.dart'; +// import 'package:stream_core/stream_core.dart'; +// import 'package:test/test.dart'; +// +// import '../../mocks.dart'; +// +// void main() { +// group('HeadersInterceptor tests', () { +// group('with SystemEnvironmentManager', () { +// late HeadersInterceptor headersInterceptor; +// +// setUp(() { +// final environmentManager = FakeSystemEnvironmentManager( +// environment: environment, +// ); +// +// headersInterceptor = HeadersInterceptor( +// FakeSystemEnvironmentManager( +// environment: systemEnvironmentManager.environment, +// ), +// ); +// }); +// +// test('should add user agent header when available', () async { +// HeadersInterceptor.additionalHeaders = { +// 'test-header': 'test-value', +// }; +// addTearDown(() => HeadersInterceptor.additionalHeaders = {}); +// +// final options = RequestOptions(path: 'test-path'); +// final handler = RequestInterceptorHandler(); +// +// await headersInterceptor.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 index 3fd096f..69ace68 100644 --- a/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart @@ -1,239 +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()); - } - }, - ); -} +// // 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/stream_http_client_options_test.dart b/packages/stream_core/test/api/stream_http_client_options_test.dart deleted file mode 100644 index 26cc421..0000000 --- a/packages/stream_core/test/api/stream_http_client_options_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -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 index 04f097d..1577a32 100644 --- a/packages/stream_core/test/api/stream_http_client_test.dart +++ b/packages/stream_core/test/api/stream_http_client_test.dart @@ -1,671 +1,671 @@ -// 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'; - -const testUser = User( - id: 'user-id', - name: 'test-user', - imageUrl: 'https://example.com/image.png', -); - -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); - }, - ); -} +// // 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'; +// +// const testUser = User( +// id: 'user-id', +// name: 'test-user', +// imageUrl: 'https://example.com/image.png', +// ); +// +// 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 index 8268474..5262075 100644 --- a/packages/stream_core/test/mocks.dart +++ b/packages/stream_core/test/mocks.dart @@ -1,50 +1,50 @@ -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 {} - -class MockDio extends Mock implements Dio { - BaseOptions? _options; - - @override - BaseOptions get options => _options ??= BaseOptions(); - - Interceptors? _interceptors; - - @override - Interceptors get interceptors => _interceptors ??= Interceptors(); -} - -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', - 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, - ); -} +// 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 {} +// +// class MockDio extends Mock implements Dio { +// BaseOptions? _options; +// +// @override +// BaseOptions get options => _options ??= BaseOptions(); +// +// Interceptors? _interceptors; +// +// @override +// Interceptors get interceptors => _interceptors ??= Interceptors(); +// } +// +// 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', +// 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, +// ); +// } diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart new file mode 100644 index 0000000..c8766ac --- /dev/null +++ b/packages/stream_core/test/query/filter_test.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:stream_core/src/query/filter.dart'; +import 'package:stream_core/src/query/filter_operator.dart'; +import 'package:test/test.dart'; + +void main() { + group('operators', () { + test('equal', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.equal(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.equal); + }); + + test('greater', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.greater(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greater); + }); + + test('greaterOrEqual', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.greaterOrEqual(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greaterOrEqual); + }); + + test('less', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.less(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.less); + }); + + test('lessOrEqual', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.lessOrEqual(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.lessOrEqual); + }); + + test('in', () { + const field = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(field, values); + expect(filter.field, field); + expect(filter.value, values); + expect(filter.operator, FilterOperator.in_); + }); + + test('in', () { + const field = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(field, values); + expect(filter.field, field); + expect(filter.value, values); + expect(filter.operator, FilterOperator.in_); + }); + + test('query', () { + const field = 'testKey'; + const value = 'testQuery'; + final filter = Filter.query(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.query); + }); + + test('autoComplete', () { + const field = 'testKey'; + const value = 'testQuery'; + final filter = Filter.autoComplete(field, value); + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.autoComplete); + }); + + test('exists', () { + const field = 'testKey'; + final filter = Filter.exists(field); + expect(filter.field, field); + expect(filter.value, isTrue); + expect(filter.operator, FilterOperator.exists); + }); + + test('raw', () { + const value = { + 'test': ['a', 'b'], + }; + const filter = Filter.raw(value: value); + expect(filter.value, value); + }); + + test('empty', () { + final filter = Filter.empty(); + expect(filter.value, {}); + }); + + test('contains', () { + const field = 'testKey'; + const values = 'testValue'; + final filter = Filter.contains(field, values); + expect(filter.field, field); + expect(filter.value, values); + expect(filter.operator, FilterOperator.contains); + }); + + group('groupedOperator', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.in_('testKey', const ['testValue']); + final filters = [filter1, filter2]; + + test('and', () { + final filter = Filter.and(filters); + expect(filter.field, isNull); + expect(filter.value, filters); + expect(filter.operator, FilterOperator.and); + }); + + test('or', () { + final filter = Filter.or(filters); + expect(filter.field, isNull); + expect(filter.value, filters); + expect(filter.operator, FilterOperator.or); + }); + }); + }); + + group('encoding', () { + group('nonGroupedFilter', () { + test('simpleValue', () { + const field = 'testKey'; + const value = 'testValue'; + final filter = Filter.equal(field, value); + final encoded = json.encode(filter); + expect(encoded, '{"$field":{"\$eq":${json.encode(value)}}}'); + }); + + test('listValue', () { + const field = 'testKey'; + const values = ['testValue']; + final filter = Filter.in_(field, values); + final encoded = json.encode(filter); + expect(encoded, '{"$field":{"\$in":${json.encode(values)}}}'); + }); + + test('raw', () { + const value = { + 'test': ['a', 'b'], + }; + const filter = Filter.raw(value: value); + + final encoded = json.encode(filter); + expect(encoded, json.encode(value)); + }); + + test('empty', () { + final filter = Filter.empty(); + final encoded = json.encode(filter); + expect(encoded, '{}'); + }); + }); + + test('groupedFilter', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.in_('testKey', const ['testValue']); + final filters = [filter1, filter2]; + + final filter = Filter.and(filters); + final encoded = json.encode(filter); + expect(encoded, '{"\$and":${json.encode(filters)}}'); + }); + + group('equality', () { + test('simpleFilter', () { + final filter1 = Filter.equal('testKey', 'testValue'); + final filter2 = Filter.equal('testKey', 'testValue'); + expect(filter1, filter2); + }); + + test('groupedFilter', () { + final filter1 = Filter.and([Filter.equal('testKey', 'testValue')]); + final filter2 = Filter.and([Filter.equal('testKey', 'testValue')]); + expect(filter1, filter2); + }); + }); + }); +} diff --git a/packages/stream_core/test/query/list_extensions_test.dart b/packages/stream_core/test/query/list_extensions_test.dart new file mode 100644 index 0000000..2a4248d --- /dev/null +++ b/packages/stream_core/test/query/list_extensions_test.dart @@ -0,0 +1,1409 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:equatable/equatable.dart'; +import 'package:stream_core/src/utils/list_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ListExtensions', () { + group('upsert', () { + test('should add new element when key does not exist', () { + final users = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '2', name: 'Bob'), + ]; + + final result = users.upsert( + const _TestUser(id: '3', name: 'Charlie'), + key: (user) => user.id, + ); + + expect(result.length, 3); + expect(result.last.id, '3'); + expect(result.last.name, 'Charlie'); + // Original list should be unchanged + expect(users.length, 2); + }); + + test('should replace existing element when key exists', () { + final users = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '2', name: 'Bob'), + ]; + + final result = users.upsert( + const _TestUser(id: '1', name: 'Alice Updated'), + key: (user) => user.id, + ); + + expect(result.length, 2); + expect(result.first.id, '1'); + expect(result.first.name, 'Alice Updated'); + expect(result.last.name, 'Bob'); + // Original list should be unchanged + expect(users.first.name, 'Alice'); + }); + + test('should work with empty list', () { + final users = <_TestUser>[]; + + final result = users.upsert( + const _TestUser(id: '1', name: 'Alice'), + key: (user) => user.id, + ); + + expect(result.length, 1); + expect(result.first.id, '1'); + expect(result.first.name, 'Alice'); + }); + + test('should handle single element list replacement', () { + final users = [const _TestUser(id: '1', name: 'Alice')]; + + final result = users.upsert( + const _TestUser(id: '1', name: 'Alice Updated'), + key: (user) => user.id, + ); + + expect(result.length, 1); + expect(result.first.name, 'Alice Updated'); + }); + + test('should handle single element list addition', () { + final users = [const _TestUser(id: '1', name: 'Alice')]; + + final result = users.upsert( + const _TestUser(id: '2', name: 'Bob'), + key: (user) => user.id, + ); + + expect(result.length, 2); + expect(result.first.name, 'Alice'); + expect(result.last.name, 'Bob'); + }); + + test('should preserve order when replacing element', () { + final users = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '2', name: 'Bob'), + const _TestUser(id: '3', name: 'Charlie'), + ]; + + final result = users.upsert( + const _TestUser(id: '2', name: 'Bob Updated'), + key: (user) => user.id, + ); + + expect(result.length, 3); + expect(result[0].name, 'Alice'); + expect(result[1].name, 'Bob Updated'); + expect(result[2].name, 'Charlie'); + }); + + test('should work with different key types', () { + final scores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 200), + ]; + + final result = scores.upsert( + const _TestScore(userId: 1, points: 150), + key: (score) => score.userId, + ); + + expect(result.length, 2); + expect(result.first.points, 150); + expect(result.last.points, 200); + }); + + test('should handle complex objects with custom key', () { + final activities = [ + const _TestActivity(id: 'act1', authorId: 'user1', content: 'Hello'), + const _TestActivity(id: 'act2', authorId: 'user2', content: 'World'), + ]; + + final result = activities.upsert( + const _TestActivity( + id: 'act1', + authorId: 'user1', + content: 'Hello Updated', + ), + key: (activity) => activity.id, + ); + + expect(result.length, 2); + expect(result.first.content, 'Hello Updated'); + expect(result.last.content, 'World'); + }); + }); + }); + + group('SortedListExtensions', () { + group('sortedInsert', () { + test('should insert element at correct position in sorted list', () { + final numbers = [1, 3, 5, 7]; + + final result = + numbers.sortedInsert(4, compare: (a, b) => a.compareTo(b)); + + expect(result, [1, 3, 4, 5, 7]); + // Original list should be unchanged + expect(numbers, [1, 3, 5, 7]); + }); + + test('should insert at beginning when element is smallest', () { + final numbers = [3, 5, 7]; + + final result = + numbers.sortedInsert(1, compare: (a, b) => a.compareTo(b)); + + expect(result, [1, 3, 5, 7]); + }); + + test('should insert at end when element is largest', () { + final numbers = [1, 3, 5]; + + final result = + numbers.sortedInsert(7, compare: (a, b) => a.compareTo(b)); + + expect(result, [1, 3, 5, 7]); + }); + + test('should work with single element list', () { + final numbers = [5]; + + final smaller = + numbers.sortedInsert(3, compare: (a, b) => a.compareTo(b)); + final larger = + numbers.sortedInsert(7, compare: (a, b) => a.compareTo(b)); + + expect(smaller, [3, 5]); + expect(larger, [5, 7]); + }); + + test('should work with empty list', () { + final numbers = []; + + final result = + numbers.sortedInsert(5, compare: (a, b) => a.compareTo(b)); + + expect(result, [5]); + }); + + test('should work with reverse order comparator', () { + final numbers = [7, 5, 3, 1]; // Descending order + + final result = + numbers.sortedInsert(4, compare: (a, b) => b.compareTo(a)); + + expect(result, [7, 5, 4, 3, 1]); + }); + + test('should work with string sorting', () { + final names = ['Alice', 'Charlie', 'David']; + + final result = + names.sortedInsert('Bob', compare: (a, b) => a.compareTo(b)); + + expect(result, ['Alice', 'Bob', 'Charlie', 'David']); + }); + + test('should work with complex objects', () { + final users = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '3', name: 'Charlie'), + const _TestUser(id: '5', name: 'Eve'), + ]; + + final result = users.sortedInsert( + const _TestUser(id: '2', name: 'Bob'), + compare: (a, b) => a.name.compareTo(b.name), + ); + + expect(result.length, 4); + expect(result.map((u) => u.name), ['Alice', 'Bob', 'Charlie', 'Eve']); + }); + + test('should handle duplicate values', () { + final numbers = [1, 3, 5, 7]; + + final result = + numbers.sortedInsert(3, compare: (a, b) => a.compareTo(b)); + + expect(result, [1, 3, 3, 5, 7]); + }); + }); + + group('sortedUpsert', () { + test('should replace existing element and maintain sorted order', () { + final users = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 3, points: 80), + const _TestScore(userId: 5, points: 60), + ]; + + final result = users.sortedUpsert( + const _TestScore(userId: 1, points: 150), + key: (score) => score.userId, + compare: (a, b) => + b.points.compareTo(a.points), // Descending by points + ); + + expect(result.length, 3); + expect(result.map((s) => s.points), [150, 80, 60]); + expect(result.map((s) => s.userId), [1, 3, 5]); + }); + + test('should insert new element at correct sorted position', () { + final users = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 3, points: 60), + ]; + + final result = users.sortedUpsert( + const _TestScore(userId: 2, points: 80), + key: (score) => score.userId, + compare: (a, b) => + b.points.compareTo(a.points), // Descending by points + ); + + expect(result.length, 3); + expect(result.map((s) => s.points), [100, 80, 60]); + expect(result.map((s) => s.userId), [1, 2, 3]); + }); + + test('should work with empty list', () { + final scores = <_TestScore>[]; + + final result = scores.sortedUpsert( + const _TestScore(userId: 1, points: 100), + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 1); + expect(result.first.points, 100); + }); + + test('should handle single element replacement', () { + final scores = [const _TestScore(userId: 1, points: 100)]; + + final result = scores.sortedUpsert( + const _TestScore(userId: 1, points: 150), + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 1); + expect(result.first.points, 150); + }); + + test('should handle single element addition', () { + final scores = [const _TestScore(userId: 1, points: 100)]; + + final result = scores.sortedUpsert( + const _TestScore(userId: 2, points: 150), + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 2); + expect(result.map((s) => s.points), [150, 100]); + expect(result.map((s) => s.userId), [2, 1]); + }); + + test('should maintain sort order after replacement', () { + final activities = [ + const _TestActivity(id: 'act1', authorId: 'user1', content: 'A'), + const _TestActivity(id: 'act2', authorId: 'user2', content: 'B'), + const _TestActivity(id: 'act3', authorId: 'user3', content: 'C'), + ]; + + final result = activities.sortedUpsert( + const _TestActivity(id: 'act2', authorId: 'user2', content: 'Z'), + key: (activity) => activity.id, + compare: (a, b) => a.content.compareTo(b.content), + ); + + expect(result.length, 3); + expect(result.map((a) => a.content), ['A', 'C', 'Z']); + expect(result.map((a) => a.id), ['act1', 'act3', 'act2']); + }); + }); + + group('merge', () { + test('should merge two lists with default update behavior', () { + final oldScores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 80), + ]; + final newScores = [ + const _TestScore(userId: 1, points: 50), // Update existing + const _TestScore(userId: 3, points: 120), // New user + ]; + + final result = oldScores.merge( + newScores, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 3); + expect(result.map((s) => s.points), [120, 80, 50]); + expect(result.map((s) => s.userId), [3, 2, 1]); + }); + + test('should merge with custom update function', () { + final oldScores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 80), + ]; + final newScores = [ + const _TestScore(userId: 1, points: 50), + const _TestScore(userId: 3, points: 120), + ]; + + final result = oldScores.merge( + newScores, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + update: (original, updated) => _TestScore( + userId: original.userId, + points: original.points + updated.points, + ), + ); + + expect(result.length, 3); + expect(result.map((s) => s.points), [150, 120, 80]); + expect(result.map((s) => s.userId), [1, 3, 2]); + }); + + test('should return unsorted result when compare is null', () { + final oldScores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 80), + ]; + final newScores = [ + const _TestScore(userId: 3, points: 120), + const _TestScore(userId: 1, points: 50), + ]; + + final result = oldScores.merge( + newScores, + key: (score) => score.userId, + compare: null, + ); + + expect(result.length, 3); + // Order should match original insertion order in map + final userIds = result.map((s) => s.userId).toList(); + expect(userIds.contains(1), true); + expect(userIds.contains(2), true); + expect(userIds.contains(3), true); + }); + + test('should handle empty other list', () { + final scores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 80), + ]; + final empty = <_TestScore>[]; + + final result = scores.merge( + empty, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result, scores); + }); + + test('should handle empty source list', () { + final empty = <_TestScore>[]; + final scores = [ + const _TestScore(userId: 1, points: 100), + const _TestScore(userId: 2, points: 80), + ]; + + final result = empty.merge( + scores, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 2); + expect(result.map((s) => s.points), [100, 80]); + }); + + test('should handle both lists empty', () { + final empty1 = <_TestScore>[]; + final empty2 = <_TestScore>[]; + + final result = empty1.merge( + empty2, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result, isEmpty); + }); + + test('should handle complex merge scenario', () { + final oldActivities = [ + const _TestActivity(id: 'act1', authorId: 'user1', content: 'Hello'), + const _TestActivity(id: 'act2', authorId: 'user2', content: 'World'), + const _TestActivity( + id: 'act4', + authorId: 'user4', + content: 'Existing', + ), + ]; + final newActivities = [ + const _TestActivity( + id: 'act1', + authorId: 'user1', + content: 'Hello Updated', + ), + const _TestActivity( + id: 'act3', + authorId: 'user3', + content: 'New Activity', + ), + const _TestActivity( + id: 'act5', + authorId: 'user5', + content: 'Another New', + ), + ]; + + final result = oldActivities.merge( + newActivities, + key: (activity) => activity.id, + compare: (a, b) => a.id.compareTo(b.id), + ); + + expect(result.length, 5); + expect( + result.map((a) => a.id), + ['act1', 'act2', 'act3', 'act4', 'act5'], + ); + expect(result.first.content, 'Hello Updated'); // Updated content + }); + }); + + group('removeNested', () { + test('should remove element from root level', () { + final comments = [ + const _TestComment(id: '1', text: 'Great post!', replies: []), + const _TestComment(id: '2', text: 'Thanks!', replies: []), + ]; + + final result = comments.removeNested( + (comment) => comment.id == '1', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) { + return parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ); + }, + ); + + expect(result.length, 1); + expect(result.first.id, '2'); + // Original list should be unchanged + expect(comments.length, 2); + }); + + test('should remove element from nested level', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Great post!', + replies: [ + _TestComment(id: '2', text: 'Thanks!', replies: []), + _TestComment(id: '3', text: 'Spam message', replies: []), + ], + ), + ]; + + final result = comments.removeNested( + (comment) => comment.text == 'Spam message', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect(result.length, 1); + expect(result.first.replies.length, 1); + expect(result.first.replies.first.text, 'Thanks!'); + expect(result.first.modifiedAt, isNotNull); // Parent was updated + }); + + test('should remove element from deeply nested structure', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Root comment', + replies: [ + _TestComment( + id: '2', + text: 'Level 1 reply', + replies: [ + _TestComment( + id: '3', + text: 'Level 2 reply', + replies: [ + _TestComment(id: '4', text: 'Deep spam', replies: []), + ], + ), + ], + ), + ], + ), + ]; + + final result = comments.removeNested( + (comment) => comment.text == 'Deep spam', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect(result.length, 1); + expect(result.first.replies.first.replies.first.replies, isEmpty); + expect(result.first.modifiedAt, isNotNull); // Root was updated + }); + + test('should handle empty list', () { + final comments = <_TestComment>[]; + + final result = comments.removeNested( + (comment) => comment.id == '1', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect(result, isEmpty); + }); + + test('should return same list if no element matches', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Great post!', + replies: [ + _TestComment(id: '2', text: 'Thanks!', replies: []), + ], + ), + ]; + + final result = comments.removeNested( + (comment) => comment.id == 'nonexistent', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect( + identical(result, comments), + true, + ); // Should return same instance + }); + + test('should handle multiple matching elements (removes first found)', + () { + final comments = [ + const _TestComment(id: '1', text: 'duplicate', replies: []), + const _TestComment(id: '2', text: 'duplicate', replies: []), + ]; + + final result = comments.removeNested( + (comment) => comment.text == 'duplicate', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect(result.length, 1); + expect(result.first.id, '2'); // First match ('1') was removed + }); + + test('should handle complex nested removal scenario', () { + final threadStructure = [ + const _TestComment( + id: '1', + text: 'Main discussion', + replies: [ + _TestComment( + id: '2', + text: 'Good point', + replies: [ + _TestComment(id: '3', text: 'I agree', replies: []), + ], + ), + _TestComment( + id: '4', + text: 'Controversial opinion', + replies: [ + _TestComment(id: '5', text: 'Spam reply', replies: []), + _TestComment(id: '6', text: 'Valid response', replies: []), + ], + ), + ], + ), + ]; + + final result = threadStructure.removeNested( + (comment) => comment.text == 'Spam reply', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + + expect(result.length, 1); + final mainComment = result.first; + expect(mainComment.replies.length, 2); + + final controversialComment = mainComment.replies + .firstWhere((c) => c.text == 'Controversial opinion'); + expect(controversialComment.replies.length, 1); + expect(controversialComment.replies.first.text, 'Valid response'); + expect(mainComment.modifiedAt, isNotNull); // Root was updated + }); + }); + + group('updateNested', () { + test('should update element at root level', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Original text', + upvotes: 5, + replies: [], + ), + const _TestComment( + id: '2', + text: 'Another comment', + upvotes: 3, + replies: [], + ), + ]; + + const updatedComment = _TestComment( + id: '1', + text: 'Updated text', + upvotes: 6, + replies: [], + ); + + final result = comments.updateNested( + updatedComment, + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + + expect(result.length, 2); + expect(result.first.text, 'Updated text'); + expect(result.first.upvotes, 6); + expect(result.first.modifiedAt, isNotNull); + expect(result.last.text, 'Another comment'); + }); + + test('should update element in nested structure', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Root comment', + upvotes: 10, + replies: [ + _TestComment( + id: '2', + text: 'Nested comment', + upvotes: 5, + replies: [], + ), + _TestComment( + id: '3', + text: 'Another nested', + upvotes: 3, + replies: [], + ), + ], + ), + ]; + + const updatedComment = _TestComment( + id: '2', + text: 'Updated nested comment', + upvotes: 8, + replies: [], + ); + + final result = comments.updateNested( + updatedComment, + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + + expect(result.length, 1); + expect(result.first.replies.length, 2); + expect(result.first.replies.first.text, 'Updated nested comment'); + expect(result.first.replies.first.upvotes, 8); + expect(result.first.replies.first.modifiedAt, isNotNull); + }); + + test('should update and sort when compare function is provided', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Root', + upvotes: 10, + replies: [ + _TestComment(id: '2', text: 'Low score', upvotes: 2, replies: []), + _TestComment( + id: '3', + text: 'High score', + upvotes: 8, + replies: [], + ), + ], + ), + ]; + + const updatedComment = _TestComment( + id: '2', + text: 'Now high score', + upvotes: 12, + replies: [], + ); + + final result = comments.updateNested( + updatedComment, + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + compare: (a, b) => + b.upvotes.compareTo(a.upvotes), // Sort by upvotes desc + ); + + expect(result.length, 1); + expect(result.first.replies.length, 2); + // Updated comment should now be first due to higher upvotes + expect(result.first.replies.first.text, 'Now high score'); + expect(result.first.replies.first.upvotes, 12); + expect(result.first.replies.last.text, 'High score'); + expect(result.first.replies.last.upvotes, 8); + }); + + test('should handle deeply nested updates', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Level 0', + replies: [ + _TestComment( + id: '2', + text: 'Level 1', + replies: [ + _TestComment( + id: '3', + text: 'Level 2', + upvotes: 5, + replies: [ + _TestComment( + id: '4', + text: 'Level 3', + upvotes: 1, + replies: [], + ), + ], + ), + ], + ), + ], + ), + ]; + + const updatedComment = _TestComment( + id: '4', + text: 'Updated Level 3', + upvotes: 10, + replies: [], + ); + + final result = comments.updateNested( + updatedComment, + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + + expect(result.length, 1); + final deepComment = + result.first.replies.first.replies.first.replies.first; + expect(deepComment.text, 'Updated Level 3'); + expect(deepComment.upvotes, 10); + expect(deepComment.modifiedAt, isNotNull); + }); + + test('should handle empty list', () { + final comments = <_TestComment>[]; + + final result = comments.updateNested( + const _TestComment(id: '1', text: 'New comment', replies: []), + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + + expect(result, isEmpty); + }); + + test('should return same list if element not found', () { + final comments = [ + const _TestComment(id: '1', text: 'Existing comment', replies: []), + ]; + + final result = comments.updateNested( + const _TestComment(id: 'nonexistent', text: 'Not found', replies: []), + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + + expect( + identical(result, comments), + true, + ); // Should return same instance + }); + + test('should handle complex thread update scenario', () { + final forumThread = [ + const _TestComment( + id: 'post1', + text: 'What are your thoughts on the new Flutter update?', + upvotes: 45, + replies: [ + _TestComment( + id: 'reply1', + text: 'Love the performance improvements!', + upvotes: 12, + replies: [ + _TestComment( + id: 'subreply1', + text: 'Agreed, much faster now', + upvotes: 8, + replies: [], + ), + ], + ), + _TestComment( + id: 'reply2', + text: 'Still has some bugs', + upvotes: 3, + replies: [], + ), + ], + ), + ]; + + // User upvotes a deeply nested comment + const upvotedComment = _TestComment( + id: 'subreply1', + text: 'Agreed, much faster now', + upvotes: 9, // Incremented + replies: [], + ); + + final result = forumThread.updateNested( + upvotedComment, + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort by upvotes + ); + + expect(result.length, 1); + final mainPost = result.first; + expect(mainPost.replies.length, 2); + + final topReply = mainPost.replies.first; + expect(topReply.id, 'reply1'); + expect(topReply.replies.first.upvotes, 9); // Upvote count updated + expect(topReply.replies.first.modifiedAt, isNotNull); + }); + }); + }); + + group('Edge Cases and Performance', () { + group('Null Safety and Type Safety', () { + test('should handle null values in custom key functions', () { + final items = [ + const _TestItemWithNullable(id: '1', value: 'A'), + const _TestItemWithNullable(id: '2', value: null), + const _TestItemWithNullable(id: '3', value: 'C'), + ]; + + final result = items.upsert( + const _TestItemWithNullable(id: '2', value: 'B Updated'), + key: (item) => item.id, + ); + + expect(result.length, 3); + expect(result[1].value, 'B Updated'); + }); + }); + + group('Performance and Memory', () { + test('should efficiently handle large lists in upsert', () { + final largeList = List.generate( + 1000, + (i) => _TestUser(id: i.toString(), name: 'User $i'), + ); + + final stopwatch = Stopwatch()..start(); + final result = largeList.upsert( + const _TestUser(id: '500', name: 'Updated User 500'), + key: (user) => user.id, + ); + stopwatch.stop(); + + expect(result.length, 1000); + expect(result[500].name, 'Updated User 500'); + expect(stopwatch.elapsedMilliseconds, lessThan(50)); // Should be fast + }); + + test('should efficiently handle large sorted inserts', () { + final largeList = List.generate( + 1000, + (i) => _TestScore(userId: i, points: i * 10), + ); + + final stopwatch = Stopwatch()..start(); + final result = largeList.sortedInsert( + const _TestScore(userId: 1001, points: 5555), + compare: (a, b) => a.points.compareTo(b.points), + ); + stopwatch.stop(); + + expect(result.length, 1001); + expect( + stopwatch.elapsedMilliseconds, + lessThan(10), + ); // Binary search is fast + }); + + test('should handle memory efficiently with copy-on-write', () { + final originalList = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '2', name: 'Bob'), + ]; + + final result1 = originalList.upsert( + const _TestUser(id: '3', name: 'Charlie'), + key: (user) => user.id, + ); + + final result2 = originalList.upsert( + const _TestUser(id: '4', name: 'David'), + key: (user) => user.id, + ); + + // Original list should be unchanged + expect(originalList.length, 2); + expect(result1.length, 3); + expect(result2.length, 3); + expect(result1.last.name, 'Charlie'); + expect(result2.last.name, 'David'); + }); + + test('should handle deeply nested structures efficiently', () { + // Create a deep nested structure (5 levels deep) + _TestComment createDeepStructure(int depth, String prefix) { + if (depth == 0) { + return _TestComment(id: '${prefix}_$depth', text: 'Leaf $prefix'); + } + return _TestComment( + id: '${prefix}_$depth', + text: 'Level $depth', + replies: [createDeepStructure(depth - 1, prefix)], + ); + } + + final deepComments = [ + createDeepStructure(5, 'thread1'), + createDeepStructure(5, 'thread2'), + ]; + + final stopwatch = Stopwatch()..start(); + final result = deepComments.updateNested( + const _TestComment(id: 'thread1_0', text: 'Updated leaf'), + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + stopwatch.stop(); + + expect(result.length, 2); + expect( + stopwatch.elapsedMilliseconds, + lessThan(20), + ); // Should handle deep nesting + }); + }); + + group('Boundary Conditions', () { + test('should handle very large merge operations', () { + final list1 = List.generate( + 500, + (i) => _TestScore(userId: i, points: i * 10), + ); + final list2 = List.generate( + 500, + (i) => _TestScore(userId: i + 250, points: (i + 250) * 15), + ); + + final result = list1.merge( + list2, + key: (score) => score.userId, + compare: (a, b) => b.points.compareTo(a.points), + ); + + expect(result.length, 750); // 500 original + 250 new (250 overlaps) + expect( + result.first.points, + greaterThan(result.last.points), + ); // Sorted correctly + }); + + test('should handle identical references correctly', () { + const user = _TestUser(id: '1', name: 'Alice'); + final list = [user]; + + final result = list.upsert(user, key: (u) => u.id); + + expect(result.length, 1); + expect(identical(result.first, user), true); // Should be same instance + }); + + test('should handle extreme nesting in removeNested', () { + // Create a linear chain of 100 nested comments + _TestComment createChain(int depth) { + if (depth == 0) { + return const _TestComment(id: 'target', text: 'Target comment'); + } + return _TestComment( + id: 'level_$depth', + text: 'Level $depth', + replies: [createChain(depth - 1)], + ); + } + + final deepStructure = [createChain(100)]; + + final stopwatch = Stopwatch()..start(); + final result = deepStructure.removeNested( + (comment) => comment.id == 'target', + children: (comment) => comment.replies, + updateChildren: (parent, newReplies) => parent.copyWith( + replies: newReplies, + modifiedAt: DateTime.now(), + ), + ); + stopwatch.stop(); + + expect(result.length, 1); + expect( + stopwatch.elapsedMilliseconds, + lessThan(50), + ); // Should handle deep nesting + }); + }); + + group('Concurrent Usage Simulation', () { + test('should maintain immutability under simulated concurrent access', + () { + final baseList = [ + const _TestUser(id: '1', name: 'Alice'), + const _TestUser(id: '2', name: 'Bob'), + ]; + + // Simulate multiple "concurrent" operations + final results = List.generate(10, (i) { + return baseList.upsert( + _TestUser(id: '${i + 3}', name: 'User ${i + 3}'), + key: (user) => user.id, + ); + }); + + // All operations should have same base + 1 new user + for (final result in results) { + expect(result.length, 3); + expect(result.first.name, 'Alice'); // Base data unchanged + expect(result[1].name, 'Bob'); // Base data unchanged + } + + // Base list should remain unchanged + expect(baseList.length, 2); + }); + + test('should handle rapid state changes in nested structures', () { + final comments = [ + const _TestComment( + id: '1', + text: 'Main thread', + replies: [ + _TestComment(id: '2', text: 'Reply 1', upvotes: 5), + _TestComment(id: '3', text: 'Reply 2', upvotes: 3), + ], + ), + ]; + + // Simulate rapid upvote changes + var currentState = comments; + for (var i = 0; i < 10; i++) { + currentState = currentState.updateNested( + _TestComment(id: '2', text: 'Reply 1', upvotes: 5 + i), + key: (comment) => comment.id, + children: (comment) => comment.replies, + update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + updateChildren: (parent, newReplies) => + parent.copyWith(replies: newReplies), + ); + } + + expect(currentState.first.replies.first.upvotes, 14); // 5 + 9 + expect(comments.first.replies.first.upvotes, 5); // Original unchanged + }); + }); + + group('Error Handling and Robustness', () { + test('should handle empty key results gracefully', () { + final items = [ + const _TestItemWithNullable(id: '', value: 'Empty ID'), + const _TestItemWithNullable(id: '1', value: 'Normal'), + ]; + + final result = items.upsert( + const _TestItemWithNullable(id: '', value: 'Updated Empty'), + key: (item) => item.id, + ); + + expect(result.length, 2); + expect(result.first.value, 'Updated Empty'); + }); + + test('should handle complex key extraction', () { + final activities = [ + const _TestActivity(id: 'act1', authorId: 'user1', content: 'Hello'), + const _TestActivity(id: 'act2', authorId: 'user1', content: 'World'), + ]; + + // Use composite key (authorId + first word of content) + final result = activities.upsert( + const _TestActivity( + id: 'act3', + authorId: 'user1', + content: 'Hello Updated', + ), + key: (activity) => + '${activity.authorId}_${activity.content.split(' ').first}', + ); + + expect(result.length, 2); // Should replace 'Hello' activity + expect(result.first.content, 'Hello Updated'); + expect(result.last.content, 'World'); + }); + }); + }); + + group('Production Feed Scenarios', () { + group('Activity Feed Operations', () { + test('should handle real-time activity updates in sorted feed', () { + // Simulate a typical activity feed sorted by creation time (newest first) + final activities = [ + _TestFeedActivity( + id: 'act_3', + userId: 'user1', + content: 'Just posted a photo', + timestamp: DateTime.now().subtract(const Duration(minutes: 10)), + likes: 5, + ), + _TestFeedActivity( + id: 'act_2', + userId: 'user2', + content: 'Having a great day!', + timestamp: DateTime.now().subtract(const Duration(hours: 1)), + likes: 12, + ), + _TestFeedActivity( + id: 'act_1', + userId: 'user3', + content: 'Good morning everyone', + timestamp: DateTime.now().subtract(const Duration(hours: 2)), + likes: 8, + ), + ]; + + // New activity comes in + final newActivity = _TestFeedActivity( + id: 'act_4', + userId: 'user1', + content: 'Live streaming now!', + timestamp: DateTime.now(), + likes: 0, + ); + + final result = activities.sortedInsert( + newActivity, + compare: (a, b) => b.timestamp.compareTo(a.timestamp), // Newest first + ); + + expect(result.length, 4); + expect(result.first.id, 'act_4'); // New activity should be first + expect(result.first.content, 'Live streaming now!'); + }); + }); + }); +} + +// region Test Models + +/// Test model representing a user with ID and name. +class _TestUser extends Equatable { + const _TestUser({required this.id, required this.name}); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + +/// Test model representing a score with user ID and points. +class _TestScore extends Equatable { + const _TestScore({required this.userId, required this.points}); + + final int userId; + final int points; + + @override + List get props => [userId, points]; +} + +/// Test model representing an activity with various properties. +class _TestActivity extends Equatable { + const _TestActivity({ + required this.id, + required this.authorId, + required this.content, + }); + + final String id; + final String authorId; + final String content; + + @override + List get props => [id, authorId, content]; +} + +/// Test model representing a comment with nested replies structure. +class _TestComment extends Equatable { + const _TestComment({ + required this.id, + required this.text, + this.upvotes = 0, + this.replies = const [], + this.modifiedAt, + }); + + final String id; + final String text; + final int upvotes; + final List<_TestComment> replies; + final DateTime? modifiedAt; + + _TestComment copyWith({ + String? id, + String? text, + int? upvotes, + List<_TestComment>? replies, + DateTime? modifiedAt, + }) { + return _TestComment( + id: id ?? this.id, + text: text ?? this.text, + upvotes: upvotes ?? this.upvotes, + replies: replies ?? this.replies, + modifiedAt: modifiedAt ?? this.modifiedAt, + ); + } + + @override + List get props => [id, text, upvotes, replies, modifiedAt]; +} + +/// Test model representing an item with nullable value. +class _TestItemWithNullable extends Equatable { + const _TestItemWithNullable({required this.id, this.value}); + + final String id; + final String? value; + + @override + List get props => [id, value]; +} + +/// Test model representing a feed activity with engagement metrics. +class _TestFeedActivity extends Equatable { + const _TestFeedActivity({ + required this.id, + required this.userId, + required this.content, + required this.timestamp, + this.likes = 0, + }); + + final String id; + final String userId; + final String content; + final DateTime timestamp; + final int likes; + + @override + List get props => [id, userId, content, timestamp, likes]; +} + +// endregion diff --git a/packages/stream_core/test/query/sort_test.dart b/packages/stream_core/test/query/sort_test.dart new file mode 100644 index 0000000..2aeec57 --- /dev/null +++ b/packages/stream_core/test/query/sort_test.dart @@ -0,0 +1,480 @@ +import 'package:stream_core/src/query/sort.dart'; +import 'package:test/test.dart'; + +// Test models +class Person { + const Person({ + required this.name, + required this.age, + this.birthDate, + this.isActive = true, + this.score, + this.custom, + }); + + final String name; + final int age; + final DateTime? birthDate; + final bool isActive; + final double? score; + final Map? custom; // For testing non-comparable types +} + +class Product { + const Product({ + required this.title, + required this.price, + }); + + final String title; + final double price; +} + +void main() { + group('SortField', () { + test('should create field for string values', () { + final field = SortField('name', (p) => p.name); + + expect(field.remote, equals('name')); + expect(field.comparator, isNotNull); + }); + + test('should create field for int values', () { + final field = SortField('age', (p) => p.age); + + expect(field.remote, equals('age')); + }); + + test('should create field for nullable DateTime values', () { + final field = SortField('birthDate', (p) => p.birthDate); + + expect(field.remote, equals('birthDate')); + }); + + test('should create field for bool values', () { + final field = SortField('isActive', (p) => p.isActive); + + expect(field.remote, equals('isActive')); + }); + + test('should create field for double values', () { + final field = SortField('score', (p) => p.score); + + expect(field.remote, equals('score')); + }); + + test('should create field for non-comparable types', () { + final field = SortField( + 'metadata', + (p) => p.custom, + ); + + expect(field.remote, equals('metadata')); + }); + }); + + group('Sort.forward', () { + test('should create forward sort with default null ordering', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.asc(field); + + expect(sort.direction, equals(SortDirection.asc)); + expect(sort.nullOrdering, equals(NullOrdering.nullsLast)); + expect(sort.field, equals(field)); + }); + + test('should create forward sort with custom null ordering', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.asc(field, nullOrdering: NullOrdering.nullsFirst); + + expect(sort.nullOrdering, equals(NullOrdering.nullsFirst)); + }); + + test('should sort strings in ascending order', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.asc(field); + + const alice = Person(name: 'Alice', age: 30); + const bob = Person(name: 'Bob', age: 25); + const charlie = Person(name: 'Charlie', age: 35); + + expect(sort.compare(alice, bob), lessThan(0)); // Alice < Bob + expect(sort.compare(bob, charlie), lessThan(0)); // Bob < Charlie + expect(sort.compare(alice, alice), equals(0)); // Alice == Alice + }); + + test('should sort numbers in ascending order', () { + final field = SortField('age', (p) => p.age); + final sort = Sort.asc(field); + + const young = Person(name: 'Young', age: 20); + const middle = Person(name: 'Middle', age: 30); + const old = Person(name: 'Old', age: 40); + + expect(sort.compare(young, middle), lessThan(0)); // 20 < 30 + expect(sort.compare(middle, old), lessThan(0)); // 30 < 40 + expect(sort.compare(young, young), equals(0)); // 20 == 20 + }); + + test('should sort DateTime in ascending order', () { + final field = SortField('birthDate', (p) => p.birthDate); + final sort = Sort.asc(field); + + final person1990 = + Person(name: 'Person1990', age: 34, birthDate: DateTime(1990)); + final person2000 = + Person(name: 'Person2000', age: 24, birthDate: DateTime(2000)); + + expect(sort.compare(person1990, person2000), lessThan(0)); // 1990 < 2000 + }); + + test('should sort bool values (false < true)', () { + final field = SortField('isActive', (p) => p.isActive); + final sort = Sort.asc(field); + + const inactive = Person(name: 'Inactive', age: 30, isActive: false); + const active = Person(name: 'Active', age: 25); + + expect(sort.compare(inactive, active), lessThan(0)); // false < true + expect(sort.compare(active, inactive), greaterThan(0)); // true > false + }); + + test('should handle null objects', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.asc(field); + const person = Person(name: 'Alice', age: 30); + + expect(sort.compare(null, null), equals(0)); + expect( + sort.compare(null, person), + greaterThan(0), + ); // null treated as "greater" with nullsLast + expect(sort.compare(person, null), lessThan(0)); + }); + + test('should handle null extracted values with nullsLast', () { + final field = SortField('birthDate', (p) => p.birthDate); + final sort = Sort.asc(field); + + final withDate = + Person(name: 'WithDate', age: 30, birthDate: DateTime(1990)); + const withoutDate = Person(name: 'WithoutDate', age: 25); + + expect( + sort.compare(withDate, withoutDate), + lessThan(0), + ); // non-null < null + expect( + sort.compare(withoutDate, withDate), + greaterThan(0), + ); // null > non-null + }); + + test('should handle null extracted values with nullsFirst', () { + final field = SortField('birthDate', (p) => p.birthDate); + final sort = Sort.asc(field, nullOrdering: NullOrdering.nullsFirst); + + final withDate = + Person(name: 'WithDate', age: 30, birthDate: DateTime(1990)); + const withoutDate = Person(name: 'WithoutDate', age: 25); + + expect( + sort.compare(withoutDate, withDate), + lessThan(0), + ); // null < non-null + expect( + sort.compare(withDate, withoutDate), + greaterThan(0), + ); // non-null > null + }); + }); + + group('Sort.reverse', () { + test('should create reverse sort with default null ordering', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.desc(field); + + expect(sort.direction, equals(SortDirection.desc)); + expect(sort.nullOrdering, equals(NullOrdering.nullsFirst)); + expect(sort.field, equals(field)); + }); + + test('should create reverse sort with custom null ordering', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.desc(field, nullOrdering: NullOrdering.nullsLast); + + expect(sort.nullOrdering, equals(NullOrdering.nullsLast)); + }); + + test('should sort strings in descending order', () { + final field = SortField('name', (p) => p.name); + final sort = Sort.desc(field); + + const alice = Person(name: 'Alice', age: 30); + const bob = Person(name: 'Bob', age: 25); + const charlie = Person(name: 'Charlie', age: 35); + + expect( + sort.compare(alice, bob), + greaterThan(0), + ); // Alice > Bob (reversed) + expect( + sort.compare(bob, charlie), + greaterThan(0), + ); // Bob > Charlie (reversed) + expect(sort.compare(alice, alice), equals(0)); // Alice == Alice + }); + + test('should sort numbers in descending order', () { + final field = SortField('age', (p) => p.age); + final sort = Sort.desc(field); + + const young = Person(name: 'Young', age: 20); + const middle = Person(name: 'Middle', age: 30); + const old = Person(name: 'Old', age: 40); + + expect(sort.compare(young, middle), greaterThan(0)); // 20 > 30 (reversed) + expect(sort.compare(middle, old), greaterThan(0)); // 30 > 40 (reversed) + expect(sort.compare(young, young), equals(0)); // 20 == 20 + }); + }); + + group('Composite Sorting (Multiple Sort Criteria)', () { + test('should sort by multiple criteria using extension', () { + final nameField = SortField('name', (p) => p.name); + final ageField = SortField('age', (p) => p.age); + + final sorts = [ + Sort.asc(nameField), + Sort.asc(ageField), + ]; + + const alice30 = Person(name: 'Alice', age: 30); + const alice25 = Person(name: 'Alice', age: 25); + const bob20 = Person(name: 'Bob', age: 20); + + // Same name, different age - should sort by age + expect( + sorts.compare(alice25, alice30), + lessThan(0), + ); // Alice(25) < Alice(30) + expect( + sorts.compare(alice30, alice25), + greaterThan(0), + ); // Alice(30) > Alice(25) + + // Different names - should sort by name first + expect(sorts.compare(alice30, bob20), lessThan(0)); // Alice < Bob + expect(sorts.compare(bob20, alice25), greaterThan(0)); // Bob > Alice + }); + + test('should handle mixed sort directions', () { + final nameField = SortField('name', (p) => p.name); + final ageField = SortField('age', (p) => p.age); + + final sorts = [ + Sort.asc(nameField), // Name ascending + Sort.desc(ageField), // Age descending + ]; + + const alice25 = Person(name: 'Alice', age: 25); + const alice30 = Person(name: 'Alice', age: 30); + + // Same name, age should be reverse sorted (30 before 25) + expect( + sorts.compare(alice25, alice30), + greaterThan(0), + ); // Alice(25) > Alice(30) when age is reversed + expect( + sorts.compare(alice30, alice25), + lessThan(0), + ); // Alice(30) < Alice(25) when age is reversed + }); + + test('should return 0 when all criteria are equal', () { + final nameField = SortField('name', (p) => p.name); + final ageField = SortField('age', (p) => p.age); + + final sorts = [ + Sort.asc(nameField), + Sort.asc(ageField), + ]; + + const alice1 = Person(name: 'Alice', age: 30); + const alice2 = Person(name: 'Alice', age: 30); + + expect(sorts.compare(alice1, alice2), equals(0)); + }); + }); + + group('Real-world Integration Tests', () { + test('should sort list of people by name', () { + final nameField = SortField('name', (p) => p.name); + final sort = Sort.asc(nameField); + + final people = [ + const Person(name: 'Charlie', age: 35), + const Person(name: 'Alice', age: 30), + const Person(name: 'Bob', age: 25), + ]; + + people.sort(sort.compare); + + expect( + people.map((p) => p.name).toList(), + equals(['Alice', 'Bob', 'Charlie']), + ); + }); + + test('should sort list of people by age (descending)', () { + final ageField = SortField('age', (p) => p.age); + final sort = Sort.desc(ageField); + + final people = [ + const Person(name: 'Alice', age: 30), + const Person(name: 'Charlie', age: 35), + const Person(name: 'Bob', age: 25), + ]; + + people.sort(sort.compare); + + expect(people.map((p) => p.age).toList(), equals([35, 30, 25])); + }); + + test('should sort with composite criteria (name asc, age desc)', () { + final nameField = SortField('name', (p) => p.name); + final ageField = SortField('age', (p) => p.age); + + final sorts = [ + Sort.asc(nameField), + Sort.desc(ageField), + ]; + + final people = [ + const Person(name: 'Alice', age: 25), + const Person(name: 'Bob', age: 30), + const Person(name: 'Alice', age: 35), + const Person(name: 'Bob', age: 20), + ]; + + people.sort(sorts.compare); + + final result = people.map((p) => '${p.name}-${p.age}').toList(); + expect(result, equals(['Alice-35', 'Alice-25', 'Bob-30', 'Bob-20'])); + }); + + test('should handle nullable values correctly', () { + final scoreField = SortField('score', (p) => p.score); + final sort = Sort.asc(scoreField, nullOrdering: NullOrdering.nullsFirst); + + final people = [ + const Person(name: 'Alice', age: 30, score: 85.5), + const Person(name: 'Bob', age: 25), // null score + const Person(name: 'Charlie', age: 35, score: 92), + const Person(name: 'David', age: 28), // null score + ]; + + people.sort(sort.compare); + + final names = people.map((p) => p.name).toList(); + expect( + names, + equals([ + 'Bob', + 'David', + 'Alice', + 'Charlie', + ]), + ); // nulls first, then by score + }); + + test('should work with different object types', () { + final titleField = SortField('title', (p) => p.title); + final sort = Sort.asc(titleField); + + final products = [ + const Product(title: 'Zebra Toy', price: 15.99), + const Product(title: 'Apple Phone', price: 999.99), + const Product(title: 'Book Collection', price: 29.99), + ]; + + products.sort(sort.compare); + + expect( + products.map((p) => p.title).toList(), + equals(['Apple Phone', 'Book Collection', 'Zebra Toy']), + ); + }); + + test('should handle mixed numeric types (int and double)', () { + final priceField = SortField('price', (p) => p.price); + final sort = Sort.asc(priceField); + + final products = [ + const Product(title: 'Expensive', price: 999.99), + const Product(title: 'Cheap', price: 15), // double that equals an int + const Product(title: 'Medium', price: 29.99), + ]; + + products.sort(sort.compare); + + expect( + products.map((p) => p.price).toList(), + equals([15.0, 29.99, 999.99]), + ); + }); + + test('should handle complex multi-field sort with nulls', () { + final nameField = SortField('name', (p) => p.name); + final scoreField = SortField('score', (p) => p.score); + + final sorts = [ + Sort.asc(nameField), + Sort.desc(scoreField, nullOrdering: NullOrdering.nullsLast), + ]; + + final people = [ + const Person(name: 'Alice', age: 30, score: 85.5), + const Person(name: 'Alice', age: 25), // null score + const Person(name: 'Alice', age: 35, score: 92), + const Person(name: 'Bob', age: 28, score: 78), + const Person(name: 'Bob', age: 32), // null score + ]; + + people.sort(sorts.compare); + + final result = people + .map((p) => '${p.name}-${p.score?.toString() ?? 'null'}') + .toList(); + expect( + result, + equals([ + 'Alice-92.0', // Alice, highest score first + 'Alice-85.5', // Alice, next highest score + 'Alice-null', // Alice, null score last + 'Bob-78.0', // Bob, with score + 'Bob-null', // Bob, null score last + ]), + ); + }); + + test('should handle non-comparable types gracefully', () { + final metadataField = SortField( + 'metadata', + (p) => p.custom, + ); + final sort = Sort.asc(metadataField); + + const person1 = Person(name: 'Alice', age: 30, custom: {'key': 'value1'}); + const person2 = Person(name: 'Bob', age: 25, custom: {'key': 'value2'}); + const person3 = Person(name: 'Charlie', age: 35); // null metadata + + // Should not throw, even though Map is not directly comparable + expect(() => sort.compare(person1, person2), returnsNormally); + expect(() => sort.compare(person1, person3), returnsNormally); + + // Non-comparable types should return 0 for comparison + expect(sort.compare(person1, person2), equals(0)); + }); + }); +} diff --git a/packages/stream_core/test/ws/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/connection_recovery_handler_test.dart index af2b594..1fed4b9 100644 --- a/packages/stream_core/test/ws/connection_recovery_handler_test.dart +++ b/packages/stream_core/test/ws/connection_recovery_handler_test.dart @@ -1,118 +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; -} +// // 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; +// } From adf5a46026fe862e9ceca46ba1e3be8c326d95fc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 25 Aug 2025 18:13:33 +0200 Subject: [PATCH 2/8] chore: fix lints --- packages/stream_core/lib/src/platform.dart | 2 +- .../lib/src/ws/client/engine/stream_web_socket_engine.dart | 6 +----- .../ws/client/reconnect/connection_recovery_handler.dart | 2 -- .../lib/src/ws/client/stream_web_socket_client.dart | 6 +----- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/stream_core/lib/src/platform.dart b/packages/stream_core/lib/src/platform.dart index 5d4468c..9b604f0 100644 --- a/packages/stream_core/lib/src/platform.dart +++ b/packages/stream_core/lib/src/platform.dart @@ -1 +1 @@ -export 'platform/current_platform.dart'; \ No newline at end of file +export 'platform/current_platform.dart'; diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index c308fcf..fc201ea 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; -import '../../../logger/logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -34,16 +33,13 @@ WebSocketChannel _createWebSocket(WebSocketOptions options) { class StreamWebSocketEngine implements WebSocketEngine { /// Creates a new instance of [StreamWebSocketEngine]. StreamWebSocketEngine({ - String tag = 'StreamWebSocketEngine', WebSocketProvider? wsProvider, WebSocketEngineListener? listener, required WebSocketMessageCodec messageCodec, - }) : _logger = TaggedLogger(tag), - _wsProvider = wsProvider ?? _createWebSocket, + }) : _wsProvider = wsProvider ?? _createWebSocket, _messageCodec = messageCodec, _listener = listener; - final TaggedLogger _logger; final WebSocketProvider _wsProvider; final WebSocketMessageCodec _messageCodec; diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 3959ac0..04b3f33 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:rxdart/utils.dart'; -import '../../../logger/impl/tagged_logger.dart'; import '../../../utils.dart'; import '../stream_web_socket_client.dart'; import '../web_socket_connection_state.dart'; @@ -75,7 +74,6 @@ class ConnectionRecoveryHandler extends Disposable { final StreamWebSocketClient _client; final RetryStrategy _reconnectStrategy; final List _policies; - late final _logger = taggedLogger(tag: 'ConnectionRecoveryHandler'); late final _subscriptions = CompositeSubscription(); diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index f0ca25b..7721073 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import '../../logger/logger.dart'; import '../../utils.dart'; import '../events/event_emitter.dart'; import '../events/ws_event.dart'; @@ -46,14 +45,13 @@ class StreamWebSocketClient implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ - String tag = 'StreamWebSocketClient', required this.options, this.onConnectionEstablished, WebSocketProvider? wsProvider, this.pingRequestBuilder = _defaultPingRequestBuilder, required WebSocketMessageCodec messageCodec, Iterable>? eventResolvers, - }) : _logger = TaggedLogger(tag) { + }) { _events = MutableEventEmitter(resolvers: eventResolvers); _engine = StreamWebSocketEngine( listener: this, @@ -62,8 +60,6 @@ class StreamWebSocketClient ); } - final TaggedLogger _logger; - /// The WebSocket connection options including URL and configuration. final WebSocketOptions options; From 153b863aa9a3ea547a3d579a0cfadd2d4ae5bd90 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 25 Aug 2025 18:15:40 +0200 Subject: [PATCH 3/8] chore: fix lints --- .../client/reconnect/automatic_reconnection_policy.dart | 6 ++++-- .../lib/src/ws/client/web_socket_connection_state.dart | 3 ++- .../lib/src/ws/client/web_socket_health_monitor.dart | 8 ++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart index 1ae4b85..56f9583 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/automatic_reconnection_policy.dart @@ -12,7 +12,8 @@ abstract interface class AutomaticReconnectionPolicy { /// A reconnection policy that checks if automatic reconnection is enabled /// based on the current state of the WebSocket connection. -class WebSocketAutomaticReconnectionPolicy implements AutomaticReconnectionPolicy { +class WebSocketAutomaticReconnectionPolicy + implements AutomaticReconnectionPolicy { /// Creates a [WebSocketAutomaticReconnectionPolicy]. WebSocketAutomaticReconnectionPolicy({required this.connectionState}); @@ -29,7 +30,8 @@ class WebSocketAutomaticReconnectionPolicy implements AutomaticReconnectionPolic /// A reconnection policy that checks for internet connectivity before allowing /// reconnection. This prevents unnecessary reconnection attempts when there's no /// network available. -class InternetAvailabilityReconnectionPolicy implements AutomaticReconnectionPolicy { +class InternetAvailabilityReconnectionPolicy + implements AutomaticReconnectionPolicy { /// Creates an [InternetAvailabilityReconnectionPolicy]. InternetAvailabilityReconnectionPolicy({required this.networkState}); 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 0ea03e4..4eff935 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 @@ -16,7 +16,8 @@ typedef ConnectionStateEmitter = StateEmitter; /// /// Extends [ConnectionStateEmitter] with the ability to update the current state. /// Used internally by WebSocket client implementations to manage state transitions. -typedef MutableConnectionStateEmitter = MutableStateEmitter; +typedef MutableConnectionStateEmitter + = MutableStateEmitter; /// Represents the current state of a WebSocket connection. /// diff --git a/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart index ea74467..940eb7b 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart @@ -27,12 +27,12 @@ abstract interface class WebSocketHealthListener { /// /// Manages the health checking mechanism for WebSocket connections by automatically /// sending ping requests and monitoring for pong responses to detect unhealthy connections. -/// +/// /// The monitor integrates with [WebSocketHealthListener] to provide automatic connection /// health detection and recovery triggers. /// /// ## Health Check Process -/// +/// /// 1. **Automatic Start**: Monitoring begins when connection state becomes connected /// 2. **Ping Scheduling**: Sends periodic ping requests at the configured interval /// 3. **Pong Monitoring**: Starts timeout timer after each ping request @@ -48,10 +48,10 @@ class WebSocketHealthMonitor { /// The interval between ping requests for health checking. final Duration pingInterval; - + /// The maximum time to wait for a pong response before considering the connection unhealthy. final Duration timeoutThreshold; - + final WebSocketHealthListener _listener; Timer? _pingTimer; From 0de8610e3a9899cdda7a95eff9434776813cae4c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 25 Aug 2025 18:44:03 +0200 Subject: [PATCH 4/8] refactor: rename imageUrl to image in user model --- packages/stream_core/lib/src/user/user.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/user/user.dart b/packages/stream_core/lib/src/user/user.dart index 9d022e1..9b0fcd5 100644 --- a/packages/stream_core/lib/src/user/user.dart +++ b/packages/stream_core/lib/src/user/user.dart @@ -12,10 +12,10 @@ class User extends Equatable { const User({ required this.id, String? name, - this.imageUrl, + this.image, this.role = 'user', this.type = UserType.regular, - Map? custom, + Map? custom, }) : originalName = name, custom = custom ?? const {}; @@ -33,7 +33,7 @@ class User extends Equatable { final String id; /// The user's image URL. - final String? imageUrl; + final String? image; /// The user's role. final String role; @@ -42,7 +42,7 @@ class User extends Equatable { final UserType type; /// The user's custom data. - final Map custom; + final Map custom; /// User's name that was provided when the object was created. It will be used when communicating /// with the API and in cases where it doesn't make sense to override `null` values with the @@ -56,7 +56,7 @@ class User extends Equatable { @override List get props => [ id, - imageUrl, + image, role, type, originalName, From ef8bce60a454bc95502720b8e8c60c9bcb75b529 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 26 Aug 2025 13:35:52 +0200 Subject: [PATCH 5/8] refactor: simplify updateNested method by using a test function instead of a key function --- .../lib/src/utils/list_extensions.dart | 58 +++++++------------ .../test/query/list_extensions_test.dart | 41 +++++-------- 2 files changed, 37 insertions(+), 62 deletions(-) diff --git a/packages/stream_core/lib/src/utils/list_extensions.dart b/packages/stream_core/lib/src/utils/list_extensions.dart index a639a2c..8f8e5e4 100644 --- a/packages/stream_core/lib/src/utils/list_extensions.dart +++ b/packages/stream_core/lib/src/utils/list_extensions.dart @@ -178,11 +178,9 @@ extension SortedListExtensions on List { // and re-sort the list if necessary. final updatedList = [...this]; - updatedList.removeAt(index); - updatedList.sortedInsert(element, compare: compare); - return updatedList; + return updatedList.sortedInsert(element, compare: compare); } /// Merges this list with another list, handling duplicates based on a key. @@ -333,14 +331,14 @@ extension SortedListExtensions on List { /// Recursively updates elements in a nested tree structure. /// - /// Searches for an element with a matching key at any level of nesting. - /// When found, the element is updated and parent elements are rebuilt - /// through the provided callback functions. Uses copy-on-write to avoid - /// unnecessary object creation. Time complexity: O(n * d) where n is + /// Searches for elements matching the test condition at any level of + /// nesting. When an element is found, it is updated and parent elements are + /// rebuilt through the provided callback functions. Uses copy-on-write to + /// avoid unnecessary object creation. Time complexity: O(n * d) where n is /// total number of nodes and d is average depth. /// /// ```dart - /// final post = [ + /// final comments = [ /// Comment( /// id: '1', /// text: 'What do you think about the new Flutter release?', @@ -373,49 +371,36 @@ extension SortedListExtensions on List { /// ), /// ]; /// - /// // User upvotes a deeply nested comment - /// final upvotedComment = Comment( - /// id: '3', - /// text: 'Agreed, much faster now', - /// author: 'senior_dev', - /// upvotes: 9, // Incremented - /// replies: [], - /// ); - /// final updated = post.updateNested( - /// upvotedComment, - /// key: (comment) => comment.id, + /// // Update comment by ID + /// final updated = comments.updateNested( + /// (comment) => comment.id == '3', /// children: (comment) => comment.replies, - /// update: (comment) => comment, // Use the updated comment as-is + /// update: (comment) => comment.copyWith(upvotes: comment.upvotes + 1), /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), /// ); - /// // Result: Deeply nested comment upvote count updated /// - /// // Edit comment text - /// final editedComment = Comment(id: '4', text: 'Actually, bugs are fixed now', author: 'skeptic_user', upvotes: 3); - /// final withEdit = post.updateNested( - /// editedComment, - /// key: (comment) => comment.id, + /// // Update comments by author with complex condition + /// final moderated = comments.updateNested( + /// (comment) => comment.author == 'skeptic_user' && comment.upvotes < 5, /// children: (comment) => comment.replies, - /// update: (comment) => comment.copyWith(editedAt: DateTime.now()), // Mark as edited + /// update: (comment) => comment.copyWith(text: '[Comment moderated]'), /// updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), - /// compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort replies by upvotes + /// compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort by upvotes /// ); - /// // Result: Comment text updated, marked as edited, replies sorted by upvotes /// ``` - List updateNested( - T element, { - required K Function(T item) key, + List updateNested( + bool Function(T element) test, { required List Function(T) children, - required T Function(T) update, + required T Function(T element) update, required T Function(T, List) updateChildren, Comparator? compare, }) { if (isEmpty) return this; - final index = indexWhere((e) => key(e) == key(element)); + final index = indexWhere(test); // If the element is found at the root level, update and sort the list if (index != -1) { - final updatedElement = update(element); + final updatedElement = update(this[index]); final updated = [...this].apply((it) => it[index] = updatedElement); return compare?.let(updated.sorted) ?? updated; } @@ -427,8 +412,7 @@ extension SortedListExtensions on List { if (kids.isEmpty) continue; final newKids = kids.updateNested( - element, - key: key, + test, children: children, update: update, updateChildren: updateChildren, diff --git a/packages/stream_core/test/query/list_extensions_test.dart b/packages/stream_core/test/query/list_extensions_test.dart index 2a4248d..4f11f18 100644 --- a/packages/stream_core/test/query/list_extensions_test.dart +++ b/packages/stream_core/test/query/list_extensions_test.dart @@ -711,10 +711,9 @@ void main() { ); final result = comments.updateNested( - updatedComment, - key: (comment) => comment.id, + (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -757,10 +756,9 @@ void main() { ); final result = comments.updateNested( - updatedComment, - key: (comment) => comment.id, + (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -798,10 +796,9 @@ void main() { ); final result = comments.updateNested( - updatedComment, - key: (comment) => comment.id, + (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), compare: (a, b) => @@ -854,10 +851,9 @@ void main() { ); final result = comments.updateNested( - updatedComment, - key: (comment) => comment.id, + (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -874,8 +870,7 @@ void main() { final comments = <_TestComment>[]; final result = comments.updateNested( - const _TestComment(id: '1', text: 'New comment', replies: []), - key: (comment) => comment.id, + (comment) => comment.id == '1', children: (comment) => comment.replies, update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => @@ -891,8 +886,7 @@ void main() { ]; final result = comments.updateNested( - const _TestComment(id: 'nonexistent', text: 'Not found', replies: []), - key: (comment) => comment.id, + (comment) => comment.id == 'nonexistent', children: (comment) => comment.replies, update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => @@ -944,10 +938,9 @@ void main() { ); final result = forumThread.updateNested( - upvotedComment, - key: (comment) => comment.id, + (comment) => comment.id == upvotedComment.id, children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => upvotedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort by upvotes @@ -1067,10 +1060,9 @@ void main() { final stopwatch = Stopwatch()..start(); final result = deepComments.updateNested( - const _TestComment(id: 'thread1_0', text: 'Updated leaf'), - key: (comment) => comment.id, + (comment) => comment.id == 'thread1_0', children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => const _TestComment(id: 'thread1_0', text: 'Updated leaf').copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -1195,10 +1187,9 @@ void main() { var currentState = comments; for (var i = 0; i < 10; i++) { currentState = currentState.updateNested( - _TestComment(id: '2', text: 'Reply 1', upvotes: 5 + i), - key: (comment) => comment.id, + (comment) => comment.id == '2', children: (comment) => comment.replies, - update: (comment) => comment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => _TestComment(id: '2', text: 'Reply 1', upvotes: 5 + i).copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); From 4406d10e27ee2a927de47d92628dfa1dc94887a6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 28 Aug 2025 10:29:52 +0200 Subject: [PATCH 6/8] chore: fix formatting --- .../test/query/list_extensions_test.dart | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/test/query/list_extensions_test.dart b/packages/stream_core/test/query/list_extensions_test.dart index 4f11f18..9144f51 100644 --- a/packages/stream_core/test/query/list_extensions_test.dart +++ b/packages/stream_core/test/query/list_extensions_test.dart @@ -713,7 +713,8 @@ void main() { final result = comments.updateNested( (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => + updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -758,7 +759,8 @@ void main() { final result = comments.updateNested( (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => + updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -798,7 +800,8 @@ void main() { final result = comments.updateNested( (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => + updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), compare: (a, b) => @@ -853,7 +856,8 @@ void main() { final result = comments.updateNested( (comment) => comment.id == updatedComment.id, children: (comment) => comment.replies, - update: (comment) => updatedComment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => + updatedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -940,7 +944,8 @@ void main() { final result = forumThread.updateNested( (comment) => comment.id == upvotedComment.id, children: (comment) => comment.replies, - update: (comment) => upvotedComment.copyWith(modifiedAt: DateTime.now()), + update: (comment) => + upvotedComment.copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), compare: (a, b) => b.upvotes.compareTo(a.upvotes), // Sort by upvotes @@ -1062,7 +1067,9 @@ void main() { final result = deepComments.updateNested( (comment) => comment.id == 'thread1_0', children: (comment) => comment.replies, - update: (comment) => const _TestComment(id: 'thread1_0', text: 'Updated leaf').copyWith(modifiedAt: DateTime.now()), + update: (comment) => + const _TestComment(id: 'thread1_0', text: 'Updated leaf') + .copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); @@ -1189,7 +1196,9 @@ void main() { currentState = currentState.updateNested( (comment) => comment.id == '2', children: (comment) => comment.replies, - update: (comment) => _TestComment(id: '2', text: 'Reply 1', upvotes: 5 + i).copyWith(modifiedAt: DateTime.now()), + update: (comment) => + _TestComment(id: '2', text: 'Reply 1', upvotes: 5 + i) + .copyWith(modifiedAt: DateTime.now()), updateChildren: (parent, newReplies) => parent.copyWith(replies: newReplies), ); From 3ffa44deee52780519dbc066cbd8a8f97a6a8a1c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 29 Aug 2025 11:38:29 +0200 Subject: [PATCH 7/8] chore: remove commented tests --- .../additional_headers_interceptor_test.dart | 54 -- .../interceptor/auth_interceptor_test.dart | 239 ------- .../connection_id_interceptor_test.dart | 57 -- .../test/api/stream_http_client_test.dart | 671 ------------------ packages/stream_core/test/mocks.dart | 50 -- .../ws/connection_recovery_handler_test.dart | 118 --- 6 files changed, 1189 deletions(-) delete mode 100644 packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart delete mode 100644 packages/stream_core/test/api/interceptor/auth_interceptor_test.dart delete mode 100644 packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart delete mode 100644 packages/stream_core/test/api/stream_http_client_test.dart delete mode 100644 packages/stream_core/test/mocks.dart delete mode 100644 packages/stream_core/test/ws/connection_recovery_handler_test.dart 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 deleted file mode 100644 index 569b1d2..0000000 --- a/packages/stream_core/test/api/interceptor/additional_headers_interceptor_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -// // ignore_for_file: invalid_use_of_protected_member -// -// import 'package:dio/dio.dart'; -// import 'package:stream_core/stream_core.dart'; -// import 'package:test/test.dart'; -// -// import '../../mocks.dart'; -// -// void main() { -// group('HeadersInterceptor tests', () { -// group('with SystemEnvironmentManager', () { -// late HeadersInterceptor headersInterceptor; -// -// setUp(() { -// final environmentManager = FakeSystemEnvironmentManager( -// environment: environment, -// ); -// -// headersInterceptor = HeadersInterceptor( -// FakeSystemEnvironmentManager( -// environment: systemEnvironmentManager.environment, -// ), -// ); -// }); -// -// test('should add user agent header when available', () async { -// HeadersInterceptor.additionalHeaders = { -// 'test-header': 'test-value', -// }; -// addTearDown(() => HeadersInterceptor.additionalHeaders = {}); -// -// final options = RequestOptions(path: 'test-path'); -// final handler = RequestInterceptorHandler(); -// -// await headersInterceptor.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 deleted file mode 100644 index 69ace68..0000000 --- a/packages/stream_core/test/api/interceptor/auth_interceptor_test.dart +++ /dev/null @@ -1,239 +0,0 @@ -// // 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 deleted file mode 100644 index ad2a667..0000000 --- a/packages/stream_core/test/api/interceptor/connection_id_interceptor_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -// 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 deleted file mode 100644 index 1577a32..0000000 --- a/packages/stream_core/test/api/stream_http_client_test.dart +++ /dev/null @@ -1,671 +0,0 @@ -// // 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'; -// -// const testUser = User( -// id: 'user-id', -// name: 'test-user', -// imageUrl: 'https://example.com/image.png', -// ); -// -// 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 deleted file mode 100644 index 5262075..0000000 --- a/packages/stream_core/test/mocks.dart +++ /dev/null @@ -1,50 +0,0 @@ -// 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 {} -// -// class MockDio extends Mock implements Dio { -// BaseOptions? _options; -// -// @override -// BaseOptions get options => _options ??= BaseOptions(); -// -// Interceptors? _interceptors; -// -// @override -// Interceptors get interceptors => _interceptors ??= Interceptors(); -// } -// -// 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', -// 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, -// ); -// } diff --git a/packages/stream_core/test/ws/connection_recovery_handler_test.dart b/packages/stream_core/test/ws/connection_recovery_handler_test.dart deleted file mode 100644 index 1fed4b9..0000000 --- a/packages/stream_core/test/ws/connection_recovery_handler_test.dart +++ /dev/null @@ -1,118 +0,0 @@ -// // 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; -// } From 4ba52fd7060b3a88c547eb0cc3c3d833d564f904 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 29 Aug 2025 12:16:22 +0200 Subject: [PATCH 8/8] feat(core): add toJson to Sort This commit adds a `toJson` method to the `Sort` class in `stream_core`. This allows for easier serialization of Sort objects, particularly when using them in conjunction with other libraries or services that require JSON data. --- packages/stream_core/lib/src/query/sort.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stream_core/lib/src/query/sort.dart b/packages/stream_core/lib/src/query/sort.dart index ee4ccb8..54bc4c2 100644 --- a/packages/stream_core/lib/src/query/sort.dart +++ b/packages/stream_core/lib/src/query/sort.dart @@ -74,6 +74,8 @@ class Sort { int compare(T? a, T? b) { return field.comparator.call(a, b, direction, nullOrdering); } + + Map toJson() => _$SortToJson(this); } class SortField {