diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index 49834ecc..5e4b6d98 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -194,11 +194,17 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) { let value: string | boolean; const nextEntry = parseEntries[i + 1]; const hasValue = !BOOLEAN_FLAGS.includes(name); + // Check if nextEntry looks like a flag: + // - Single dash followed by a letter: -X, -H, -d + // - Double dash followed by a letter: --data-raw, --header + // This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags + const nextEntryIsFlag = typeof nextEntry === 'string' && + (nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/)); if (isSingleDash && name.length > 1) { // Handle squished arguments like -XPOST value = name.slice(1); name = name.slice(0, 1); - } else if (typeof nextEntry === 'string' && hasValue && !nextEntry.startsWith('-')) { + } else if (typeof nextEntry === 'string' && hasValue && !nextEntryIsFlag) { // Next arg is not a flag, so assign it as the value value = nextEntry; i++; // Skip next one @@ -305,11 +311,32 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) { } // Body (Text or Blob) - const dataParameters = pairsToDataParameters(flagsByName); const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type'); - const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0] : null; + const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0]?.trim() : null; - // Body (Multipart Form Data) + // Extract boundary from Content-Type header for multipart parsing + const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\s;]+)/i); + const boundary = boundaryMatch?.[1]; + + // Get raw data from --data-raw flags (before splitting by &) + const rawDataValues = [ + ...((flagsByName['data-raw'] as string[] | undefined) || []), + ...((flagsByName.d as string[] | undefined) || []), + ...((flagsByName.data as string[] | undefined) || []), + ...((flagsByName['data-binary'] as string[] | undefined) || []), + ...((flagsByName['data-ascii'] as string[] | undefined) || []), + ]; + + // Check if this is multipart form data in --data-raw (Chrome DevTools format) + let multipartFormDataFromRaw: { name: string; value?: string; file?: string; enabled: boolean }[] | null = null; + if (mimeType === 'multipart/form-data' && boundary && rawDataValues.length > 0) { + const rawBody = rawDataValues.join(''); + multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary); + } + + const dataParameters = pairsToDataParameters(flagsByName); + + // Body (Multipart Form Data from -F flags) const formDataParams = [ ...((flagsByName.form as string[] | undefined) || []), ...((flagsByName.F as string[] | undefined) || []), @@ -336,7 +363,13 @@ function importCommand(parseEntries: ParseEntry[], workspaceId: string) { let bodyType: string | null = null; const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']); - if (dataParameters.length > 0 && bodyAsGET) { + if (multipartFormDataFromRaw) { + // Handle multipart form data parsed from --data-raw (Chrome DevTools format) + bodyType = 'multipart/form-data'; + body = { + form: multipartFormDataFromRaw, + }; + } else if (dataParameters.length > 0 && bodyAsGET) { urlParameters.push(...dataParameters); } else if ( dataParameters.length > 0 && @@ -473,6 +506,71 @@ function splitOnce(str: string, sep: string): string[] { return [str]; } +/** + * Parses multipart form data from a raw body string + * Used when Chrome DevTools exports a cURL with --data-raw containing multipart data + */ +function parseMultipartFormData( + rawBody: string, + boundary: string, +): { name: string; value?: string; file?: string; enabled: boolean }[] | null { + const results: { name: string; value?: string; file?: string; enabled: boolean }[] = []; + + // The boundary in the body typically has -- prefix + const boundaryMarker = `--${boundary}`; + const parts = rawBody.split(boundaryMarker); + + for (const part of parts) { + // Skip empty parts and the closing boundary marker + if (!part || part.trim() === '--' || part.trim() === '--\r\n') { + continue; + } + + // Each part has headers and content separated by \r\n\r\n + const headerContentSplit = part.indexOf('\r\n\r\n'); + if (headerContentSplit === -1) { + continue; + } + + const headerSection = part.slice(0, headerContentSplit); + let content = part.slice(headerContentSplit + 4); // Skip \r\n\r\n + + // Remove trailing \r\n from content + if (content.endsWith('\r\n')) { + content = content.slice(0, -2); + } + + // Parse Content-Disposition header to get name and filename + const contentDispositionMatch = headerSection.match( + /Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?/i, + ); + + if (!contentDispositionMatch) { + continue; + } + + const name = contentDispositionMatch[1] ?? ''; + const filename = contentDispositionMatch[2]; + + const item: { name: string; value?: string; file?: string; enabled: boolean } = { + name, + enabled: true, + }; + + if (filename) { + // This is a file upload field + item.file = filename; + } else { + // This is a regular text field + item.value = content; + } + + results.push(item); + } + + return results.length > 0 ? results : null; +} + const idCount: Partial> = {}; function generateId(model: string): string { diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index 71e8cac1..6f75b8e4 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -441,6 +441,72 @@ describe('importer-curl', () => { }, }); }); + + test('Imports multipart form data from --data-raw (Chrome DevTools format)', () => { + // This is the format Chrome DevTools uses when copying a multipart form submission as cURL + const curlCommand = `curl 'http://localhost:8080/system' \ + -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd' \ + --data-raw $'------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="username"\r\n\r\njsgj\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="password"\r\n\r\n654321\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\r\nContent-Disposition: form-data; name="captcha"; filename="test.xlsx"\r\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n\r\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd--\r\n'`; + + expect(convertCurl(curlCommand)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + url: 'http://localhost:8080/system', + method: 'POST', + headers: [ + { + name: 'Content-Type', + value: 'multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd', + enabled: true, + }, + ], + bodyType: 'multipart/form-data', + body: { + form: [ + { name: 'username', value: 'jsgj', enabled: true }, + { name: 'password', value: '654321', enabled: true }, + { name: 'captcha', file: 'test.xlsx', enabled: true }, + ], + }, + }), + ], + }, + }); + }); + + test('Imports multipart form data with text-only fields from --data-raw', () => { + const curlCommand = `curl 'http://example.com/api' \ + -H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \ + --data-raw $'------FormBoundary123\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------FormBoundary123\r\nContent-Disposition: form-data; name="field2"\r\n\r\nvalue2\r\n------FormBoundary123--\r\n'`; + + expect(convertCurl(curlCommand)).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + url: 'http://example.com/api', + method: 'POST', + headers: [ + { + name: 'Content-Type', + value: 'multipart/form-data; boundary=----FormBoundary123', + enabled: true, + }, + ], + bodyType: 'multipart/form-data', + body: { + form: [ + { name: 'field1', value: 'value1', enabled: true }, + { name: 'field2', value: 'value2', enabled: true }, + ], + }, + }), + ], + }, + }); + }); }); const idCount: Partial> = {};