import type { HttpRequest } from '@yaakapp-internal/models'; import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import classNames from 'classnames'; import { atom, useAtom, useAtomValue } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import type { CSSProperties } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; import { grpcRequestsAtom } from '../hooks/useGrpcRequests'; import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication'; import { httpRequestsAtom } from '../hooks/useHttpRequests'; import { useImportCurl } from '../hooks/useImportCurl'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor'; import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest'; 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, } from '../lib/model_util'; import { prepareImportQuerystring } from '../lib/prepareImportQuerystring'; import { resolvedModelName } from '../lib/resolvedModelName'; import { showToast } from '../lib/toast'; import { BinaryFileEditor } from './BinaryFileEditor'; import { CountBadge } from './core/CountBadge'; import { Editor } from './core/Editor/Editor'; import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import { InlineCode } from './core/InlineCode'; import type { Pair } from './core/PairEditor'; import { PlainInput } from './core/PlainInput'; import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; import { EmptyStateText } from './EmptyStateText'; import { FormMultipartEditor } from './FormMultipartEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { GraphQLEditor } from './GraphQLEditor'; import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { UrlBar } from './UrlBar'; import { UrlParametersEditor } from './UrlParameterEditor'; 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 tabsAtom = atomWithStorage>('requestPaneActiveTabs', {}); const nonActiveRequestUrlsAtom = atom((get) => { const activeRequestId = get(activeRequestIdAtom); const requests = [...get(httpRequestsAtom), ...get(grpcRequestsAtom)]; 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 { mutateAsync: updateRequestAsync, mutate: updateRequest } = useUpdateAnyHttpRequest(); const [activeTabs, setActiveTabs] = useAtom(tabsAtom); const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState(0); const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null); const [{ urlKey }, { focusParamsTab, forceUrlRefresh, forceParamsRefresh }] = useRequestEditor(); const contentType = useContentTypeFromHeaders(activeRequest.headers); const authentication = useHttpAuthenticationSummaries(); const handleContentTypeChange = useCallback( async (contentType: string | null) => { 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 updateRequestAsync({ id: activeRequest.id, update: { headers } }); // Force update header editor so any changed headers are reflected setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100); }, [activeRequest, updateRequestAsync], ); 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 index = items.findIndex((p) => p.name === name); if (index >= 0) { items[index]!.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 ) { const n = Array.isArray(activeRequest.body?.form) ? activeRequest.body.form.filter((p) => p.name).length : 0; numParams = n; } 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 }, { 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); } await updateRequestAsync({ id: activeRequestId, update: patch }); if (newContentType !== undefined) { await handleContentTypeChange(newContentType); } }, }, }, { value: TAB_PARAMS, rightSlot: , label: 'Params', }, { value: TAB_HEADERS, label: 'Headers', rightSlot: h.name).length} />, }, { value: TAB_AUTH, label: 'Auth', options: { value: activeRequest.authenticationType, items: [ ...authentication.map((a) => ({ label: a.label || 'UNKNOWN', shortLabel: a.shortLabel, value: a.name, })), { type: 'separator' }, { label: 'No Authentication', shortLabel: 'Auth', value: null }, ], onChange: async (authenticationType) => { let authentication: HttpRequest['authentication'] = activeRequest.authentication; if (activeRequest.authenticationType !== authenticationType) { authentication = { // Reset auth if changing types }; } updateRequest({ id: activeRequestId, update: { authenticationType, authentication }, }); }, }, }, { value: TAB_DESCRIPTION, label: 'Info', }, ], [ activeRequest.authentication, activeRequest.authenticationType, activeRequest.bodyType, activeRequest.headers, activeRequest.method, activeRequestId, authentication, handleContentTypeChange, numParams, updateRequest, updateRequestAsync, 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']) => updateRequest({ id: activeRequestId, update: { body } }), [activeRequestId, updateRequest], ); const handleBodyTextChange = useCallback( (text: string) => updateRequest({ id: activeRequestId, update: { body: { text } } }), [activeRequestId, updateRequest], ); const activeTab = activeTabs?.[activeRequestId]; const setActiveTab = useCallback( (tab: string) => { setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab })); }, [activeRequest.id, setActiveTabs], ); useRequestEditorEvent('request_pane.focus_tab', () => { setActiveTab(TAB_PARAMS); }); 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( (e: ClipboardEvent, text: string) => { if (text.startsWith('curl ')) { importCurl({ overwriteRequestId: activeRequestId, command: text }); } else { const data = prepareImportQuerystring(text); if (data != null) { e.preventDefault(); // Prevent input onChange updateRequest({ id: activeRequestId, update: data }); focusParamsTab(); // Wait for request to update, then refresh the UI // TODO: Somehow make this deterministic setTimeout(() => { forceUrlRefresh(); forceParamsRefresh(); }, 100); } } }, [ activeRequestId, focusParamsTab, forceParamsRefresh, forceUrlRefresh, importCurl, updateRequest, ], ); const handleSend = useCallback( () => sendRequest(activeRequest.id ?? null), [activeRequest.id, sendRequest], ); const handleMethodChange = useCallback( (method: string) => updateRequest({ id: activeRequestId, update: { method } }), [activeRequestId, updateRequest], ); const handleUrlChange = useCallback( (url: string) => updateRequest({ id: activeRequestId, update: { url } }), [activeRequestId, updateRequest], ); return (
{activeRequest && ( <> updateRequest({ id: activeRequestId, update: { headers } })} /> updateRequest({ id: activeRequestId, update: { 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 ? ( updateRequest({ id: activeRequestId, update: { body } })} onChangeContentType={handleContentTypeChange} /> ) : typeof activeRequest.bodyType === 'string' ? ( ) : ( No Body )}
updateRequest({ id: activeRequestId, update: { name } })} /> updateRequest({ id: activeRequestId, update: { description } }) } />
)}
); }