From f8936e7b76b76ffc3fcd5f09616aead1b27cd1d5 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 29 Aug 2024 10:52:41 -0700 Subject: [PATCH] Detect JSON APIs returning HTML content-type --- src-web/components/ResponsePane.tsx | 11 ++---- .../responseViewers/HTMLOrTextViewer.tsx | 37 +++++++++++++++++++ .../components/responseViewers/TextViewer.tsx | 28 +++++++------- src-web/hooks/useResponseBodyText.ts | 2 +- src-web/lib/contentType.ts | 9 +++++ 5 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 src-web/components/responseViewers/HTMLOrTextViewer.tsx diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index 9d5d425e..0aec0b1f 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -1,3 +1,4 @@ +import type { HttpRequest } from '@yaakapp/api'; import classNames from 'classnames'; import type { CSSProperties } from 'react'; import { memo, useMemo } from 'react'; @@ -5,7 +6,6 @@ import { createGlobalState } from 'react-use'; import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; -import type { HttpRequest } from '@yaakapp/api'; import { isResponseLoading } from '../lib/models'; import { Banner } from './core/Banner'; import { CountBadge } from './core/CountBadge'; @@ -23,11 +23,10 @@ import { ResponseHeaders } from './ResponseHeaders'; import { ResponseInfo } from './ResponseInfo'; import { AudioViewer } from './responseViewers/AudioViewer'; import { CsvViewer } from './responseViewers/CsvViewer'; +import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer'; import { ImageViewer } from './responseViewers/ImageViewer'; import { PdfViewer } from './responseViewers/PdfViewer'; -import { TextViewer } from './responseViewers/TextViewer'; import { VideoViewer } from './responseViewers/VideoViewer'; -import { WebPageViewer } from './responseViewers/WebPageViewer'; interface Props { style?: CSSProperties; @@ -171,13 +170,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ ) : contentType?.match(/csv|tab-separated/) ? ( - ) : viewMode === 'pretty' && contentType?.includes('html') ? ( - ) : ( // ) : viewMode === 'pretty' && contentType?.includes('json') ? ( // - diff --git a/src-web/components/responseViewers/HTMLOrTextViewer.tsx b/src-web/components/responseViewers/HTMLOrTextViewer.tsx new file mode 100644 index 00000000..c63b5eef --- /dev/null +++ b/src-web/components/responseViewers/HTMLOrTextViewer.tsx @@ -0,0 +1,37 @@ +import type { HttpResponse } from '@yaakapp/api'; +import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders'; +import { useResponseBodyText } from '../../hooks/useResponseBodyText'; +import { isJSON, languageFromContentType } from '../../lib/contentType'; +import { BinaryViewer } from './BinaryViewer'; +import { TextViewer } from './TextViewer'; +import { WebPageViewer } from './WebPageViewer'; + +interface Props { + response: HttpResponse; + pretty: boolean; + textViewerClassName?: string; +} + +export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { + const rawBody = useResponseBodyText(response); + let language = languageFromContentType(useContentTypeFromHeaders(response.headers)); + + // A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so + if (language === 'html' && isJSON(rawBody.data ?? '')) { + language = 'json'; + } + + if (rawBody.isLoading) { + return null; + } + + if (rawBody.data == null) { + return ; + } + + if (language === 'html' && pretty) { + return ; + } else { + return ; + } +} diff --git a/src-web/components/responseViewers/TextViewer.tsx b/src-web/components/responseViewers/TextViewer.tsx index 76b5ca7e..c19ffd73 100644 --- a/src-web/components/responseViewers/TextViewer.tsx +++ b/src-web/components/responseViewers/TextViewer.tsx @@ -9,7 +9,7 @@ import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; import { useSaveResponse } from '../../hooks/useSaveResponse'; import { useToggle } from '../../hooks/useToggle'; -import { languageFromContentType } from '../../lib/contentType'; +import { isJSON, languageFromContentType } from '../../lib/contentType'; import { tryFormatJson, tryFormatXml } from '../../lib/formatters'; import { CopyButton } from '../CopyButton'; import { Banner } from '../core/Banner'; @@ -46,9 +46,15 @@ export function TextViewer({ response, pretty, className }: Props) { [setFilterTextMap, response], ); - const saveResponse = useSaveResponse(response); - const contentType = useContentTypeFromHeaders(response.headers); const rawBody = useResponseBodyText(response); + const saveResponse = useSaveResponse(response); + let language = languageFromContentType(useContentTypeFromHeaders(response.headers)); + + // A lot of APIs return JSON with `text/html` content type, so interpret as JSON if so + if (language === 'html' && isJSON(rawBody.data ?? '')) { + language = 'json'; + } + const isSearching = filterText != null; const filteredResponse = useFilterResponse({ @@ -64,11 +70,7 @@ export function TextViewer({ response, pretty, className }: Props) { } }, [isSearching, setFilterText]); - console.log('HELLO', contentType); - - const isJson = contentType?.includes('json'); - const isXml = contentType?.includes('xml') || contentType?.includes('html'); - const canFilter = isJson || isXml; + const canFilter = language === 'json' || language === 'xml' || language === 'html'; const actions = useMemo(() => { const nodes: ReactNode[] = []; @@ -85,7 +87,7 @@ export function TextViewer({ response, pretty, className }: Props) { autoFocus containerClassName="bg-surface" size="sm" - placeholder={isJson ? 'JSONPath expression' : 'XPath expression'} + placeholder={language === 'json' ? 'JSONPath expression' : 'XPath expression'} label="Filter expression" name="filter" defaultValue={filterText} @@ -112,8 +114,8 @@ export function TextViewer({ response, pretty, className }: Props) { canFilter, filterText, filteredResponse.error, - isJson, isSearching, + language, response.id, setFilterText, toggleSearch, @@ -156,9 +158,9 @@ export function TextViewer({ response, pretty, className }: Props) { } const formattedBody = - pretty && contentType?.includes('json') + pretty && language === 'json' ? tryFormatJson(rawBody.data) - : pretty && contentType?.includes('xml') + : pretty && (language === 'xml' || language === 'html') ? tryFormatXml(rawBody.data) : rawBody.data; @@ -179,7 +181,7 @@ export function TextViewer({ response, pretty, className }: Props) { className={className} forceUpdateKey={body} defaultValue={body} - language={languageFromContentType(contentType)} + language={language} actions={actions} extraExtensions={extraExtensions} /> diff --git a/src-web/hooks/useResponseBodyText.ts b/src-web/hooks/useResponseBodyText.ts index 006804d7..4ee80f48 100644 --- a/src-web/hooks/useResponseBodyText.ts +++ b/src-web/hooks/useResponseBodyText.ts @@ -4,7 +4,7 @@ import { getResponseBodyText } from '../lib/responseBody'; export function useResponseBodyText(response: HttpResponse) { return useQuery({ - queryKey: ['response-body-text', response?.updatedAt], + queryKey: ['response-body-text', response.id, response?.updatedAt], queryFn: () => getResponseBodyText(response), }); } diff --git a/src-web/lib/contentType.ts b/src-web/lib/contentType.ts index 7d85103c..4329160f 100644 --- a/src-web/lib/contentType.ts +++ b/src-web/lib/contentType.ts @@ -14,3 +14,12 @@ export function languageFromContentType(contentType: string | null): EditorProps return 'text'; } } + +export function isJSON(text: string): boolean { + try { + JSON.parse(text); + return true; + } catch (_) { + return false; + } +}