feat: implement cURL import functionality with parsing and action handling

This commit is contained in:
Udhay-Adithya
2025-09-17 02:19:22 +05:30
parent 8036d60615
commit 9b1c3fe41c
7 changed files with 363 additions and 18 deletions

View File

@@ -90,6 +90,34 @@ class DashbotAddTestButton extends ConsumerWidget with DashbotActionMixin {
} }
} }
class DashbotApplyCurlButton extends ConsumerWidget with DashbotActionMixin {
@override
final ChatAction action;
const DashbotApplyCurlButton({super.key, required this.action});
String _labelForField(String? field) {
switch (field) {
case 'apply_to_selected':
return 'Apply to Selected';
case 'apply_to_new':
return 'Create New Request';
default:
return 'Apply';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final label = _labelForField(action.field);
return ElevatedButton(
onPressed: () async {
await ref.read(chatViewmodelProvider.notifier).applyAutoFix(action);
},
child: Text(label),
);
}
}
class DashbotGenerateLanguagePicker extends ConsumerWidget class DashbotGenerateLanguagePicker extends ConsumerWidget
with DashbotActionMixin { with DashbotActionMixin {
@override @override
@@ -177,6 +205,8 @@ class DashbotActionWidgetFactory {
return DashbotGenerateLanguagePicker(action: action); return DashbotGenerateLanguagePicker(action: action);
} }
break; break;
case ChatActionType.applyCurl:
return DashbotApplyCurlButton(action: action);
case ChatActionType.updateField: case ChatActionType.updateField:
case ChatActionType.addHeader: case ChatActionType.addHeader:
case ChatActionType.updateHeader: case ChatActionType.updateHeader:
@@ -203,6 +233,9 @@ class DashbotActionWidgetFactory {
if (action.action == 'show_languages' && action.target == 'codegen') { if (action.action == 'show_languages' && action.target == 'codegen') {
return DashbotGenerateLanguagePicker(action: action); return DashbotGenerateLanguagePicker(action: action);
} }
if (action.action == 'apply_curl') {
return DashbotApplyCurlButton(action: action);
}
if (action.action.contains('update') || if (action.action.contains('update') ||
action.action.contains('add') || action.action.contains('add') ||
action.action.contains('delete')) { action.action.contains('delete')) {

View File

@@ -0,0 +1,129 @@
import 'dart:convert';
import 'package:curl_parser/curl_parser.dart';
/// Service to parse cURL commands and produce
/// a standard action message map understood by Dashbot.
class CurlImportService {
/// Attempts to parse a cURL string.
/// Returns null if parsing fails.
static Curl? tryParseCurl(String input) {
return Curl.tryParse(input);
}
/// Convert a parsed Curl into a payload used by Dashbot auto-fix action.
static Map<String, dynamic> buildActionPayloadFromCurl(Curl curl) {
final headers =
Map<String, String>.from(curl.headers ?? <String, String>{});
bool hasHeader(String key) =>
headers.keys.any((k) => k.toLowerCase() == key.toLowerCase());
void setIfMissing(String key, String? value) {
if (value == null || value.isEmpty) return;
if (!hasHeader(key)) headers[key] = value;
}
// Map cookie to Cookie header if not present
setIfMissing('Cookie', curl.cookie);
// Map user agent and referer to headers if not present
setIfMissing('User-Agent', curl.userAgent);
setIfMissing('Referer', curl.referer);
// Map -u user:password to Authorization: Basic ... if not already present
if (!hasHeader('Authorization') && (curl.user?.isNotEmpty ?? false)) {
final basic = base64.encode(utf8.encode(curl.user!));
headers['Authorization'] = 'Basic $basic';
}
return {
'method': curl.method,
'url': curl.uri.toString(),
'headers': headers,
'body': curl.data,
'form': curl.form,
'formData': curl.formData
?.map((f) => {
'name': f.name,
'value': f.value,
'type': f.type.name,
})
.toList(),
};
}
/// Build the message object with two actions: apply to selected or new.
static Map<String, dynamic> buildActionMessageFromPayload(
Map<String, dynamic> actionPayload,
{String? note}) {
final explanation = StringBuffer(
'Parsed the cURL command. Where do you want to apply the changes? Choose one of the options below.');
if (note != null && note.isNotEmpty) {
explanation.writeln('');
explanation.write('Note: $note');
}
return {
'explnation': explanation.toString(),
'actions': [
{
'action': 'apply_curl',
'target': 'httpRequestModel',
'field': 'apply_to_selected',
'path': null,
'value': actionPayload,
},
{
'action': 'apply_curl',
'target': 'httpRequestModel',
'field': 'apply_to_new',
'path': null,
'value': actionPayload,
}
]
};
}
/// Convenience: from parsed [Curl] to (json, actions list).
static ({String jsonMessage, List<Map<String, dynamic>> actions})
buildResponseFromParsed(Curl curl) {
final payload = buildActionPayloadFromCurl(curl);
// Build a small note for flags that are not represented in the request model
final notes = <String>[];
// if (curl.insecure) notes.add('insecure (-k) is not applied automatically');
// if (curl.location) {
// notes.add('follow redirects (-L) is not applied automatically');
// }
final msg = buildActionMessageFromPayload(
payload,
note: notes.isEmpty ? null : notes.join('; '),
);
final actions =
(msg['actions'] as List).whereType<Map<String, dynamic>>().toList();
return (jsonMessage: jsonEncode(msg), actions: actions);
}
/// High-level helper to process a pasted cURL string.
/// Returns either a built (json, actions) tuple or an error message.
static ({
String? error,
String? jsonMessage,
List<Map<String, dynamic>>? actions
}) processPastedCurl(String input) {
try {
final curl = tryParseCurl(input);
if (curl == null) {
return (
error:
'Sorry, I could not parse that cURL. Ensure it starts with `curl ` and is complete.',
jsonMessage: null,
actions: null
);
}
final built = buildResponseFromParsed(curl);
return (
error: null,
jsonMessage: built.jsonMessage,
actions: built.actions
);
} catch (e) {
final safe = e.toString().replaceAll('"', "'");
return (error: 'Parsing failed: $safe', jsonMessage: null, actions: null);
}
}
}

View File

@@ -91,6 +91,7 @@ enum ChatMessageType {
generateTest, generateTest,
generateDoc, generateDoc,
generateCode, generateCode,
importCurl,
general general
} }
@@ -104,6 +105,7 @@ enum ChatActionType {
updateUrl, updateUrl,
updateMethod, updateMethod,
showLanguages, showLanguages,
applyCurl,
other, other,
noAction, noAction,
uploadAsset, uploadAsset,
@@ -135,6 +137,8 @@ ChatActionType _chatActionTypeFromString(String s) {
return ChatActionType.updateMethod; return ChatActionType.updateMethod;
case 'show_languages': case 'show_languages':
return ChatActionType.showLanguages; return ChatActionType.showLanguages;
case 'apply_curl':
return ChatActionType.applyCurl;
case 'upload_asset': case 'upload_asset':
return ChatActionType.uploadAsset; return ChatActionType.uploadAsset;
case 'no_action': case 'no_action':
@@ -164,6 +168,8 @@ String chatActionTypeToString(ChatActionType t) {
return 'update_method'; return 'update_method';
case ChatActionType.showLanguages: case ChatActionType.showLanguages:
return 'show_languages'; return 'show_languages';
case ChatActionType.applyCurl:
return 'apply_curl';
case ChatActionType.other: case ChatActionType.other:
return 'other'; return 'other';
case ChatActionType.noAction: case ChatActionType.noAction:

View File

@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:apidash_core/apidash_core.dart'; import 'package:apidash_core/apidash_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:apidash/providers/providers.dart'; import 'package:apidash/providers/providers.dart';
import 'package:apidash/models/models.dart'; import 'package:apidash/models/models.dart';
import 'package:nanoid/nanoid.dart'; import 'package:nanoid/nanoid.dart';
import '../../../core/services/curl_import_service.dart';
import '../../../core/utils/safe_parse_json_message.dart'; import '../../../core/utils/safe_parse_json_message.dart';
import '../../../core/constants/dashbot_prompts.dart' as dash; import '../../../core/constants/dashbot_prompts.dart' as dash;
@@ -47,7 +49,7 @@ class ChatViewmodel extends StateNotifier<ChatState> {
'[Chat] sendMessage start: type=$type, countAsUser=$countAsUser'); '[Chat] sendMessage start: type=$type, countAsUser=$countAsUser');
final ai = _selectedAIModel; final ai = _selectedAIModel;
if (text.trim().isEmpty && countAsUser) return; if (text.trim().isEmpty && countAsUser) return;
if (ai == null) { if (ai == null && type != ChatMessageType.importCurl) {
debugPrint('[Chat] No AI model configured'); debugPrint('[Chat] No AI model configured');
_appendSystem( _appendSystem(
'AI model is not configured. Please set one.', 'AI model is not configured. Please set one.',
@@ -57,6 +59,7 @@ class ChatViewmodel extends StateNotifier<ChatState> {
} }
final requestId = _currentRequest?.id ?? 'global'; final requestId = _currentRequest?.id ?? 'global';
final existingMessages = state.chatSessions[requestId] ?? const [];
debugPrint('[Chat] using requestId=$requestId'); debugPrint('[Chat] using requestId=$requestId');
if (countAsUser) { if (countAsUser) {
@@ -72,25 +75,54 @@ class ChatViewmodel extends StateNotifier<ChatState> {
); );
} }
// Special handling: generateCode flow has two steps // If user pasted a cURL in import flow, handle locally without AI
String? systemPrompt; final lastSystemImport = existingMessages.lastWhere(
(m) =>
m.role == MessageRole.system &&
m.messageType == ChatMessageType.importCurl,
orElse: () => ChatMessage(
id: '',
content: '',
role: MessageRole.system,
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
),
);
final importFlowActive = lastSystemImport.id.isNotEmpty;
if (text.trim().startsWith('curl ') &&
(type == ChatMessageType.importCurl || importFlowActive)) {
await handlePotentialCurlPaste(text);
return;
}
String systemPrompt;
if (type == ChatMessageType.generateCode) { if (type == ChatMessageType.generateCode) {
// If the user message includes a language (heuristic), go straight to code gen; else show intro + language choices
final detectedLang = _detectLanguageFromText(text); final detectedLang = _detectLanguageFromText(text);
systemPrompt = _composeSystemPrompt( systemPrompt = _composeSystemPrompt(
_currentRequest, _currentRequest,
detectedLang == null type,
? ChatMessageType.generateCode
: ChatMessageType.generateCode,
overrideLanguage: detectedLang, overrideLanguage: detectedLang,
); );
} else if (type == ChatMessageType.importCurl) {
final rqId = _currentRequest?.id ?? 'global';
_addMessage(
rqId,
ChatMessage(
id: nanoid(),
content:
'{"explnation":"Let\'s import a cURL request. Paste your complete cURL command below.","actions":[]}',
role: MessageRole.system,
timestamp: DateTime.now(),
messageType: ChatMessageType.importCurl,
),
);
return;
} else { } else {
systemPrompt = _composeSystemPrompt(_currentRequest, type); systemPrompt = _composeSystemPrompt(_currentRequest, type);
} }
final userPrompt = (text.trim().isEmpty && !countAsUser) final userPrompt = (text.trim().isEmpty && !countAsUser)
? 'Please complete the task based on the provided context.' ? 'Please complete the task based on the provided context.'
: text; : text;
final enriched = ai.copyWith( final enriched = ai!.copyWith(
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
userPrompt: userPrompt, userPrompt: userPrompt,
stream: true, stream: true,
@@ -240,6 +272,9 @@ class ChatViewmodel extends StateNotifier<ChatState> {
case ChatActionType.updateMethod: case ChatActionType.updateMethod:
await _applyMethodUpdate(action); await _applyMethodUpdate(action);
break; break;
case ChatActionType.applyCurl:
await _applyCurl(action);
break;
case ChatActionType.other: case ChatActionType.other:
await _applyOtherAction(action); await _applyOtherAction(action);
break; break;
@@ -375,6 +410,14 @@ class ChatViewmodel extends StateNotifier<ChatState> {
case 'test': case 'test':
await _applyTestToPostScript(action); await _applyTestToPostScript(action);
break; break;
case 'httpRequestModel':
if (action.actionType == ChatActionType.applyCurl) {
await _applyCurl(action);
break;
}
// Unsupported other action
debugPrint('[Chat] Unsupported other action target: ${action.target}');
break;
default: default:
debugPrint('[Chat] Unsupported other action target: ${action.target}'); debugPrint('[Chat] Unsupported other action target: ${action.target}');
} }
@@ -386,14 +429,9 @@ class ChatViewmodel extends StateNotifier<ChatState> {
final collectionNotifier = final collectionNotifier =
_ref.read(collectionStateNotifierProvider.notifier); _ref.read(collectionStateNotifierProvider.notifier);
final testCode = action.value as String; final testCode = action.value is String ? action.value as String : '';
final currentPostScript = _currentRequest?.postRequestScript ?? '';
// Get the current post-request script (if any) final newPostScript = currentPostScript.trim().isEmpty
final currentRequest = _currentRequest;
final currentPostScript = currentRequest?.postRequestScript ?? '';
// Append the test code to the existing post-request script
final newPostScript = currentPostScript.isEmpty
? testCode ? testCode
: '$currentPostScript\n\n// Generated Test\n$testCode'; : '$currentPostScript\n\n// Generated Test\n$testCode';
@@ -405,6 +443,131 @@ class ChatViewmodel extends StateNotifier<ChatState> {
ChatMessageType.generateTest); ChatMessageType.generateTest);
} }
// Parse a pasted cURL and present actions to apply to current or new request
Future<void> handlePotentialCurlPaste(String text) async {
// quick check
final trimmed = text.trim();
if (!trimmed.startsWith('curl ')) return;
try {
debugPrint('[cURL] Original: $trimmed');
final curl = CurlImportService.tryParseCurl(trimmed);
if (curl == null) {
_appendSystem(
'{"explnation":"Sorry, I couldn\'t parse that cURL command. Please verify it starts with `curl ` and is complete.","actions":[]}',
ChatMessageType.importCurl);
return;
}
final built = CurlImportService.buildResponseFromParsed(curl);
final msg = jsonDecode(built.jsonMessage) as Map<String, dynamic>;
final rqId = _currentRequest?.id ?? 'global';
_addMessage(
rqId,
ChatMessage(
id: nanoid(),
content: jsonEncode(msg),
role: MessageRole.system,
timestamp: DateTime.now(),
messageType: ChatMessageType.importCurl,
actions: (msg['actions'] as List)
.whereType<Map<String, dynamic>>()
.map(ChatAction.fromJson)
.toList(),
),
);
} catch (e) {
debugPrint('[cURL] Exception: $e');
final safe = e.toString().replaceAll('"', "'");
_appendSystem('{"explnation":"Parsing failed: $safe","actions":[]}',
ChatMessageType.importCurl);
}
}
Future<void> _applyCurl(ChatAction action) async {
final requestId = _currentRequest?.id;
final collection = _ref.read(collectionStateNotifierProvider.notifier);
final payload = action.value is Map<String, dynamic>
? (action.value as Map<String, dynamic>)
: <String, dynamic>{};
String methodStr = (payload['method'] as String?)?.toLowerCase() ?? 'get';
final method = HTTPVerb.values.firstWhere(
(m) => m.name == methodStr,
orElse: () => HTTPVerb.get,
);
final url = payload['url'] as String? ?? '';
final headersMap =
(payload['headers'] as Map?)?.cast<String, dynamic>() ?? {};
final headers = headersMap.entries
.map((e) => NameValueModel(name: e.key, value: e.value.toString()))
.toList();
final body = payload['body'] as String?;
final formFlag = payload['form'] == true;
final formDataListRaw = (payload['formData'] as List?)?.cast<dynamic>();
final formData = formDataListRaw == null
? <FormDataModel>[]
: formDataListRaw
.whereType<Map>()
.map((e) => FormDataModel(
name: (e['name'] as String?) ?? '',
value: (e['value'] as String?) ?? '',
type: (() {
final t = (e['type'] as String?) ?? 'text';
try {
return FormDataType.values
.firstWhere((ft) => ft.name == t);
} catch (_) {
return FormDataType.text;
}
})(),
))
.toList();
ContentType bodyContentType;
if (formFlag || formData.isNotEmpty) {
bodyContentType = ContentType.formdata;
} else if ((body ?? '').trim().isEmpty) {
bodyContentType = ContentType.text;
} else {
// Heuristic JSON detection
try {
jsonDecode(body!);
bodyContentType = ContentType.json;
} catch (_) {
bodyContentType = ContentType.text;
}
}
if (action.field == 'apply_to_selected') {
if (requestId == null) return;
collection.update(
method: method,
url: url,
headers: headers,
isHeaderEnabledList: List<bool>.filled(headers.length, true),
body: body,
bodyContentType: bodyContentType,
formData: formData.isEmpty ? null : formData,
);
_appendSystem(
'Applied cURL to the selected request.', ChatMessageType.importCurl);
} else if (action.field == 'apply_to_new') {
final model = HttpRequestModel(
method: method,
url: url,
headers: headers,
isHeaderEnabledList: List<bool>.filled(headers.length, true),
body: body,
bodyContentType: bodyContentType,
formData: formData.isEmpty ? null : formData,
);
collection.addRequestModel(model, name: 'Imported cURL');
_appendSystem(
'Created a new request from the cURL.', ChatMessageType.importCurl);
}
}
// Helpers // Helpers
void _addMessage(String requestId, ChatMessage m) { void _addMessage(String requestId, ChatMessage m) {
debugPrint( debugPrint(
@@ -553,6 +716,9 @@ class ChatViewmodel extends StateNotifier<ChatState> {
language: overrideLanguage, language: overrideLanguage,
); );
} }
case ChatMessageType.importCurl:
// No AI prompt needed; handled locally.
return null;
case ChatMessageType.general: case ChatMessageType.general:
return prompts.generalInteractionPrompt(); return prompts.generalInteractionPrompt();
} }

View File

@@ -58,7 +58,16 @@ class _DashbotHomePageState extends ConsumerState<DashbotHomePage> {
}, },
), ),
HomeScreenTaskButton( HomeScreenTaskButton(
label: "🔎 Explain me this response", label: "📥 Import cURL",
onPressed: () {
Navigator.of(context).pushNamed(
DashbotRoutes.dashbotChat,
arguments: ChatMessageType.importCurl,
);
},
),
HomeScreenTaskButton(
label: "<EFBFBD>🔎 Explain me this response",
onPressed: () { onPressed: () {
Navigator.of(context).pushNamed( Navigator.of(context).pushNamed(
DashbotRoutes.dashbotChat, DashbotRoutes.dashbotChat,

View File

@@ -375,7 +375,7 @@ packages:
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
curl_parser: curl_parser:
dependency: transitive dependency: "direct main"
description: description:
path: "packages/curl_parser" path: "packages/curl_parser"
relative: true relative: true

View File

@@ -44,6 +44,8 @@ dependencies:
path: packages/json_explorer path: packages/json_explorer
json_field_editor: json_field_editor:
path: packages/json_field_editor path: packages/json_field_editor
curl_parser:
path: packages/curl_parser
just_audio: ^0.9.46 just_audio: ^0.9.46
just_audio_mpv: ^0.1.7 just_audio_mpv: ^0.1.7
just_audio_windows: ^0.2.0 just_audio_windows: ^0.2.0