Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions packages/zudoku/src/lib/plugins/openapi/playground/BodyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChevronDownIcon,
FileInput,
Grid2x2PlusIcon,
LinkIcon,
PaperclipIcon,
ScanTextIcon,
XIcon,
Expand All @@ -20,6 +21,7 @@ import { Textarea } from "zudoku/ui/Textarea.js";
import { cn } from "../../../util/cn.js";
import { humanFileSize } from "../../../util/humanFileSize.js";
import type { MediaTypeObject } from "../graphql/graphql.js";
import { exampleToUrlEncodedRows } from "../util/formatRequestBody.js";
import {
CollapsibleHeader,
CollapsibleHeaderTrigger,
Expand All @@ -28,18 +30,27 @@ import ExamplesDropdown from "./ExamplesDropdown.js";
import ParamsGrid from "./ParamsGrid.js";
import type { PlaygroundForm } from "./Playground.js";
import { MultipartField } from "./request-panel/MultipartField.js";
import { UrlEncodedField } from "./request-panel/UrlEncodedField.js";
import { useKeyValueFieldManager } from "./request-panel/useKeyValueFieldManager.js";

export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
const { register, setValue, watch, control } =
useFormContext<PlaygroundForm>();
const examples = (content ?? []).flatMap((e) => e.examples);
const [headers, file, bodyMode, body, multipartFormFields] = watch([
const [
headers,
file,
bodyMode,
body,
multipartFormFields,
urlencodedFormFields,
] = watch([
"headers",
"file",
"bodyMode",
"body",
"multipartFormFields",
"urlencodedFormFields",
]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
Expand Down Expand Up @@ -94,6 +105,15 @@ export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
},
});

const urlencodedManager = useKeyValueFieldManager<
PlaygroundForm,
"urlencodedFormFields"
>({
control,
name: "urlencodedFormFields",
defaultValue: { name: "", value: "", active: false },
});

return (
<Collapsible defaultOpen>
<CollapsibleHeaderTrigger className="items-center">
Expand All @@ -118,6 +138,11 @@ export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
<PaperclipIcon size={14} />
File
</>
) : bodyMode === "urlencoded" ? (
<>
<LinkIcon size={14} />
URL-encoded
</>
) : (
<>
<Grid2x2PlusIcon size={14} />
Expand Down Expand Up @@ -164,6 +189,18 @@ export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
)}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setValue("bodyMode", "urlencoded")}
className="gap-2"
>
<LinkIcon size={14} />
<span className="flex-1">URL-encoded</span>
<span>
{urlencodedFormFields?.some((field) => field.active) && (
<div className="w-1.5 h-1.5 bg-primary rounded-full" />
)}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
Expand All @@ -177,9 +214,22 @@ export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
<ExamplesDropdown
examples={content}
onSelect={(example, mediaType) => {
setValue("body", JSON.stringify(example.value, null, 2));
if (mediaType === "application/x-www-form-urlencoded") {
const rows = exampleToUrlEncodedRows(example.value);
setValue(
"urlencodedFormFields",
rows.map((r) => ({ ...r, active: true })),
);
setValue("bodyMode", "urlencoded");
} else {
setValue("body", JSON.stringify(example.value, null, 2));
setValue("urlencodedFormFields", []);
setValue("bodyMode", "text");
}
setValue("headers", [
...headers.filter((h) => h.name !== "Content-Type"),
...headers.filter(
(h) => h.name.toLowerCase() !== "content-type",
),
{
name: "Content-Type",
value: mediaType,
Expand Down Expand Up @@ -263,6 +313,17 @@ export const BodyPanel = ({ content }: { content?: MediaTypeObject[] }) => {
))}
</ParamsGrid>
)}
{bodyMode === "urlencoded" && (
<ParamsGrid>
{urlencodedManager.fields.map((field, index) => (
<UrlEncodedField
key={field.id}
index={index}
manager={urlencodedManager}
/>
))}
</ParamsGrid>
)}
</CollapsibleContent>
</Collapsible>
);
Expand Down
35 changes: 17 additions & 18 deletions packages/zudoku/src/lib/plugins/openapi/playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
import { useSelectedServer } from "../state.js";
import { AuthorizeDialog } from "./AuthorizeDialog.js";
import BodyPanel from "./BodyPanel.js";
import { buildRequestBody } from "./buildRequestBody.js";
import {
CollapsibleHeader,
CollapsibleHeaderTrigger,
Expand Down Expand Up @@ -87,13 +88,18 @@ export type PathParam = {

export type PlaygroundForm = {
body: string;
bodyMode?: "text" | "file" | "multipart";
bodyMode?: "text" | "file" | "multipart" | "urlencoded";
file?: File | null;
multipartFormFields: Array<{
name: string;
value: File | string;
active: boolean;
}>;
urlencodedFormFields: Array<{
name: string;
value: string;
active: boolean;
}>;
queryParams: Array<{
name: string;
value: string;
Expand Down Expand Up @@ -241,6 +247,7 @@ export const Playground = ({
bodyMode: "text",
file: null,
multipartFormFields: [],
urlencodedFormFields: [],
queryParams:
queryParams.length > 0
? queryParams.map((param) => ({
Expand Down Expand Up @@ -313,25 +320,14 @@ export const Playground = ({
.map<[string, string]>((h) => [h.name, h.value]),
);

let body: string | FormData | File | undefined;

switch (data.bodyMode) {
case "file":
body = data.file || undefined;
const built = buildRequestBody(data);
const body = built.body;
switch (built.contentType.kind) {
case "remove":
headers.delete("Content-Type");
break;
case "multipart": {
const formData = new FormData();
data.multipartFormFields
?.filter((field) => field.name && field.active)
.forEach((field) => formData.append(field.name, field.value));

body = formData;
headers.delete("Content-Type");
break;
}
default:
body = data.body || undefined;
case "override":
headers.set("Content-Type", built.contentType.value);
break;
}

Expand Down Expand Up @@ -431,6 +427,9 @@ export const Playground = ({
)
.join("\n");
break;
case "urlencoded":
requestBody = typeof built.body === "string" ? built.body : "";
break;
default:
requestBody = data.body;
break;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { buildRequestBody } from "./buildRequestBody.js";
import type { PlaygroundForm } from "./Playground.js";

const baseForm: PlaygroundForm = {
body: "",
bodyMode: "text",
file: null,
multipartFormFields: [],
urlencodedFormFields: [],
queryParams: [],
pathParams: [],
headers: [],
};

describe("buildRequestBody", () => {
it("returns text body as-is and preserves Content-Type", () => {
const r = buildRequestBody({
...baseForm,
bodyMode: "text",
body: "hello",
});
expect(r.body).toBe("hello");
expect(r.contentType).toEqual({ kind: "preserve" });
});

it("returns undefined for empty text body", () => {
expect(buildRequestBody({ ...baseForm, bodyMode: "text" }).body).toBe(
undefined,
);
});

it("file mode returns the file and removes Content-Type", () => {
const file = new File(["abc"], "x.txt");
const r = buildRequestBody({ ...baseForm, bodyMode: "file", file });
expect(r.body).toBe(file);
expect(r.contentType).toEqual({ kind: "remove" });
});

it("file mode without a file returns undefined body", () => {
const r = buildRequestBody({ ...baseForm, bodyMode: "file", file: null });
expect(r.body).toBeUndefined();
expect(r.contentType).toEqual({ kind: "remove" });
});

it("multipart skips inactive and empty-name fields", () => {
const r = buildRequestBody({
...baseForm,
bodyMode: "multipart",
multipartFormFields: [
{ name: "a", value: "1", active: true },
{ name: "b", value: "2", active: false },
{ name: "", value: "3", active: true },
],
});
expect(r.body).toBeInstanceOf(FormData);
const fd = r.body as FormData;
expect(fd.get("a")).toBe("1");
expect(fd.has("b")).toBe(false);
expect(r.contentType).toEqual({ kind: "remove" });
});

it("urlencoded encodes active fields and overrides Content-Type", () => {
const r = buildRequestBody({
...baseForm,
bodyMode: "urlencoded",
urlencodedFormFields: [
{ name: "grant_type", value: "client_credentials", active: true },
{ name: "client_id", value: "abc", active: true },
{ name: "secret", value: "shh", active: false },
{ name: "", value: "skipped", active: true },
],
});
expect(r.body).toBe("grant_type=client_credentials&client_id=abc");
expect(r.contentType).toEqual({
kind: "override",
value: "application/x-www-form-urlencoded",
});
});

it("urlencoded percent-encodes special characters", () => {
const r = buildRequestBody({
...baseForm,
bodyMode: "urlencoded",
urlencodedFormFields: [
{ name: "q", value: "hello world", active: true },
{ name: "k+1", value: "x&y", active: true },
],
});
expect(r.body).toBe("q=hello+world&k%2B1=x%26y");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { rowsToUrlEncoded } from "../util/formatRequestBody.js";
import type { PlaygroundForm } from "./Playground.js";

export type ContentTypeAction =
| { kind: "preserve" }
| { kind: "remove" }
| { kind: "override"; value: string };

export type BuiltRequestBody = {
body: string | FormData | File | undefined;
contentType: ContentTypeAction;
};

export const buildRequestBody = (form: PlaygroundForm): BuiltRequestBody => {
switch (form.bodyMode) {
case "file":
return {
body: form.file || undefined,
contentType: { kind: "remove" },
};
case "multipart": {
const formData = new FormData();
form.multipartFormFields
?.filter((f) => f.name && f.active)
.forEach((f) => formData.append(f.name, f.value));
return { body: formData, contentType: { kind: "remove" } };
}
case "urlencoded": {
const rows = (form.urlencodedFormFields ?? [])
.filter((f) => f.name && f.active)
.map((f) => ({ name: f.name, value: f.value }));
return {
body: rowsToUrlEncoded(rows),
contentType: {
kind: "override",
value: "application/x-www-form-urlencoded",
},
};
}
default:
return {
body: form.body || undefined,
contentType: { kind: "preserve" },
};
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const makeFormData = (
): PlaygroundForm => ({
body: "",
multipartFormFields: [],
urlencodedFormFields: [],
queryParams: [],
pathParams: [],
headers: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Checkbox } from "zudoku/ui/Checkbox.js";
import {
ParamsGridInput,
ParamsGridItem,
ParamsGridRemoveButton,
} from "../ParamsGrid.js";
import type { PlaygroundForm } from "../Playground.js";
import type { useKeyValueFieldManager } from "./useKeyValueFieldManager.js";

type UrlEncodedFieldProps = {
index: number;
manager: ReturnType<
typeof useKeyValueFieldManager<PlaygroundForm, "urlencodedFormFields">
>;
};

export const UrlEncodedField = ({ index, manager }: UrlEncodedFieldProps) => (
<ParamsGridItem>
<Checkbox
{...manager.getCheckboxProps(index)}
disabled={!manager.getValue(index, "name")}
/>
<ParamsGridInput {...manager.getNameInputProps(index)} placeholder="Key" />
<div className="flex items-center gap-1 flex-1">
<ParamsGridInput
{...manager.getValueInputProps(index)}
placeholder="Value"
/>
<ParamsGridRemoveButton {...manager.getRemoveButtonProps(index)} />
</div>
</ParamsGridItem>
);
Loading
Loading