cURL: Support all --data flags and fix parsing of key=value pairs (#5818)

* test(curl): data-urlencode support key=value pairs

Updates tests to support key=value pairs in urlencode

Issue: https://github.com/Kong/insomnia/issues/5696

* fix(curl): data-urlencode support key=value pairs

Splits out the logic to handle the cases for the --data-urlencode flag from cURL.

docs: https://curl.se/docs/manpage.html#--data-urlencode
Issue: https://github.com/Kong/insomnia/issues/5696

* feat(cURL): support all data flags

This refactors the current implementation for handling --data[suffix] flags from cURL supporting all possible formats outside of reading files into Insomnia.

docs: https://curl.se/docs/manpage.html

Issues: https://github.com/Kong/insomnia/issues/5696, https://github.com/Kong/insomnia/issues/5400

* test(cURL): support all data flags

Combines a lot of tests and covers all cases provided in the cURL man pages.

* style: lint
This commit is contained in:
Nick Hackman 2023-03-31 05:53:32 -05:00 committed by GitHub
parent 29acc92559
commit bed21be8af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 255 additions and 79 deletions

View File

@ -1,58 +1,156 @@
import { describe, expect, it } from '@jest/globals';
import { quote } from 'shell-quote';
import { Parameter } from '../entities';
import { convert } from './curl';
describe('curl', () => {
const url = 'http://example.com';
const method = '-X POST';
const mimeType = 'application/x-www-form-urlencoded';
const header = `-H 'Content-Type: ${mimeType}'`;
describe('encoding', () => {
describe('cURL --data flags', () => {
it.each([
{ input: ' ', expected: ' ' },
{ input: 'a=', expected: 'a' }, // using `a` before `=` to disambiguate shell parameters
{ input: '<', expected: '<' },
{ input: '>', expected: '>' },
{ input: '[', expected: '[' },
{ input: ']', expected: ']' },
{ input: '{', expected: '{' },
{ input: '}', expected: '}' },
{ input: '|', expected: '|' },
{ input: '^', expected: '^' },
{ input: '%3d', expected: '=' },
{ input: '"', expected: '"' },
])('encodes %p correctly', ({ input, expected }: { input: string; expected: string }) => {
const quoted = quote([input]);
const rawData = `curl ${method} ${url} ${header} --data ${quoted} --data-urlencode ${quoted}`;
// -d
{ flag: '-d', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{ flag: '-d', inputs: ['value'], expected: [{ name: '', value: 'value' }] },
{ flag: '-d', inputs: ['@filename'], expected: [{ name: '', fileName: 'filename', type: 'file' }] },
{
flag: '-d',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '-d',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
{ flag: '-d', inputs: ['%3D'], expected: [{ name: '', value: '=' }] },
{ flag: '--d', inputs: ['%3D=%3D'], expected: [{ name: '=', value: '=' }] },
// --data
{ flag: '--data', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{ flag: '--data', inputs: ['value'], expected: [{ name: '', value: 'value' }] },
{ flag: '--data', inputs: ['@filename'], expected: [{ name: '', fileName: 'filename' }] },
{
flag: '--data',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '--data',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
{ flag: '--data', inputs: ['%3D'], expected: [{ name: '', value: '=' }] },
{ flag: '--data', inputs: ['%3D=%3D'], expected: [{ name: '=', value: '=' }] },
// --data-ascii
{ flag: '--data-ascii', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{ flag: '--data-ascii', inputs: ['value'], expected: [{ name: '', value: 'value' }] },
{ flag: '--data-ascii', inputs: ['@filename'], expected: [{ name: '', fileName: 'filename', type: 'file' }] },
{
flag: '--data-ascii',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '--data-ascii',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
// --data-binary
{ flag: '--data-binary', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{ flag: '--data-binary', inputs: ['value'], expected: [{ name: '', value: 'value' }] },
{ flag: '--data-binary', inputs: ['@filename'], expected: [{ name: '', fileName: 'filename', type: 'file' }] },
{
flag: '--data-binary',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '--data-binary',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
// --data-raw
{ flag: '--data-raw', inputs: ['@filename'], expected: [{ name: '', value: '@filename' }] },
{ flag: '--data-raw', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{
flag: '--data-raw',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '--data-raw',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
// --data-urlencode
{ flag: '--data-urlencode', inputs: ['key=value'], expected: [{ name: 'key', value: 'value' }] },
{
flag: '--data-urlencode',
inputs: ['key@filename'],
expected: [{ name: 'key', fileName: 'filename', type: 'file' }],
},
{
flag: '--data-urlencode',
inputs: ['first=1', 'second=2', 'third'],
expected: [{ name: 'first', value: '1' }, {
name: 'second',
value: '2',
}, { name: '', value: 'third' }],
},
{
flag: '--data-urlencode',
inputs: ['first=1&second=2'],
expected: [{ name: 'first', value: '1' }, { name: 'second', value: '2' }],
},
{ flag: '--data-urlencode', inputs: ['=value'], expected: [{ name: '', value: 'value' }] },
// --data-urlencode URI encoding
{ flag: '--data-urlencode', inputs: ['a='], expected: [{ name: '', value: 'a=' }] },
{ flag: '--data-urlencode', inputs: [' '], expected: [{ name: '', value: ' ' }] },
{ flag: '--data-urlencode', inputs: ['<'], expected: [{ name: '', value: '<' }] },
{ flag: '--data-urlencode', inputs: ['>'], expected: [{ name: '', value: '>' }] },
{ flag: '--data-urlencode', inputs: ['?'], expected: [{ name: '', value: '?' }] },
{ flag: '--data-urlencode', inputs: ['['], expected: [{ name: '', value: '[' }] },
{ flag: '--data-urlencode', inputs: [']'], expected: [{ name: '', value: ']' }] },
{ flag: '--data-urlencode', inputs: ['|'], expected: [{ name: '', value: '|' }] },
{ flag: '--data-urlencode', inputs: ['^'], expected: [{ name: '', value: '^' }] },
{ flag: '--data-urlencode', inputs: ['"'], expected: [{ name: '', value: '"' }] },
{ flag: '--data-urlencode', inputs: ['='], expected: [{ name: '', value: '=' }] },
{ flag: '--data-urlencode', inputs: ['%3D'], expected: [{ name: '', value: '%3D' }] },
])('handles %p correctly', async ({
flag,
inputs,
expected,
}: { flag: string; inputs: string[]; expected: Parameter[] }) => {
const flaggedInputs = inputs.map(input => `${flag} ${quote([input])}`).join(' ');
const rawData = `curl -X POST https://example.com
-H 'Content-Type: application/x-www-form-urlencoded'
${flaggedInputs}
`;
expect(convert(rawData)).toMatchObject([{
body: {
params: [
{ name: expected, value: '' },
{ name: input, value: '' },
],
params: expected,
},
}]);
});
it('handles & correctly', () => {
const rawData = `curl ${method} ${url} ${header} --data a=1\\&b=2 --data-urlencode c=3\\&d=4`;
expect(convert(rawData)).toMatchObject([{
body: {
params: [
{ name: 'a', value: '1' },
{ name: 'b', value: '2' },
{ name: 'c=3&d=4', value: '' },
],
},
}]);
});
it('throws on invalid url encoding', () => {
const rawData = `curl ${method} ${url} ${header} --data %`;
expect(() => convert(rawData)).toThrow('URI malformed');
});
});
});

View File

@ -158,26 +158,7 @@ const importCommand = (parseEntries: ParseEntry[]): ImportRequest => {
}
/// /////// Body (Text or Blob) //////////
let textBodyParams: Pair[] = [];
const paramNames = [
'd',
'data',
'data-raw',
'data-urlencode',
'data-binary',
'data-ascii',
];
for (const paramName of paramNames) {
const pair = pairsByName[paramName];
if (pair && pair.length) {
textBodyParams = textBodyParams.concat(paramName === 'data-urlencode' ? pair.map(item => encodeURIComponent(item)) : pair);
}
}
// join params to make body
const textBody = textBodyParams.join('&');
const dataParameters = pairsToDataParameters(pairsByName);
const contentTypeHeader = headers.find(
header => header.name.toLowerCase() === 'content-type',
);
@ -210,25 +191,18 @@ const importCommand = (parseEntries: ParseEntry[]): ImportRequest => {
const body: PostData = mimeType ? { mimeType } : {};
const bodyAsGET = getPairValue(pairsByName, false, ['G', 'get']);
if (textBody && bodyAsGET) {
const bodyParams = textBody.split('&').map(v => {
const [name, value] = v.split('=');
if (dataParameters.length !== 0 && bodyAsGET) {
parameters.push(...dataParameters);
} else if (dataParameters && mimeType === 'application/x-www-form-urlencoded') {
body.params = dataParameters.map(parameter => {
return {
name: name || '',
value: value || '',
...parameter,
name: decodeURIComponent(parameter.name || ''),
value: decodeURIComponent(parameter.value || ''),
};
});
parameters.push(...bodyParams);
} else if (textBody && mimeType === 'application/x-www-form-urlencoded') {
body.params = textBody.split('&').map(v => {
const [name, value] = v.split('=');
return {
name: decodeURIComponent(name || ''),
value: decodeURIComponent(value || ''),
};
});
} else if (textBody) {
body.text = textBody;
} else if (dataParameters.length !== 0) {
body.text = dataParameters.map(parameter => `${parameter.name}${parameter.value}`).join('&');
body.mimeType = mimeType || '';
} else if (formDataParams.length) {
body.params = formDataParams;
@ -260,6 +234,110 @@ const importCommand = (parseEntries: ParseEntry[]): ImportRequest => {
};
};
/**
* cURL supported -d, and --date[suffix] flags.
*/
const dataFlags = [
/**
* https://curl.se/docs/manpage.html#-d
*/
'd',
'data',
/**
* https://curl.se/docs/manpage.html#--data-raw
*/
'data-raw',
/**
* https://curl.se/docs/manpage.html#--data-urlencode
*/
'data-urlencode',
/**
* https://curl.se/docs/manpage.html#--data-binary
*/
'data-binary',
/**
* https://curl.se/docs/manpage.html#--data-ascii
*/
'data-ascii',
];
/**
* Parses pairs supporting only flags dictated by {@link dataFlags}
*
* @param keyedPairs pairs with cURL flags as keys.
*/
const pairsToDataParameters = (keyedPairs: PairsByName): Parameter[] => {
let dataParameters: Parameter[] = [];
for (const flagName of dataFlags) {
const pairs = keyedPairs[flagName];
if (!pairs || pairs.length === 0) {
continue;
}
switch (flagName) {
case 'd':
case 'data':
case 'data-ascii':
case 'data-binary':
dataParameters = dataParameters.concat(pairs.flatMap(pair => pairToParameters(pair, true)));
break;
case 'data-raw':
dataParameters = dataParameters.concat(pairs.flatMap(pair => pairToParameters(pair)));
break;
case 'data-urlencode':
dataParameters = dataParameters.concat(pairs.flatMap(pair => pairToParameters(pair, true))
.map(parameter => {
if (parameter.type === 'file') {
return parameter;
}
return {
...parameter,
value: encodeURIComponent(parameter.value ?? ''),
};
}));
break;
default:
throw new Error(`unhandled data flag ${flagName}`);
}
}
return dataParameters;
};
/**
* Converts pairs (that could include multiple via `&`) into {@link Parameter}s. This
* method supports both `@filename` and `name@filename`.
*
* @param pair command line value
* @param allowFiles whether to allow the `@` to support include files
*/
const pairToParameters = (pair: Pair, allowFiles = false): Parameter[] => {
if (typeof pair === 'boolean') {
return [{ name: '', value: pair.toString() }];
}
return pair.split('&').map(pair => {
if (pair.includes('@') && allowFiles) {
const [name, fileName] = pair.split('@');
return { name, fileName, type: 'file' };
}
const [name, value] = pair.split('=');
if (!value || !pair.includes('=')) {
return { name: '', value: pair };
}
return { name, value };
});
};
const getPairValue = <T extends string | boolean>(
parisByName: PairsByName,
defaultValue: T,