import type { HttpRequest } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import classNames from "classnames"; import { atom, useAtomValue } from "jotai"; import type { CSSProperties } from "react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; import { allRequestsAtom } from "../hooks/useAllRequests"; import { useAuthTab } from "../hooks/useAuthTab"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useHeadersTab } from "../hooks/useHeadersTab"; import { useImportCurl } from "../hooks/useImportCurl"; import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_URLENCODED, BODY_TYPE_GRAPHQL, BODY_TYPE_JSON, BODY_TYPE_NONE, BODY_TYPE_OTHER, BODY_TYPE_XML, getContentTypeFromHeaders, } from "../lib/model_util"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; import { showToast } from "../lib/toast"; import { BinaryFileEditor } from "./BinaryFileEditor"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; import { CountBadge } from "./core/CountBadge"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { Editor } from "./core/Editor/LazyEditor"; import { InlineCode } from "./core/InlineCode"; import type { Pair } from "./core/PairEditor"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; import { EmptyStateText } from "./EmptyStateText"; import { FormMultipartEditor } from "./FormMultipartEditor"; import { FormUrlencodedEditor } from "./FormUrlencodedEditor"; import { HeadersEditor } from "./HeadersEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { JsonBodyEditor } from "./JsonBodyEditor"; import { MarkdownEditor } from "./MarkdownEditor"; import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { UrlBar } from "./UrlBar"; import { UrlParametersEditor } from "./UrlParameterEditor"; const GraphQLEditor = lazy(() => import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })), ); interface Props { style: CSSProperties; fullHeight: boolean; className?: string; activeRequest: HttpRequest; } const TAB_BODY = "body"; const TAB_PARAMS = "params"; const TAB_HEADERS = "headers"; const TAB_AUTH = "auth"; const TAB_DESCRIPTION = "description"; const TABS_STORAGE_KEY = "http_request_tabs"; const nonActiveRequestUrlsAtom = atom((get) => { const activeRequestId = get(activeRequestIdAtom); const requests = get(allRequestsAtom); return requests .filter((r) => r.id !== activeRequestId) .map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); }); const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom); export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { const activeRequestId = activeRequest.id; const tabsRef = useRef(null); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState(0); const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null); const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const contentType = getContentTypeFromHeaders(activeRequest.headers); const authTab = useAuthTab(TAB_AUTH, activeRequest); const headersTab = useHeadersTab(TAB_HEADERS, activeRequest); const inheritedHeaders = useInheritedHeaders(activeRequest); // Listen for event to focus the params tab (e.g., when clicking a :param in the URL) useRequestEditorEvent( "request_pane.focus_tab", () => { tabsRef.current?.setActiveTab(TAB_PARAMS); }, [], ); const handleContentTypeChange = useCallback( async (contentType: string | null, patch: Partial> = {}) => { if (activeRequest == null) { console.error("Failed to get active request to update", activeRequest); return; } const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type"); if (contentType != null) { headers.push({ name: "Content-Type", value: contentType, enabled: true, id: generateId(), }); } await patchModel(activeRequest, { ...patch, headers }); // Force update header editor so any changed headers are reflected setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100); }, [activeRequest], ); const { urlParameterPairs, urlParametersKey } = useMemo(() => { const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( (m) => m[1] ?? "", ); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const items: Pair[] = [...nonEmptyParameters]; for (const name of placeholderNames) { const item = items.find((p) => p.name === name); if (item) { item.readOnlyName = true; } else { items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() }); } } return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") }; }, [activeRequest.url, activeRequest.urlParameters]); let numParams = 0; if ( activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED || activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ) { numParams = Array.isArray(activeRequest.body?.form) ? activeRequest.body.form.filter((p) => p.name).length : 0; } const tabs = useMemo( () => [ { value: TAB_BODY, rightSlot: numParams > 0 ? : null, options: { value: activeRequest.bodyType, items: [ { type: "separator", label: "Form Data" }, { label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED }, { label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART }, { type: "separator", label: "Text Content" }, { label: "GraphQL", value: BODY_TYPE_GRAPHQL }, { label: "JSON", value: BODY_TYPE_JSON }, { label: "XML", value: BODY_TYPE_XML }, { label: "Other", value: BODY_TYPE_OTHER, shortLabel: nameOfContentTypeOr(contentType, "Other"), }, { type: "separator", label: "Other" }, { label: "Binary File", value: BODY_TYPE_BINARY }, { label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE }, ], onChange: async (bodyType) => { if (bodyType === activeRequest.bodyType) return; const showMethodToast = (newMethod: string) => { if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return; showToast({ id: "switched-method", message: ( <> Request method switched to POST ), }); }; const patch: Partial = { bodyType }; let newContentType: string | null | undefined; if (bodyType === BODY_TYPE_NONE) { newContentType = null; } else if ( bodyType === BODY_TYPE_FORM_URLENCODED || bodyType === BODY_TYPE_FORM_MULTIPART || bodyType === BODY_TYPE_JSON || bodyType === BODY_TYPE_OTHER || bodyType === BODY_TYPE_XML ) { const isDefaultishRequest = activeRequest.bodyType === BODY_TYPE_NONE && activeRequest.method.toLowerCase() === "get"; const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART; if (isDefaultishRequest || requiresPost) { patch.method = "POST"; showMethodToast(patch.method); } newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType; } else if (bodyType === BODY_TYPE_GRAPHQL) { patch.method = "POST"; newContentType = "application/json"; showMethodToast(patch.method); } if (newContentType !== undefined) { await handleContentTypeChange(newContentType, patch); } else { await patchModel(activeRequest, patch); } }, }, }, { value: TAB_PARAMS, rightSlot: , label: "Params", }, ...headersTab, ...authTab, { value: TAB_DESCRIPTION, label: "Info", }, ], [ activeRequest, authTab, contentType, handleContentTypeChange, headersTab, numParams, urlParameterPairs.length, ], ); const { mutate: sendRequest } = useSendAnyHttpRequest(); const { activeResponse } = usePinnedHttpResponse(activeRequestId); const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const updateKey = useRequestUpdateKey(activeRequestId); const { mutate: importCurl } = useImportCurl(); const handleBodyChange = useCallback( (body: HttpRequest["body"]) => patchModel(activeRequest, { body }), [activeRequest], ); const handleBodyTextChange = useCallback( (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), [activeRequest], ); const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); const autocomplete: GenericCompletionConfig = useMemo( () => ({ minMatch: 3, options: autocompleteUrls.length > 0 ? autocompleteUrls : [ { label: "http://", type: "constant" }, { label: "https://", type: "constant" }, ], }), [autocompleteUrls], ); const handlePaste = useCallback( async (e: ClipboardEvent, text: string) => { if (text.startsWith("curl ")) { importCurl({ overwriteRequestId: activeRequestId, command: text }); } else { const patch = prepareImportQuerystring(text); if (patch != null) { e.preventDefault(); // Prevent input onChange await patchModel(activeRequest, patch); await setActiveTab({ storageKey: TABS_STORAGE_KEY, activeTabKey: activeRequestId, value: TAB_PARAMS, }); // Wait for request to update, then refresh the UI // TODO: Somehow make this deterministic setTimeout(() => { forceUrlRefresh(); forceParamsRefresh(); }, 100); } } }, [activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl], ); const handleSend = useCallback( () => sendRequest(activeRequest.id ?? null), [activeRequest.id, sendRequest], ); const handleUrlChange = useCallback( (url: string) => patchModel(activeRequest, { url }), [activeRequest], ); return (
{activeRequest && ( <>
} forceUpdateKey={updateKey} isLoading={activeResponse != null && activeResponse.state !== "closed"} /> patchModel(activeRequest, { headers })} /> patchModel(activeRequest, { urlParameters })} /> {activeRequest.bodyType === BODY_TYPE_JSON ? ( ) : activeRequest.bodyType === BODY_TYPE_XML ? ( ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( ) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? ( ) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? ( ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( patchModel(activeRequest, { body })} onChangeContentType={handleContentTypeChange} /> ) : typeof activeRequest.bodyType === "string" ? ( ) : ( No Body )}
patchModel(activeRequest, { name })} /> patchModel(activeRequest, { description })} />
)} ); } function nameOfContentTypeOr(contentType: string | null, fallback: string) { const language = languageFromContentType(contentType); if (language === "markdown") { return "Markdown"; } return fallback; }