diff --git a/lib/providers/collection_providers.dart b/lib/providers/collection_providers.dart index 35bc4aa0c..5c7bd8ba1 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/collection_providers.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,6 +22,40 @@ final selectedRequestModelProvider = StateProvider((ref) { } }); +class SSEFramesNotifier extends StateNotifier>> { + SSEFramesNotifier() : super({}); + + void addFrame(String requestId, String frame) { + print(frame+"\n"); + state = { + ...state, + requestId: [...(state[requestId] ?? []), SSEEventModel.fromRawSSE(frame)], + }; + } + + void clearFrames(String requestId) { + if (state.containsKey(requestId)) { + state = { + ...state, + requestId: [], + }; + } + } + + void removeRequest(String requestId) { + final newState = {...state}; + newState.remove(requestId); + state = newState; + } +} + +// Provide it globally +final sseFramesProvider = + StateNotifierProvider>>( + (ref) => SSEFramesNotifier(), +); + + final requestSequenceProvider = StateProvider>((ref) { var ids = hiveHandler.getIds(); return ids ?? []; @@ -262,6 +298,7 @@ class CollectionStateNotifier unsave(); } + Future sendRequest() async { final requestId = ref.read(selectedIdStateProvider); ref.read(codePaneVisibleStateProvider.notifier).state = false; @@ -289,6 +326,75 @@ class CollectionStateNotifier state = map; bool noSSL = ref.read(settingsProvider).isSSLDisabled; + if(apiType == APIType.sse){ + + List frames; + await sendSSERequest( + requestId, + apiType, + substitutedHttpRequestModel, + defaultUriScheme: defaultUriScheme, + noSSL: noSSL, + onConnect: (statusCode, responseHeaders, requestHeaders,duration) { + + ref.read(sseFramesProvider.notifier).clearFrames(requestId); + map = {...state!}; + + map[requestId] = requestModel.copyWith( + responseStatus: statusCode, + message: kResponseCodeReasons[statusCode], + httpResponseModel: baseHttpResponseModel.copyWith( + statusCode: statusCode, + headers: responseHeaders, + requestHeaders: requestHeaders, + time: duration, + ), + isWorking: true, + ); + state = map; + + + }, + onData: (response) { + ref.read(sseFramesProvider.notifier).addFrame(requestId, response); + }, + onError: (error, stackTrace) { + frames = ref.read(sseFramesProvider)[requestId] ?? []; + map = {...state!}; + map[requestId] = requestModel.copyWith( + responseStatus: 500, + message: error.toString(), + httpResponseModel: baseHttpResponseModel.copyWith( + sseEvents: frames, + ), + isWorking: false, + ); + state = map; + }, + onDone: () { + frames = ref.read(sseFramesProvider)[requestId] ?? []; + map = {...state!}; + map[requestId] = requestModel.copyWith( + httpResponseModel: baseHttpResponseModel.copyWith( + sseEvents: frames, + ), + isWorking: false, + ); + state = map; + }, + onCancel: () { + frames = ref.read(sseFramesProvider)[requestId] ?? []; + map = {...state!}; + map[requestId] = requestModel.copyWith( + responseStatus: -1, + message: kMsgRequestCancelled, + isWorking: false, + ); + state = map; + }, + ); + return; + } var responseRec = await sendHttpRequest( requestId, apiType, @@ -343,6 +449,7 @@ class CollectionStateNotifier unsave(); } + void cancelRequest() { final id = ref.read(selectedIdStateProvider); cancelHttpRequest(id); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane.dart index 9c852c71b..c15deab83 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane.dart @@ -15,7 +15,7 @@ class EditRequestPane extends ConsumerWidget { final apiType = ref .watch(selectedRequestModelProvider.select((value) => value?.apiType)); return switch (apiType) { - APIType.rest => const EditRestRequestPane(), + APIType.rest || APIType.sse => const EditRestRequestPane(), APIType.graphql => const EditGraphQLRequestPane(), _ => kSizedBoxEmpty, }; diff --git a/lib/screens/home_page/editor_pane/details_card/response_pane.dart b/lib/screens/home_page/editor_pane/details_card/response_pane.dart index 367bb4128..d6563cac0 100644 --- a/lib/screens/home_page/editor_pane/details_card/response_pane.dart +++ b/lib/screens/home_page/editor_pane/details_card/response_pane.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,11 +7,12 @@ import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; -class ResponsePane extends ConsumerWidget { +class ResponsePane extends ConsumerWidget{ const ResponsePane({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedIdStateProvider)!; final isWorking = ref.watch( selectedRequestModelProvider.select((value) => value?.isWorking)) ?? false; @@ -19,12 +22,26 @@ class ResponsePane extends ConsumerWidget { selectedRequestModelProvider.select((value) => value?.responseStatus)); final message = ref .watch(selectedRequestModelProvider.select((value) => value?.message)); + final sseFramesNotifier = ref.watch(sseFramesProvider.select((state) => state[selectedId] ?? [])); + final apiType = ref.watch(selectedRequestModelProvider.select((value) => value?.apiType)); + + if (isWorking && apiType == APIType.sse && sseFramesNotifier.isEmpty) { + + return SendingWidget( + startSendingTime: startSendingTime, + ); + + } + - if (isWorking) { + if (isWorking && apiType != APIType.sse ) { + return SendingWidget( startSendingTime: startSendingTime, ); + } + if (responseStatus == null) { return const NotSentWidget(); } @@ -54,7 +71,6 @@ class ResponseDetails extends ConsumerWidget { .watch(selectedRequestModelProvider.select((value) => value?.message)); final responseModel = ref.watch(selectedRequestModelProvider .select((value) => value?.httpResponseModel)); - return Column( children: [ ResponsePaneHeader( @@ -78,12 +94,21 @@ class ResponseTabs extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedId = ref.watch(selectedIdStateProvider); + final selectedId = ref.watch(selectedIdStateProvider)!; + final apiType = ref + .watch(selectedRequestModelProvider.select((value) => value?.apiType)); + return ResponseTabView( selectedId: selectedId, - children: const [ - ResponseBodyTab(), - ResponseHeadersTab(), + children: [ + if (apiType == APIType.rest || apiType == APIType.graphql) ...const[ + ResponseBodyTab(), + ResponseHeadersTab(), + ]else ...[ + SSEView(requestId: selectedId), + const ResponseHeadersTab(), + ] + ], ); } @@ -106,15 +131,204 @@ class ResponseHeadersTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final requestHeaders = ref.watch(selectedRequestModelProvider + final requestHttpHeaders = ref.watch(selectedRequestModelProvider .select((value) => value?.httpResponseModel?.requestHeaders)) ?? {}; - final responseHeaders = ref.watch(selectedRequestModelProvider + final responseHttpHeaders = ref.watch(selectedRequestModelProvider .select((value) => value?.httpResponseModel?.headers)) ?? {}; - return ResponseHeaders( - responseHeaders: responseHeaders, - requestHeaders: requestHeaders, - ); + + final apiType = ref + .watch(selectedRequestModelProvider.select((value) => value?.apiType)); + switch (apiType!) { + + case APIType.rest: + return ResponseHeaders( + responseHeaders: responseHttpHeaders, + requestHeaders: requestHttpHeaders, + ); + case _: + return ResponseHeaders( + responseHeaders: responseHttpHeaders, + requestHeaders: requestHttpHeaders, + ); + } + } } + + + +class SSEView extends ConsumerStatefulWidget { + final String requestId; + const SSEView({super.key, required this.requestId}); + + @override + ConsumerState createState() => _SSEViewState(); +} + +class _SSEViewState extends ConsumerState { + final ScrollController _controller = ScrollController(); + bool _isAtBottom = true; + List _pausedFrames = []; + + @override + void initState() { + super.initState(); + + _controller.addListener(() { + if (_controller.hasClients) { + final isAtBottom = _controller.position.pixels >= _controller.position.maxScrollExtent; + setState(() { + _isAtBottom = isAtBottom; + }); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final frames = ref.watch(sseFramesProvider.select((state) => state[widget.requestId] ?? [])); + + // if (_isAtBottom) { + // _pausedFrames = List.from(frames); + // } + if (_isAtBottom || frames.isEmpty) { + _pausedFrames = List.from(frames); + } + final displayFrames = _isAtBottom ? frames : _pausedFrames; + + return Scaffold( + body: Column( + children: [ + Expanded( + child: ListView.builder( + controller: _controller, + itemCount: displayFrames.length, + itemBuilder: (context, index) { + final event = displayFrames[index]; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.event.isNotEmpty) _buildTag("Event", "message", Colors.blue), + if (event.data.isNotEmpty) _buildData(event.data), + if (event.id != null && event.id!.isNotEmpty) _buildTag("ID", event.id!, Colors.orange), + if (event.retry != null) _buildTag("Retry", "${event.retry} ms", Colors.purple), + if (event.customFields != null && event.customFields!.isNotEmpty) + ...event.customFields!.entries.map( + (e) => _buildTag(e.key, e.value, Colors.grey), + ), + ], + ), + ), + ), + ); + }, + ), + ), + if (!_isAtBottom) + ElevatedButton( + onPressed: () { + _controller.animateTo( + _controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + }, + child: const Text("Scroll to Bottom"), + ), + ], + ), + ); + } + + Widget _buildTag(String title, String value, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + title, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ], + ), + ), + const Divider(height: 10, thickness: 0.5), // Add a subtle separator line + ], + ); + } + + Widget _buildData(String data) { + bool _isExpanded = false; + + return StatefulBuilder( + builder: (context, setState) { + try { + final jsonData = json.decode(data); + final formattedJson = const JsonEncoder.withIndent(' ').convert(jsonData); + final bool shouldCollapse = formattedJson.length > 100; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text("Data", style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.green)), + const Spacer(), + if (shouldCollapse) + IconButton( + icon: Icon(_isExpanded ? Icons.expand_less : Icons.expand_more), + onPressed: () => setState(() => _isExpanded = !_isExpanded), + iconSize: 18, + ), + ], + ), + Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _isExpanded || !shouldCollapse ? formattedJson : "${formattedJson.substring(0, 100)}...", + style: const TextStyle(fontFamily: 'Courier', fontSize: 12), + ), + ), + const Divider(height: 10, thickness: 0.5), + ], + ); + } catch (_) { + return _buildTag("Data", data, Colors.green); + } + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/home_page/editor_pane/url_card.dart b/lib/screens/home_page/editor_pane/url_card.dart index 829bc5c97..a5828d0f7 100644 --- a/lib/screens/home_page/editor_pane/url_card.dart +++ b/lib/screens/home_page/editor_pane/url_card.dart @@ -33,12 +33,12 @@ class EditorPaneRequestURLCard extends ConsumerWidget { ? Row( children: [ switch (apiType) { - APIType.rest => const DropdownButtonHTTPMethod(), + APIType.rest || APIType.sse => const DropdownButtonHTTPMethod(), APIType.graphql => kSizedBoxEmpty, null => kSizedBoxEmpty, }, switch (apiType) { - APIType.rest => kHSpacer5, + APIType.rest || APIType.sse => kHSpacer5, _ => kHSpacer8, }, const Expanded( @@ -49,7 +49,7 @@ class EditorPaneRequestURLCard extends ConsumerWidget { : Row( children: [ switch (apiType) { - APIType.rest => const DropdownButtonHTTPMethod(), + APIType.rest || APIType.sse=> const DropdownButtonHTTPMethod(), APIType.graphql => kSizedBoxEmpty, null => kSizedBoxEmpty, }, diff --git a/lib/utils/ui_utils.dart b/lib/utils/ui_utils.dart index 4f72a1a3f..50458f72a 100644 --- a/lib/utils/ui_utils.dart +++ b/lib/utils/ui_utils.dart @@ -36,6 +36,7 @@ Color getAPIColor( method, ), APIType.graphql => kColorGQL, + APIType.sse => kColorSSE, }; if (brightness == Brightness.dark) { col = getDarkModeColor(col); diff --git a/lib/widgets/texts.dart b/lib/widgets/texts.dart index c64a71b27..834936c56 100644 --- a/lib/widgets/texts.dart +++ b/lib/widgets/texts.dart @@ -20,6 +20,7 @@ class SidebarRequestCardTextBox extends StatelessWidget { switch (apiType) { APIType.rest => method.abbr, APIType.graphql => apiType.abbr, + APIType.sse => apiType.abbr, }, textAlign: TextAlign.center, style: TextStyle( diff --git a/packages/apidash_core/lib/apidash_core.dart b/packages/apidash_core/lib/apidash_core.dart index 305bb5c81..a26b11b8f 100644 --- a/packages/apidash_core/lib/apidash_core.dart +++ b/packages/apidash_core/lib/apidash_core.dart @@ -4,7 +4,7 @@ export 'consts.dart'; export 'extensions/extensions.dart'; export 'models/models.dart'; export 'utils/utils.dart'; -export 'services/services.dart'; +export 'services/services.dart' hide StreamedResponse; export 'import_export/import_export.dart'; // Export 3rd party packages diff --git a/packages/apidash_core/lib/consts.dart b/packages/apidash_core/lib/consts.dart index 1d7044c74..6bb48bcbf 100644 --- a/packages/apidash_core/lib/consts.dart +++ b/packages/apidash_core/lib/consts.dart @@ -2,7 +2,8 @@ import 'dart:convert'; enum APIType { rest("HTTP", "HTTP"), - graphql("GraphQL", "GQL"); + graphql("GraphQL", "GQL"), + sse("SSE", "SSE"); const APIType(this.label, this.abbr); final String label; diff --git a/packages/apidash_core/lib/models/http_response_model.dart b/packages/apidash_core/lib/models/http_response_model.dart index 914aaa577..5e49a4f9e 100644 --- a/packages/apidash_core/lib/models/http_response_model.dart +++ b/packages/apidash_core/lib/models/http_response_model.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:apidash_core/apidash_core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:collection/collection.dart' show mergeMaps; import 'package:http/http.dart'; @@ -56,6 +57,7 @@ class HttpResponseModel with _$HttpResponseModel { String? formattedBody, @Uint8ListConverter() Uint8List? bodyBytes, @DurationConverter() Duration? time, + List? sseEvents, }) = _HttpResponseModel; factory HttpResponseModel.fromJson(Map json) => diff --git a/packages/apidash_core/lib/models/http_response_model.freezed.dart b/packages/apidash_core/lib/models/http_response_model.freezed.dart index 45071986a..68fe1923b 100644 --- a/packages/apidash_core/lib/models/http_response_model.freezed.dart +++ b/packages/apidash_core/lib/models/http_response_model.freezed.dart @@ -29,6 +29,7 @@ mixin _$HttpResponseModel { Uint8List? get bodyBytes => throw _privateConstructorUsedError; @DurationConverter() Duration? get time => throw _privateConstructorUsedError; + List? get sseEvents => throw _privateConstructorUsedError; /// Serializes this HttpResponseModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -53,7 +54,8 @@ abstract class $HttpResponseModelCopyWith<$Res> { String? body, String? formattedBody, @Uint8ListConverter() Uint8List? bodyBytes, - @DurationConverter() Duration? time}); + @DurationConverter() Duration? time, + List? sseEvents}); } /// @nodoc @@ -78,6 +80,7 @@ class _$HttpResponseModelCopyWithImpl<$Res, $Val extends HttpResponseModel> Object? formattedBody = freezed, Object? bodyBytes = freezed, Object? time = freezed, + Object? sseEvents = freezed, }) { return _then(_value.copyWith( statusCode: freezed == statusCode @@ -108,6 +111,10 @@ class _$HttpResponseModelCopyWithImpl<$Res, $Val extends HttpResponseModel> ? _value.time : time // ignore: cast_nullable_to_non_nullable as Duration?, + sseEvents: freezed == sseEvents + ? _value.sseEvents + : sseEvents // ignore: cast_nullable_to_non_nullable + as List?, ) as $Val); } } @@ -127,7 +134,8 @@ abstract class _$$HttpResponseModelImplCopyWith<$Res> String? body, String? formattedBody, @Uint8ListConverter() Uint8List? bodyBytes, - @DurationConverter() Duration? time}); + @DurationConverter() Duration? time, + List? sseEvents}); } /// @nodoc @@ -150,6 +158,7 @@ class __$$HttpResponseModelImplCopyWithImpl<$Res> Object? formattedBody = freezed, Object? bodyBytes = freezed, Object? time = freezed, + Object? sseEvents = freezed, }) { return _then(_$HttpResponseModelImpl( statusCode: freezed == statusCode @@ -180,6 +189,10 @@ class __$$HttpResponseModelImplCopyWithImpl<$Res> ? _value.time : time // ignore: cast_nullable_to_non_nullable as Duration?, + sseEvents: freezed == sseEvents + ? _value._sseEvents + : sseEvents // ignore: cast_nullable_to_non_nullable + as List?, )); } } @@ -195,9 +208,11 @@ class _$HttpResponseModelImpl extends _HttpResponseModel { this.body, this.formattedBody, @Uint8ListConverter() this.bodyBytes, - @DurationConverter() this.time}) + @DurationConverter() this.time, + final List? sseEvents}) : _headers = headers, _requestHeaders = requestHeaders, + _sseEvents = sseEvents, super._(); factory _$HttpResponseModelImpl.fromJson(Map json) => @@ -235,10 +250,19 @@ class _$HttpResponseModelImpl extends _HttpResponseModel { @override @DurationConverter() final Duration? time; + final List? _sseEvents; + @override + List? get sseEvents { + final value = _sseEvents; + if (value == null) return null; + if (_sseEvents is EqualUnmodifiableListView) return _sseEvents; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } @override String toString() { - return 'HttpResponseModel(statusCode: $statusCode, headers: $headers, requestHeaders: $requestHeaders, body: $body, formattedBody: $formattedBody, bodyBytes: $bodyBytes, time: $time)'; + return 'HttpResponseModel(statusCode: $statusCode, headers: $headers, requestHeaders: $requestHeaders, body: $body, formattedBody: $formattedBody, bodyBytes: $bodyBytes, time: $time, sseEvents: $sseEvents)'; } @override @@ -255,7 +279,9 @@ class _$HttpResponseModelImpl extends _HttpResponseModel { (identical(other.formattedBody, formattedBody) || other.formattedBody == formattedBody) && const DeepCollectionEquality().equals(other.bodyBytes, bodyBytes) && - (identical(other.time, time) || other.time == time)); + (identical(other.time, time) || other.time == time) && + const DeepCollectionEquality() + .equals(other._sseEvents, _sseEvents)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -268,7 +294,8 @@ class _$HttpResponseModelImpl extends _HttpResponseModel { body, formattedBody, const DeepCollectionEquality().hash(bodyBytes), - time); + time, + const DeepCollectionEquality().hash(_sseEvents)); /// Create a copy of HttpResponseModel /// with the given fields replaced by the non-null parameter values. @@ -295,7 +322,8 @@ abstract class _HttpResponseModel extends HttpResponseModel { final String? body, final String? formattedBody, @Uint8ListConverter() final Uint8List? bodyBytes, - @DurationConverter() final Duration? time}) = _$HttpResponseModelImpl; + @DurationConverter() final Duration? time, + final List? sseEvents}) = _$HttpResponseModelImpl; const _HttpResponseModel._() : super._(); factory _HttpResponseModel.fromJson(Map json) = @@ -317,6 +345,8 @@ abstract class _HttpResponseModel extends HttpResponseModel { @override @DurationConverter() Duration? get time; + @override + List? get sseEvents; /// Create a copy of HttpResponseModel /// with the given fields replaced by the non-null parameter values. diff --git a/packages/apidash_core/lib/models/http_response_model.g.dart b/packages/apidash_core/lib/models/http_response_model.g.dart index 186fd152f..f3e174018 100644 --- a/packages/apidash_core/lib/models/http_response_model.g.dart +++ b/packages/apidash_core/lib/models/http_response_model.g.dart @@ -20,6 +20,10 @@ _$HttpResponseModelImpl _$$HttpResponseModelImplFromJson(Map json) => bodyBytes: const Uint8ListConverter().fromJson(json['bodyBytes'] as List?), time: const DurationConverter().fromJson((json['time'] as num?)?.toInt()), + sseEvents: (json['sseEvents'] as List?) + ?.map((e) => + SSEEventModel.fromJson(Map.from(e as Map))) + .toList(), ); Map _$$HttpResponseModelImplToJson( @@ -32,4 +36,5 @@ Map _$$HttpResponseModelImplToJson( 'formattedBody': instance.formattedBody, 'bodyBytes': const Uint8ListConverter().toJson(instance.bodyBytes), 'time': const DurationConverter().toJson(instance.time), + 'sseEvents': instance.sseEvents?.map((e) => e.toJson()).toList(), }; diff --git a/packages/apidash_core/lib/models/http_sse_event_model.dart b/packages/apidash_core/lib/models/http_sse_event_model.dart new file mode 100644 index 000000000..f633d3baa --- /dev/null +++ b/packages/apidash_core/lib/models/http_sse_event_model.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'http_sse_event_model.freezed.dart'; +part 'http_sse_event_model.g.dart'; + +@freezed +class SSEEventModel with _$SSEEventModel { + const SSEEventModel._(); + + @JsonSerializable( + explicitToJson: true, + anyMap: true, + ) + const factory SSEEventModel({ + @Default("") String event, + @Default("") String data, + @Default("") String comment, + String? id, + int? retry, + Map? customFields, + }) = _SSEEventModel; + + factory SSEEventModel.fromJson(Map json) => + _$SSEEventModelFromJson(json); + + /// Parses raw SSE data into an SSEEventModel + static SSEEventModel fromRawSSE(String rawEvent) { + final Map fields = {}; + String? event, data, id ,comment; + int? retry; + + for (var line in LineSplitter.split(rawEvent)) { + if (line.startsWith(":")) { + comment = line;// Ignore comments + } + final parts = line.split(": "); + if (parts.length < 2) continue; + + final key = parts[0].trim(); + final value = parts.sublist(1).join(": ").trim(); + + switch (key) { + case "event": + event = value; + break; + case "data": + data = (data ?? "") + value; + break; + case "id": + id = value; + break; + case "retry": + retry = int.tryParse(value); + break; + default: + fields[key] = value; // Store unknown fields as custom metadata + } + } + + return SSEEventModel( + event: event ?? "", + data: data?.trim() ?? "", + comment: comment ?? "", + id: id, + retry: retry, + customFields: fields.isNotEmpty ? fields : null, + ); + } +} \ No newline at end of file diff --git a/packages/apidash_core/lib/models/http_sse_event_model.freezed.dart b/packages/apidash_core/lib/models/http_sse_event_model.freezed.dart new file mode 100644 index 000000000..2e16d7079 --- /dev/null +++ b/packages/apidash_core/lib/models/http_sse_event_model.freezed.dart @@ -0,0 +1,284 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'http_sse_event_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +SSEEventModel _$SSEEventModelFromJson(Map json) { + return _SSEEventModel.fromJson(json); +} + +/// @nodoc +mixin _$SSEEventModel { + String get event => throw _privateConstructorUsedError; + String get data => throw _privateConstructorUsedError; + String get comment => throw _privateConstructorUsedError; + String? get id => throw _privateConstructorUsedError; + int? get retry => throw _privateConstructorUsedError; + Map? get customFields => throw _privateConstructorUsedError; + + /// Serializes this SSEEventModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SSEEventModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SSEEventModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SSEEventModelCopyWith<$Res> { + factory $SSEEventModelCopyWith( + SSEEventModel value, $Res Function(SSEEventModel) then) = + _$SSEEventModelCopyWithImpl<$Res, SSEEventModel>; + @useResult + $Res call( + {String event, + String data, + String comment, + String? id, + int? retry, + Map? customFields}); +} + +/// @nodoc +class _$SSEEventModelCopyWithImpl<$Res, $Val extends SSEEventModel> + implements $SSEEventModelCopyWith<$Res> { + _$SSEEventModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SSEEventModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? event = null, + Object? data = null, + Object? comment = null, + Object? id = freezed, + Object? retry = freezed, + Object? customFields = freezed, + }) { + return _then(_value.copyWith( + event: null == event + ? _value.event + : event // ignore: cast_nullable_to_non_nullable + as String, + data: null == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as String, + comment: null == comment + ? _value.comment + : comment // ignore: cast_nullable_to_non_nullable + as String, + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + retry: freezed == retry + ? _value.retry + : retry // ignore: cast_nullable_to_non_nullable + as int?, + customFields: freezed == customFields + ? _value.customFields + : customFields // ignore: cast_nullable_to_non_nullable + as Map?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SSEEventModelImplCopyWith<$Res> + implements $SSEEventModelCopyWith<$Res> { + factory _$$SSEEventModelImplCopyWith( + _$SSEEventModelImpl value, $Res Function(_$SSEEventModelImpl) then) = + __$$SSEEventModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String event, + String data, + String comment, + String? id, + int? retry, + Map? customFields}); +} + +/// @nodoc +class __$$SSEEventModelImplCopyWithImpl<$Res> + extends _$SSEEventModelCopyWithImpl<$Res, _$SSEEventModelImpl> + implements _$$SSEEventModelImplCopyWith<$Res> { + __$$SSEEventModelImplCopyWithImpl( + _$SSEEventModelImpl _value, $Res Function(_$SSEEventModelImpl) _then) + : super(_value, _then); + + /// Create a copy of SSEEventModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? event = null, + Object? data = null, + Object? comment = null, + Object? id = freezed, + Object? retry = freezed, + Object? customFields = freezed, + }) { + return _then(_$SSEEventModelImpl( + event: null == event + ? _value.event + : event // ignore: cast_nullable_to_non_nullable + as String, + data: null == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as String, + comment: null == comment + ? _value.comment + : comment // ignore: cast_nullable_to_non_nullable + as String, + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + retry: freezed == retry + ? _value.retry + : retry // ignore: cast_nullable_to_non_nullable + as int?, + customFields: freezed == customFields + ? _value._customFields + : customFields // ignore: cast_nullable_to_non_nullable + as Map?, + )); + } +} + +/// @nodoc + +@JsonSerializable(explicitToJson: true, anyMap: true) +class _$SSEEventModelImpl extends _SSEEventModel { + const _$SSEEventModelImpl( + {this.event = "", + this.data = "", + this.comment = "", + this.id, + this.retry, + final Map? customFields}) + : _customFields = customFields, + super._(); + + factory _$SSEEventModelImpl.fromJson(Map json) => + _$$SSEEventModelImplFromJson(json); + + @override + @JsonKey() + final String event; + @override + @JsonKey() + final String data; + @override + @JsonKey() + final String comment; + @override + final String? id; + @override + final int? retry; + final Map? _customFields; + @override + Map? get customFields { + final value = _customFields; + if (value == null) return null; + if (_customFields is EqualUnmodifiableMapView) return _customFields; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + String toString() { + return 'SSEEventModel(event: $event, data: $data, comment: $comment, id: $id, retry: $retry, customFields: $customFields)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SSEEventModelImpl && + (identical(other.event, event) || other.event == event) && + (identical(other.data, data) || other.data == data) && + (identical(other.comment, comment) || other.comment == comment) && + (identical(other.id, id) || other.id == id) && + (identical(other.retry, retry) || other.retry == retry) && + const DeepCollectionEquality() + .equals(other._customFields, _customFields)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, event, data, comment, id, retry, + const DeepCollectionEquality().hash(_customFields)); + + /// Create a copy of SSEEventModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SSEEventModelImplCopyWith<_$SSEEventModelImpl> get copyWith => + __$$SSEEventModelImplCopyWithImpl<_$SSEEventModelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SSEEventModelImplToJson( + this, + ); + } +} + +abstract class _SSEEventModel extends SSEEventModel { + const factory _SSEEventModel( + {final String event, + final String data, + final String comment, + final String? id, + final int? retry, + final Map? customFields}) = _$SSEEventModelImpl; + const _SSEEventModel._() : super._(); + + factory _SSEEventModel.fromJson(Map json) = + _$SSEEventModelImpl.fromJson; + + @override + String get event; + @override + String get data; + @override + String get comment; + @override + String? get id; + @override + int? get retry; + @override + Map? get customFields; + + /// Create a copy of SSEEventModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SSEEventModelImplCopyWith<_$SSEEventModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/apidash_core/lib/models/http_sse_event_model.g.dart b/packages/apidash_core/lib/models/http_sse_event_model.g.dart new file mode 100644 index 000000000..17c1ccdf6 --- /dev/null +++ b/packages/apidash_core/lib/models/http_sse_event_model.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'http_sse_event_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SSEEventModelImpl _$$SSEEventModelImplFromJson(Map json) => + _$SSEEventModelImpl( + event: json['event'] as String? ?? "", + data: json['data'] as String? ?? "", + comment: json['comment'] as String? ?? "", + id: json['id'] as String?, + retry: (json['retry'] as num?)?.toInt(), + customFields: (json['customFields'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + ); + +Map _$$SSEEventModelImplToJson(_$SSEEventModelImpl instance) => + { + 'event': instance.event, + 'data': instance.data, + 'comment': instance.comment, + 'id': instance.id, + 'retry': instance.retry, + 'customFields': instance.customFields, + }; diff --git a/packages/apidash_core/lib/models/models.dart b/packages/apidash_core/lib/models/models.dart index c50ec988f..71dda0ae9 100644 --- a/packages/apidash_core/lib/models/models.dart +++ b/packages/apidash_core/lib/models/models.dart @@ -1,3 +1,4 @@ export 'environment_model.dart'; export 'http_request_model.dart'; export 'http_response_model.dart'; +export 'http_sse_event_model.dart'; \ No newline at end of file diff --git a/packages/apidash_core/lib/services/http_client_manager.dart b/packages/apidash_core/lib/services/http_client_manager.dart index 7b815413f..2dc13d821 100644 --- a/packages/apidash_core/lib/services/http_client_manager.dart +++ b/packages/apidash_core/lib/services/http_client_manager.dart @@ -62,4 +62,4 @@ class HttpClientManager { bool hasActiveClient(String requestId) { return _clients.containsKey(requestId); } -} +} \ No newline at end of file diff --git a/packages/apidash_core/lib/services/http_service.dart b/packages/apidash_core/lib/services/http_service.dart index dbd93086a..fb9f825e1 100644 --- a/packages/apidash_core/lib/services/http_service.dart +++ b/packages/apidash_core/lib/services/http_service.dart @@ -140,6 +140,7 @@ Future<(HttpResponse?, Duration?, String?)> sendHttpRequest( } void cancelHttpRequest(String? requestId) { + print("Cancelling request: $requestId"); httpClientManager.cancelRequest(requestId); } @@ -166,3 +167,4 @@ http.Request prepareHttpRequest({ request.headers.addAll(headers); return request; } + diff --git a/packages/apidash_core/lib/services/http_sse_service.dart b/packages/apidash_core/lib/services/http_sse_service.dart new file mode 100644 index 000000000..f7422f411 --- /dev/null +++ b/packages/apidash_core/lib/services/http_sse_service.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:http_interceptor/http_interceptor.dart'; +import 'package:seed/consts.dart'; +import '../consts.dart'; +import '../models/models.dart'; +import '../utils/utils.dart'; +import 'http_service.dart'; + + + + + +class SSETransformer extends StreamTransformerBase { + const SSETransformer(); + + @override + Stream bind(Stream stream) async* { + String buffer = ""; + await for (final chunk in stream) { + buffer += chunk; + List frames = buffer.split("\n\n"); + + + buffer = frames.removeLast(); + + for (final frame in frames) { + yield frame.trim(); + } + } + } +} +class LoggingInterceptor implements InterceptorContract { + Map? interceptedHeaders; + + @override + FutureOr interceptRequest({required BaseRequest request}) async { + interceptedHeaders = request.headers; + return request; + } + + @override + Future interceptResponse({required BaseResponse response}) async { + + return response; + } + + @override + FutureOr shouldInterceptRequest() => true; + + @override + FutureOr shouldInterceptResponse() => true; +} + +Future sendSSERequest( + String requestId, + APIType apiType, + HttpRequestModel requestModel, { + SupportedUriSchemes defaultUriScheme = kDefaultUriScheme, + bool noSSL = false, + Function(int statusCode, Map headers, Map? interceptedHeaders, Duration?)? onConnect, + Function(String event)? onData, + Function(Object error, StackTrace stackTrace)? onError, + Function()? onDone, + Function()? onCancel, +}) async { + if (httpClientManager.wasRequestCancelled(requestId)) { + httpClientManager.removeCancelledRequest(requestId); + } + final interceptor = LoggingInterceptor(); + final baseClient = httpClientManager.createClient(requestId, noSSL: noSSL); + final client = InterceptedClient.build(interceptors: [interceptor], client: baseClient); + + try { + (Uri?, String?) uriRec = getValidRequestUri( + requestModel.url, + requestModel.enabledParams, + defaultUriScheme: defaultUriScheme, + ); + if (uriRec.$1 == null) return; + + Uri requestUrl = uriRec.$1!; + var isMultiPartRequest = requestModel.bodyContentType == ContentType.formdata; + var stopwatch = Stopwatch()..start(); + late http.StreamedResponse streamedResponse; + if (apiType == APIType.sse) { + + if (isMultiPartRequest) { + var multiPartRequest = http.MultipartRequest( + requestModel.method.name.toUpperCase(), + requestUrl, + ); + multiPartRequest.headers.addAll(requestModel.enabledHeadersMap); + + for (var formData in requestModel.formDataList) { + if (formData.type == FormDataType.text) { + multiPartRequest.fields[formData.name] = formData.value; + } else { + multiPartRequest.files.add(await http.MultipartFile.fromPath(formData.name, formData.value)); + } + } + + streamedResponse = await multiPartRequest.send(); + stopwatch.stop(); + + + + } else { + var request = http.Request(requestModel.method.name.toUpperCase(), requestUrl); + request.headers.addAll(requestModel.enabledHeadersMap); + + if (kMethodsWithBody.contains(requestModel.method) && requestModel.body != null) { + request.body = requestModel.body!; + request.headers[HttpHeaders.contentTypeHeader] = requestModel.bodyContentType.header; + } + + streamedResponse = await client.send(request); + stopwatch.stop(); + + + + } + onConnect?.call( + streamedResponse.statusCode, streamedResponse.headers, interceptor.interceptedHeaders, stopwatch.elapsed + ); + + final stream = streamedResponse.stream + .transform(utf8.decoder) + .transform(const SSETransformer()); + stream.listen( + (frame) { + onData?.call(frame); + }, + onError: (error) { + + if (httpClientManager.wasRequestCancelled(requestId)) { + onCancel?.call(); + + return; + } + (error, stackTrace) => onError?.call(error, stackTrace); + + }, + onDone: () { + if (httpClientManager.wasRequestCancelled(requestId)) { + onCancel?.call(); + return; + } + onDone?.call(); + + } + ); + } + } catch (e, stackTrace) { + + onError?.call(e, stackTrace); + } + +} + diff --git a/packages/apidash_core/lib/services/services.dart b/packages/apidash_core/lib/services/services.dart index d155e9c70..a5bb7fbd0 100644 --- a/packages/apidash_core/lib/services/services.dart +++ b/packages/apidash_core/lib/services/services.dart @@ -1,2 +1,3 @@ export 'http_client_manager.dart'; export 'http_service.dart'; +export 'http_sse_service.dart'; \ No newline at end of file diff --git a/packages/apidash_core/lib/utils/http_request_utils.dart b/packages/apidash_core/lib/utils/http_request_utils.dart index b5eab42cc..24bc36753 100644 --- a/packages/apidash_core/lib/utils/http_request_utils.dart +++ b/packages/apidash_core/lib/utils/http_request_utils.dart @@ -93,7 +93,7 @@ List? getEnabledRows( String? getRequestBody(APIType type, HttpRequestModel httpRequestModel) { return switch (type) { - APIType.rest => + APIType.rest || APIType.sse => (httpRequestModel.hasJsonData || httpRequestModel.hasTextData) ? httpRequestModel.body : null, diff --git a/packages/apidash_core/pubspec.yaml b/packages/apidash_core/pubspec.yaml index 13aaea079..344a20aac 100644 --- a/packages/apidash_core/pubspec.yaml +++ b/packages/apidash_core/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: path: ../postman seed: ^0.0.3 xml: ^6.3.0 + http_interceptor: ^2.0.0 dev_dependencies: flutter_test: diff --git a/packages/apidash_design_system/lib/tokens/colors.dart b/packages/apidash_design_system/lib/tokens/colors.dart index 11943bd32..92b8c061f 100644 --- a/packages/apidash_design_system/lib/tokens/colors.dart +++ b/packages/apidash_design_system/lib/tokens/colors.dart @@ -26,6 +26,8 @@ final kColorHttpMethodDelete = Colors.red.shade800; final kColorGQL = Colors.pink.shade600; +final kColorSSE = Colors.purple.shade600; + const kHintOpacity = 0.6; const kForegroundOpacity = 0.05; const kOverlayBackgroundOpacity = 0.5; diff --git a/pubspec.lock b/pubspec.lock index 9881558db..c6df612cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -735,6 +735,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + http_interceptor: + dependency: transitive + description: + name: http_interceptor + sha256: "288c6ded4a2c66de2730a16b30cbd29d05d042a5e61304d9b4be0e16378f4082" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http_multi_server: dependency: transitive description: