Skip to content

Commit 36f6a08

Browse files
committed
feat: multipart form data in REST Client and cURL Copy
1 parent bb4c8af commit 36f6a08

File tree

5 files changed

+191
-46
lines changed

5 files changed

+191
-46
lines changed

src/pages/rest/components/BodyEditor.tsx

Lines changed: 148 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import { monacoOnMountHandler } from "@/components/Monaco/utils";
2-
import { Group, Radio } from "@mantine/core";
2+
import {
3+
Group,
4+
Radio,
5+
Table,
6+
ActionIcon,
7+
Tooltip,
8+
Button,
9+
FileButton,
10+
Box,
11+
TextInput,
12+
} from "@mantine/core";
313
import Editor from "@monaco-editor/react";
414
import { useMemo } from "react";
5-
import { BodyMode, RequestBody } from "../types/rest";
15+
import { BodyMode, RequestBody, KeyValue } from "../types/rest";
16+
import ParamsHeadersEditor from "./ParamsHeadersEditor";
17+
import { BsPlus, BsTrash3 } from "react-icons/bs";
618

719
type Props = {
820
body: RequestBody;
921
onChange: (body: RequestBody) => void;
1022
};
1123

12-
const modes: BodyMode[] = ["none", "json", "xml", "text"];
24+
const modes: BodyMode[] = ["none", "json", "xml", "text", "multipart"];
1325

1426
export default function BodyEditor({ body, onChange }: Props) {
1527
const language = useMemo(() => {
@@ -20,20 +32,28 @@ export default function BodyEditor({ body, onChange }: Props) {
2032
}, [body.mode]);
2133

2234
return (
23-
<div style={{ height: 240, display: "flex", flexDirection: "column", gap: 8 }}>
35+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
2436
<Group justify="space-between">
2537
<Radio.Group
2638
size="xs"
2739
value={body.mode}
2840
onChange={v => {
29-
onChange(
30-
v === "none"
31-
? { mode: "none" }
32-
: {
33-
mode: v as Exclude<BodyMode, "none" | "form" | "multipart">,
34-
text: (body as any).text || "",
35-
}
36-
);
41+
if (v === "none") {
42+
onChange({ mode: "none" });
43+
return;
44+
}
45+
if (v === "multipart") {
46+
onChange({
47+
mode: "multipart",
48+
fields: (body as any).fields || [],
49+
files: (body as any).files || [],
50+
});
51+
return;
52+
}
53+
onChange({
54+
mode: v as Exclude<BodyMode, "none" | "multipart">,
55+
text: (body as any).text || "",
56+
});
3757
}}
3858
name="body-mode"
3959
>
@@ -50,23 +70,124 @@ export default function BodyEditor({ body, onChange }: Props) {
5070
style={{
5171
flex: 1,
5272
minHeight: 180,
53-
borderRadius: "var(--mantine-radius-md)",
54-
overflow: "hidden",
5573
}}
5674
>
57-
<Editor
58-
height="100%"
59-
defaultLanguage={language}
60-
value={(body as any).text || ""}
61-
onChange={value => onChange({ ...(body as any), text: value || "" })}
62-
options={{
63-
minimap: { enabled: false },
64-
fontSize: 13,
65-
wordWrap: "on",
66-
scrollBeyondLastLine: false,
67-
}}
68-
onMount={monacoOnMountHandler}
69-
/>
75+
{body.mode === "json" || body.mode === "xml" || body.mode === "text" ? (
76+
<Box
77+
h="100%"
78+
w="100%"
79+
style={{ borderRadius: "var(--mantine-radius-md)", overflow: "hidden" }}
80+
>
81+
<Editor
82+
height="100%"
83+
defaultLanguage={language}
84+
value={(body as any).text || ""}
85+
onChange={value => onChange({ ...(body as any), text: value || "" })}
86+
options={{
87+
minimap: { enabled: false },
88+
fontSize: 13,
89+
wordWrap: "on",
90+
scrollBeyondLastLine: false,
91+
}}
92+
onMount={monacoOnMountHandler}
93+
/>
94+
</Box>
95+
) : null}
96+
{body.mode === "multipart" && (
97+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
98+
<ParamsHeadersEditor
99+
title="Multipart Fields"
100+
rows={(body as any).fields || []}
101+
onChange={(rows: KeyValue[]) => onChange({ ...(body as any), fields: rows })}
102+
/>
103+
<Group justify="space-between" style={{ marginBottom: 8 }}>
104+
<strong>Files</strong>
105+
<Tooltip label="Add file">
106+
<ActionIcon
107+
variant="default"
108+
onClick={() => {
109+
const files = (body as any).files || [];
110+
const next = [
111+
...files,
112+
{ id: crypto.randomUUID(), field: "", name: "", file: undefined },
113+
];
114+
onChange({ ...(body as any), files: next });
115+
}}
116+
>
117+
<BsPlus size={18} />
118+
</ActionIcon>
119+
</Tooltip>
120+
</Group>
121+
122+
<Table striped withTableBorder withColumnBorders>
123+
<Table.Thead>
124+
<Table.Tr>
125+
<Table.Th>Field</Table.Th>
126+
<Table.Th>File</Table.Th>
127+
<Table.Th>Actions</Table.Th>
128+
</Table.Tr>
129+
</Table.Thead>
130+
<Table.Tbody>
131+
{((body as any).files || []).map((f: any) => (
132+
<Table.Tr key={f.id}>
133+
<Table.Td>
134+
<TextInput
135+
value={f.field}
136+
placeholder="field name"
137+
onChange={e => {
138+
const files = (body as any).files || [];
139+
onChange({
140+
...(body as any),
141+
files: files.map((x: any) =>
142+
x.id === f.id ? { ...x, field: e.currentTarget.value } : x
143+
),
144+
});
145+
}}
146+
/>
147+
</Table.Td>
148+
<Table.Td>
149+
<Group>
150+
<div>{f.name || (f.file && f.file.name) || "(no file)"}</div>
151+
<FileButton
152+
onChange={file => {
153+
const files = (body as any).files || [];
154+
onChange({
155+
...(body as any),
156+
files: files.map((x: any) =>
157+
x.id === f.id ? { ...x, file, name: file?.name || x.name } : x
158+
),
159+
});
160+
}}
161+
>
162+
{props => (
163+
<Button size="xs" {...props}>
164+
Choose
165+
</Button>
166+
)}
167+
</FileButton>
168+
</Group>
169+
</Table.Td>
170+
<Table.Td style={{ width: 56 }}>
171+
<ActionIcon
172+
color="red"
173+
variant="subtle"
174+
onClick={() => {
175+
const files = (body as any).files || [];
176+
onChange({
177+
...(body as any),
178+
files: files.filter((x: any) => x.id !== f.id),
179+
});
180+
}}
181+
>
182+
<BsTrash3 size={16} />
183+
</ActionIcon>
184+
</Table.Td>
185+
</Table.Tr>
186+
))}
187+
</Table.Tbody>
188+
</Table>
189+
</div>
190+
)}
70191
</div>
71192
)}
72193
</div>

src/pages/rest/components/RestEditor.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Button, Flex, Group, NativeSelect, Stack, TextInput } from "@mantine/core";
1+
import { Button, Flex, Group, NativeSelect, Stack, TextInput, Tooltip } from "@mantine/core";
22
import { useEffect, useRef } from "react";
33
import { LayoutType } from "../Rest";
44
import { HttpMethod, RequestTab } from "../types/rest";
55
import RequestEditor from "./RequestEditor";
66
import ResponseViewer from "./ResponseViewer";
7+
import { toCurl } from "../utils/curl";
8+
import { CopyButton } from "@/components/CopyButton";
79

810
type Props = {
911
tab: RequestTab;
@@ -67,7 +69,7 @@ export default function RestEditor({ tab, onChange, onSend, sending, layout }: P
6769

6870
return (
6971
<Stack gap="sm" style={{ overflow: "scroll" }}>
70-
<Group wrap="nowrap">
72+
<Group wrap="nowrap" gap={6}>
7173
<Flex
7274
flex={1}
7375
styles={{
@@ -84,6 +86,15 @@ export default function RestEditor({ tab, onChange, onSend, sending, layout }: P
8486
<Button onClick={onSend} loading={sending} disabled={sending} variant="light">
8587
Send
8688
</Button>
89+
<Tooltip label="Copy CURL" withArrow>
90+
<CopyButton
91+
value={toCurl(tab)}
92+
label="cURL"
93+
size="sm"
94+
variant="light"
95+
fullWidth={false}
96+
/>
97+
</Tooltip>
8798
</Group>
8899

89100
{layout === "vertical" ? (

src/pages/rest/types/rest.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ export type KeyValue = {
77
enabled: boolean;
88
};
99

10-
export type BodyMode = "none" | "json" | "xml" | "text" | "form" | "multipart";
10+
export type BodyMode = "none" | "json" | "xml" | "text" | "multipart";
1111

1212
export type RequestBody =
1313
| { mode: "none" }
1414
| { mode: "json" | "xml" | "text"; text: string }
15-
| { mode: "form"; fields: KeyValue[] }
1615
| {
1716
mode: "multipart";
1817
fields: KeyValue[];

src/pages/rest/utils/curl.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { HttpMethod, KeyValue, RequestBody } from "../types/rest";
2+
import { buildUrlWithParams } from "./request";
23

34
export function toCurl(args: {
45
method: HttpMethod;
56
url: string;
67
headers: KeyValue[];
78
body: RequestBody;
9+
params: KeyValue[];
810
}): string {
9-
const { method, url, headers, body } = args;
11+
const { method, url, headers, body, params } = args;
12+
13+
const finalUrl = buildUrlWithParams(url, params);
14+
1015
const headerFlags = headers
1116
.filter(h => h.enabled && h.key)
1217
.map(h => `-H ${JSON.stringify(`${h.key}: ${h.value}`)}`)
@@ -15,20 +20,14 @@ export function toCurl(args: {
1520
let data = "";
1621
if (body.mode === "json" || body.mode === "xml" || body.mode === "text") {
1722
data = `--data ${JSON.stringify(body.text ?? "")}`;
18-
} else if (body.mode === "form") {
19-
// repeat -d for each field to support same key
20-
data = body.fields
21-
.filter(f => f.enabled && f.key)
22-
.map(f => `-d ${JSON.stringify(`${f.key}=${f.value}`)}`)
23-
.join(" ");
2423
} else if (body.mode === "multipart") {
2524
data = body.fields
2625
.filter(f => f.enabled && f.key)
2726
.map(f => `-F ${JSON.stringify(`${f.key}=${f.value}`)}`)
2827
.join(" ");
2928
}
3029

31-
const parts = ["curl", "-X", method, headerFlags, data, JSON.stringify(url)]
30+
const parts = ["curl", "-X", method, headerFlags, data, JSON.stringify(finalUrl)]
3231
.filter(Boolean)
3332
.join(" ");
3433
return parts;

src/pages/rest/utils/request.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,28 @@ export function buildBody(body: RequestBody): BodyInit | undefined {
3434
if (body.mode === "json" || body.mode === "xml" || body.mode === "text") {
3535
return body.text ?? "";
3636
}
37-
if (body.mode === "form") {
38-
const usp = new URLSearchParams();
39-
body.fields.filter(f => f.enabled && f.key).forEach(f => usp.append(f.key, f.value));
40-
return usp as unknown as BodyInit;
41-
}
4237
if (body.mode === "multipart") {
4338
const fd = new FormData();
4439
body.fields.filter(f => f.enabled && f.key).forEach(f => fd.append(f.key, f.value));
4540
// Files: path resolution can be handled later with Tauri FS/Open dialog
4641
body.files?.forEach(file => {
47-
if (file.field && file.name) {
48-
// Placeholder: append empty Blob; actual file picking wired in UI
42+
if (!file.field) return;
43+
const picked = (file as any).file;
44+
// Heuristic: treat as picked file when it has a `name` property (File/Blob from FileButton)
45+
if (picked && typeof (picked as any).name === "string") {
46+
fd.append(
47+
file.field,
48+
picked as unknown as File,
49+
(file as any).name || (picked as any).name
50+
);
51+
return;
52+
}
53+
if (file.path) {
54+
// Desktop (Tauri) path-only entry: placeholder until FS read is wired
55+
fd.append(file.field, new Blob([""]), file.name || file.path);
56+
return;
57+
}
58+
if (file.name) {
4959
fd.append(file.field, new Blob([""]), file.name);
5060
}
5161
});
@@ -82,6 +92,11 @@ export async function sendRequest(args: {
8292
? "application/xml"
8393
: "text/plain";
8494
}
95+
// For multipart/form-data, remove any Content-Type header so fetch can add the boundary
96+
if (body.mode === "multipart") {
97+
const cKey = Object.keys(headerRecord).find(k => k.toLowerCase() === "content-type");
98+
if (cKey) delete headerRecord[cKey];
99+
}
85100

86101
const init: RequestInit = {
87102
method,

0 commit comments

Comments
 (0)