Skip to content

Commit 354e0cf

Browse files
Merge pull request #17 from koul1sh/zz-formdata
feat: added support for multipart formdata
2 parents 2e510bb + 3857e82 commit 354e0cf

File tree

13 files changed

+256
-16
lines changed

13 files changed

+256
-16
lines changed

docs/zzapi-bundle-description.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ requests:
7272
* `headers`: a set of headers, this will be in in addition to the common set, or overridden if the name is the same
7373
* `params`: a set of parameter values, or overridden if the name is the same
7474
* `body`: the raw request body. (Use the value `file://<filename>` to read from a file), or an object, which will be converted to a JSON string after variable replacements.
75+
* `formValues`: An object representing form fields to send in the request body. If no content-type is given and:
76+
1. if none of the fields contain a file (specified as `file://<filename>`), the request will be sent as `application/x-www-form-urlencoded`.
77+
2. if at least one field contains a file, the request will be sent as `multipart/form-data`.
7578
* `response*`: a set of sample responses, useful for documentation (doesn't affect the request)
7679
* `headers`: the headers expected in the response
7780
* `body`: the raw response body, or a JSON object. Use `<filename` to read from a file.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
"typescript": "^5.3.3"
4040
},
4141
"dependencies": {
42+
"form-data-encoder": "^4.0.2",
43+
"formdata-node": "^6.0.3",
4244
"got": "11.8.6",
4345
"jsonpath": "^1.1.1",
4446
"yaml": "^2.3.4"

schemas/zzapi-bundle.schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,16 @@
257257
"description": "The request body",
258258
"type": ["string", "object", "array"]
259259
},
260+
"formValues":{
261+
"description": "Put form values here.",
262+
"anyOf":[
263+
{
264+
"type": "array",
265+
"items": { "$ref": "#/$defs/headerArray" }
266+
},
267+
{ "$ref": "#/$defs/headerObject" }
268+
]
269+
},
260270
"options": { "$ref": "#/$defs/options" },
261271
"tests": { "$ref": "#/$defs/tests" },
262272
"capture": {

src/checkTypes.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ function checkKey(
55
item: string,
66
key: string,
77
expectedTypes: string[],
8-
optional: boolean,
8+
optional: boolean
99
): string | undefined {
1010
if (!optional && !obj.hasOwnProperty(key)) {
1111
return `${key} key must be present in each ${item} item`;
@@ -217,6 +217,18 @@ export function validateRawRequest(obj: any): string | undefined {
217217
}
218218
}
219219

220+
if (obj.hasOwnProperty("formValues") && obj.hasOwnProperty("body")) {
221+
return `both body and formValues can't be present in the same request.`;
222+
}
223+
224+
if (obj.hasOwnProperty("method") && obj["method"] == "GET" && obj.hasOwnProperty("formValues")) {
225+
return `formValues can't be used with method GET`;
226+
}
227+
228+
if (obj.hasOwnProperty("method") && obj["method"] == "GET" && obj.hasOwnProperty("body")) {
229+
return `body can't be used with method GET`;
230+
}
231+
220232
return undefined;
221233
}
222234

src/constructCurl.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,73 @@
1-
import { getStringValueIfDefined } from "./utils/typeUtils";
2-
1+
import { getStringValueIfDefined, hasFile, isFilePath } from "./utils/typeUtils";
32
import { RequestSpec } from "./models";
43
import { getParamsForUrl, getURL } from "./executeRequest";
4+
import path from "path";
55

66
function replaceSingleQuotes<T>(value: T): T {
77
if (typeof value !== "string") return value;
88
return value.replace(/'/g, "%27") as T & string;
99
}
1010

11+
function formatCurlFormField(key: string, value: string): string {
12+
if (isFilePath(value)) {
13+
return ` --form ${key}=@"${path.resolve(value.slice(7))}"`;
14+
}
15+
return ` --form '${key}="${encodeURIComponent(value)}"'`;
16+
}
17+
18+
function getFormDataUrlEncoded(request: RequestSpec): string {
19+
const formValues = request.httpRequest.formValues;
20+
if (!formValues) return "";
21+
let result = "";
22+
23+
formValues.forEach((formValue: any) => {
24+
result += ` --data "${formValue.name}=${encodeURIComponent(formValue.value)}"`;
25+
});
26+
27+
return result;
28+
}
29+
30+
function getFormDataCurlRequest(request: RequestSpec): string {
31+
const formValues = request.httpRequest.formValues;
32+
if (!formValues) return "";
33+
let result = "";
34+
for (const { name, value } of formValues) {
35+
result += formatCurlFormField(name, value);
36+
}
37+
return result;
38+
}
39+
1140
export function getCurlRequest(request: RequestSpec): string {
1241
let curl: string = "curl";
1342

43+
if (
44+
request.httpRequest.headers["content-type"] == "multipart/form-data" ||
45+
hasFile(request.httpRequest.formValues)
46+
) {
47+
curl += getFormDataCurlRequest(request);
48+
curl += ` '${replaceSingleQuotes(
49+
getURL(
50+
request.httpRequest.baseUrl,
51+
request.httpRequest.url,
52+
getParamsForUrl(request.httpRequest.params, request.options.rawParams)
53+
)
54+
)}'`;
55+
return curl;
56+
} else if (
57+
request.httpRequest.headers["content-type"] == "application/x-www-form-urlencoded" ||
58+
request.httpRequest.formValues
59+
) {
60+
curl += getFormDataUrlEncoded(request);
61+
curl += ` '${replaceSingleQuotes(
62+
getURL(
63+
request.httpRequest.baseUrl,
64+
request.httpRequest.url,
65+
getParamsForUrl(request.httpRequest.params, request.options.rawParams)
66+
)
67+
)}'`;
68+
return curl;
69+
}
70+
1471
// method
1572
curl += ` -X ${request.httpRequest.method.toUpperCase()}`;
1673

@@ -22,8 +79,9 @@ export function getCurlRequest(request: RequestSpec): string {
2279
}
2380

2481
// body
25-
if (request.httpRequest.body !== undefined)
82+
if (request.httpRequest.body !== undefined) {
2683
curl += ` -d '${replaceSingleQuotes(getStringValueIfDefined(request.httpRequest.body))}'`;
84+
}
2785

2886
// options.follow
2987
if (request.options.follow) curl += " -L";
@@ -39,8 +97,8 @@ export function getCurlRequest(request: RequestSpec): string {
3997
getURL(
4098
request.httpRequest.baseUrl,
4199
request.httpRequest.url,
42-
getParamsForUrl(request.httpRequest.params, request.options.rawParams),
43-
),
100+
getParamsForUrl(request.httpRequest.params, request.options.rawParams)
101+
)
44102
)}'`;
45103

46104
return curl;

src/executeRequest.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import got, { Method, OptionsOfTextResponseBody } from "got";
22

3-
import { getStringValueIfDefined } from "./utils/typeUtils";
3+
import { getStringValueIfDefined, hasFile, isFilePath } from "./utils/typeUtils";
44

55
import { GotRequest, Param, RequestSpec } from "./models";
6+
import { fileFromPathSync } from "formdata-node/file-from-path";
7+
8+
import { FormDataEncoder } from "form-data-encoder";
9+
import { FormData } from "formdata-node";
10+
import { Readable } from "stream";
11+
import * as path from "path";
612

713
export function constructGotRequest(allData: RequestSpec): GotRequest {
814
const completeUrl: string = getURL(
915
allData.httpRequest.baseUrl,
1016
allData.httpRequest.url,
11-
getParamsForUrl(allData.httpRequest.params, allData.options.rawParams),
17+
getParamsForUrl(allData.httpRequest.params, allData.options.rawParams)
1218
);
1319

1420
const options: OptionsOfTextResponseBody = {
1521
method: allData.httpRequest.method.toLowerCase() as Method,
16-
body: getStringValueIfDefined(allData.httpRequest.body),
22+
body: getBody(allData),
1723
headers: allData.httpRequest.headers,
1824
followRedirect: allData.options.follow,
1925
https: { rejectUnauthorized: allData.options.verifySSL },
@@ -23,6 +29,60 @@ export function constructGotRequest(allData: RequestSpec): GotRequest {
2329
return got(completeUrl, options);
2430
}
2531

32+
function getFileFromPath(filePath: string) {
33+
filePath = path.resolve(filePath.slice(7)); // removes file:// prefix
34+
const fileName = path.basename(filePath);
35+
return fileFromPathSync(filePath, fileName);
36+
}
37+
38+
function constructFormUrlEncoded(request: RequestSpec) {
39+
const formValues = request.httpRequest.formValues;
40+
if (!formValues) return "";
41+
const result = new URLSearchParams();
42+
if (formValues) {
43+
request.httpRequest.headers["content-type"] = "application/x-www-form-urlencoded";
44+
}
45+
46+
for (const { name, value } of formValues) {
47+
result.append(name, value);
48+
}
49+
50+
return result.toString();
51+
}
52+
53+
function constructFormData(request: RequestSpec) {
54+
const formValues = request.httpRequest.formValues;
55+
if (!formValues) return;
56+
const multipart = new FormData();
57+
58+
for (const fv of formValues) {
59+
if (isFilePath(fv.value)) {
60+
multipart.append(fv.name, getFileFromPath(fv.value));
61+
} else {
62+
multipart.append(fv.name, fv.value);
63+
}
64+
}
65+
const fde = new FormDataEncoder(multipart);
66+
67+
request.httpRequest.headers["content-type"] = fde.contentType; //FormDataEncoder builds the actual content-type header.
68+
return Readable.from(fde);
69+
}
70+
71+
export function getBody(request: RequestSpec) {
72+
const body = request.httpRequest.body;
73+
const formValues = request.httpRequest.formValues;
74+
75+
if (request.httpRequest.headers["content-type"] == "multipart/form-data" || hasFile(formValues)) {
76+
return constructFormData(request);
77+
}
78+
79+
if (formValues) {
80+
return constructFormUrlEncoded(request);
81+
}
82+
83+
return getStringValueIfDefined(body);
84+
}
85+
2686
export async function executeGotRequest(httpRequest: GotRequest): Promise<{
2787
response: { [key: string]: any };
2888
executionTime: number;

src/mergeData.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function getMergedParams(commonParams: RawParams, requestParams: RawParams): Par
5151

5252
function getMergedHeaders(
5353
commonHeaders: RawHeaders,
54-
requestHeaders: RawHeaders,
54+
requestHeaders: RawHeaders
5555
): { [name: string]: string } {
5656
if (Array.isArray(commonHeaders)) {
5757
commonHeaders = getArrayHeadersAsObject(commonHeaders);
@@ -80,7 +80,7 @@ function getMergedOptions(cOptions: RawOptions = {}, rOptions: RawOptions = {}):
8080

8181
function getMergedSetVars(
8282
setvars: RawSetVars = {},
83-
captures: Captures = {},
83+
captures: Captures = {}
8484
): { mergedVars: SetVar[]; hasJsonVars: boolean } {
8585
const mergedVars: SetVar[] = [];
8686
let hasJsonVars = false;
@@ -155,7 +155,7 @@ export function mergePrefixBasedTests(tests: RawTests) {
155155

156156
function getMergedTests(
157157
cTests: RawTests = {},
158-
rTests: RawTests = {},
158+
rTests: RawTests = {}
159159
): { mergedTests: Tests; hasJsonTests: boolean } {
160160
// Convert $. and h. at root level into headers and json keys
161161
mergePrefixBasedTests(cTests);
@@ -208,15 +208,16 @@ export function getMergedData(commonData: Common, requestData: RawRequest): Requ
208208
const params = getMergedParams(commonData.params, requestData.params);
209209
const headers = getMergedHeaders(commonData.headers, requestData.headers);
210210
const body = requestData.body;
211+
const formValues = getMergedParams([], requestData.formValues);
211212
const options = getMergedOptions(commonData.options, requestData.options);
212213

213214
const { mergedTests: tests, hasJsonTests: hasJsonTests } = getMergedTests(
214215
commonData?.tests,
215-
requestData.tests,
216+
requestData.tests
216217
);
217218
const { mergedVars: setvars, hasJsonVars: hasJsonVars } = getMergedSetVars(
218219
requestData.setvars,
219-
requestData.capture,
220+
requestData.capture
220221
);
221222

222223
const mergedData: RequestSpec = {
@@ -228,6 +229,7 @@ export function getMergedData(commonData: Common, requestData: RawRequest): Requ
228229
params,
229230
headers,
230231
body,
232+
formValues: formValues.length > 0 ? formValues : undefined,
231233
},
232234
options,
233235
tests,

src/models.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CancelableRequest, Response, Method, OptionsOfTextResponseBody } from "got";
1+
import { CancelableRequest, Response, Method } from "got";
22

33
export interface Header {
44
name: string;
@@ -85,6 +85,7 @@ export interface RawRequest {
8585
headers: RawHeaders;
8686
params: RawParams;
8787
body?: string;
88+
formValues?: RawParams;
8889
options?: RawOptions;
8990
tests?: RawTests;
9091
capture?: Captures;
@@ -101,6 +102,7 @@ export interface RequestSpec {
101102
params: Param[];
102103
headers: { [key: string]: string };
103104
body?: any;
105+
formValues?: Param[];
104106
};
105107
expectJson: boolean;
106108
options: Options;

src/utils/typeUtils.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function getStringIfNotScalar(data: any): Exclude<any, object> {
2121

2222
export function getStringValueIfDefined<
2323
T extends undefined | Exclude<any, undefined>,
24-
R = T extends undefined ? undefined : string,
24+
R = T extends undefined ? undefined : string
2525
>(value: T): R {
2626
if (typeof value === "undefined") return undefined as R;
2727
if (typeof value === "object") return JSON.stringify(value) as R; // handles dicts, arrays, null, date (all obj)
@@ -32,3 +32,27 @@ export function getStrictStringValue(value: any): string {
3232
if (typeof value === "undefined") return "undefined";
3333
return getStringValueIfDefined(value);
3434
}
35+
36+
export function isString(value: any): boolean {
37+
return typeof value === "string" || value instanceof String;
38+
}
39+
40+
export function isFilePath(value: any): boolean {
41+
if (!isString(value)) {
42+
return false;
43+
}
44+
const fileRegex = /file:\/\/([^\s]+)/g;
45+
return fileRegex.test(value);
46+
}
47+
48+
export function hasFile(formValues: any): boolean {
49+
if (!formValues) {
50+
return false;
51+
}
52+
for (const formValue of formValues) {
53+
if (isFilePath(formValue.value)) {
54+
return true;
55+
}
56+
}
57+
return false;
58+
}

0 commit comments

Comments
 (0)