diff --git a/examples/goose-quotes/src/index.ts b/examples/goose-quotes/src/index.ts index cfd249443..bf917b19d 100644 --- a/examples/goose-quotes/src/index.ts +++ b/examples/goose-quotes/src/index.ts @@ -2,7 +2,7 @@ import { Hono, type HonoRequest } from "hono"; import { instrument, measure } from "@fiberplane/hono-otel"; import { neon } from "@neondatabase/serverless"; -import { asc, eq, ilike } from "drizzle-orm"; +import { asc, eq, ilike, isNull, not } from "drizzle-orm"; import { drizzle } from "drizzle-orm/neon-http"; import { @@ -72,6 +72,27 @@ app.get("/api/geese", async (c) => { return c.json(searchResults); }); +/** + * Search for geese with avatars + */ +app.get("/api/geese-with-avatar", async (c) => { + const sql = neon(c.env.DATABASE_URL); + const db = drizzle(sql); + + console.log("Fetching geese with avatars"); + + const geeseWithAvatars = await measure("getGeeseWithAvatars", () => + db + .select() + .from(geese) + .where(not(isNull(geese.avatar))) + .orderBy(asc(geese.id)), + )(); + + console.log(`Found ${geeseWithAvatars.length} geese with avatars`); + return c.json(geeseWithAvatars.map((g) => g.id)); +}); + /** * Create a Goose and return the Goose * diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx deleted file mode 100644 index f2acd091f..000000000 --- a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { CodeMirrorJsonEditor } from "@/components/CodeMirrorEditor"; -import { SubSectionHeading } from "@/components/Timeline"; -import { TextOrJsonViewer } from "@/components/Timeline/DetailsList/TextJsonViewer"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { cn, isJson, noop, safeParseJson } from "@/utils"; -import { - CaretDownIcon, - CaretRightIcon, - LinkBreak2Icon, - QuestionMarkIcon, -} from "@radix-ui/react-icons"; -import { useMemo, useState } from "react"; -import type { ProxiedRequestResponse } from "../queries"; -import { - type RequestorActiveResponse, - isRequestorActiveResponse, -} from "../store/types"; - -export function ResponseBody({ - response, - className, -}: { - response?: ProxiedRequestResponse | RequestorActiveResponse; - className?: string; -}) { - const isFailure = isRequestorActiveResponse(response) - ? response?.isFailure - : response?.app_responses?.isFailure; - - // This means we couldn't even contact the service - if (isFailure) { - return ; - } - - if (isRequestorActiveResponse(response)) { - const body = response?.responseBody; - if (body?.type === "error") { - return ; - } - - if (body?.type === "text" || body?.type === "html") { - return ( -
- -
- ); - } - - if (body?.type === "json") { - const prettyBody = JSON.stringify(safeParseJson(body.value), null, 2); - - return ( -
- -
- ); - } - - // TODO - if (body?.type === "binary") { - return ( -
- - - -
- ); - } - - // TODO - Stylize - if (body?.type === "unknown") { - return ; - } - - return ; - } - - if (!isRequestorActiveResponse(response)) { - const body = response?.app_responses?.responseBody; - - // Special rendering for JSON - if (body && isJson(body)) { - const prettyBody = JSON.stringify(JSON.parse(body), null, 2); - - return ( -
- -
- ); - } - - // For text responses, just split into lines and render with rudimentary line numbers - // TODO - if response is empty, show that in a ux friendly way, with 204 for example - - return ( -
- -
- ); - } -} - -function UnknownResponse({ - className, -}: { - className?: string; -}) { - return ( -
- -
- - - Unknown response type, cannot render body - -
-
-
- ); -} - -function ResponseBodyBinary({ - body, -}: { - body: { contentType: string; type: "binary"; value: ArrayBuffer }; -}) { - const isImage = body.contentType.startsWith("image/"); - - if (isImage) { - const blob = new Blob([body.value], { type: body.contentType }); - const imageUrl = URL.createObjectURL(blob); - return ( - Response URL.revokeObjectURL(imageUrl)} - /> - ); - } - - // TODO - Stylize - return
Binary response {body.contentType}
; -} - -export function ResponseBodyText({ - body, - maxPreviewLength = null, - maxPreviewLines = null, - defaultExpanded = false, - className, -}: { - body: string; - maxPreviewLength?: number | null; - maxPreviewLines?: number | null; - defaultExpanded?: boolean; - className?: string; -}) { - const [isExpanded, setIsExpanded] = useState(!!defaultExpanded); - const toggleIsExpanded = () => setIsExpanded((e) => !e); - - // For text responses, just split into lines and render with rudimentary line numbers - const { lines, hiddenLinesCount, hiddenCharsCount, shouldShowExpandButton } = - useTextPreview(body, isExpanded, maxPreviewLength, maxPreviewLines); - - // TODO - if response is empty, show that in a ux friendly way, with 204 for example - - return ( -
-
-        {lines}
-      
- {shouldShowExpandButton && ( -
- {!isExpanded && ( -
- {hiddenLinesCount > 0 ? ( - <>{hiddenLinesCount} lines hidden - ) : ( - <>{hiddenCharsCount} characters hidden - )} -
- )} - -
- )} -
- ); -} - -function useTextPreview( - body: string, - isExpanded: boolean, - maxPreviewLength: number | null, - maxPreviewLines: number | null, -) { - return useMemo(() => { - let hiddenCharsCount = 0; - let hiddenLinesCount = 0; - const allLinesCount = body.split("\n")?.length; - - const exceedsMaxPreviewLength = maxPreviewLength - ? body.length > maxPreviewLength - : false; - - const exceedsMaxPreviewLines = maxPreviewLines - ? allLinesCount > maxPreviewLines - : false; - - // If we're not expanded, we want to show a preview of the body depending on the maxPreviewLength - let previewBody = body; - if (maxPreviewLength && exceedsMaxPreviewLength && !isExpanded) { - previewBody = body ? `${body.slice(0, maxPreviewLength)}...` : ""; - hiddenCharsCount = body.length - maxPreviewLength; - } - - let previewLines = previewBody?.split("\n"); - if ( - maxPreviewLines && - !isExpanded && - previewLines.length > maxPreviewLines - ) { - previewLines = previewLines.slice(0, maxPreviewLines); - previewBody = `${previewLines.join("\n")}...`; - hiddenLinesCount = allLinesCount - previewLines.length; - } - - const lines = (isExpanded ? body : previewBody) - ?.split("\n") - ?.map((line, index) => ( -
- - {index + 1} - - {line} -
- )); - - return { - lines, - shouldShowExpandButton: exceedsMaxPreviewLength || exceedsMaxPreviewLines, - hiddenLinesCount, - hiddenCharsCount, - }; - }, [body, maxPreviewLines, maxPreviewLength, isExpanded]); -} - -export function FailedRequest({ - response, -}: { response?: ProxiedRequestResponse | RequestorActiveResponse }) { - // TODO - Show a more friendly error message - const failureReason = isRequestorActiveResponse(response) - ? null - : response?.app_responses?.failureReason; - const friendlyMessage = - failureReason === "fetch failed" ? "Service unreachable" : null; - // const failureDetails = response?.app_responses?.failureDetails; - return ( -
-
- -
- {friendlyMessage - ? `Request failed: ${friendlyMessage}` - : "Request failed"} -
-
- Make sure your api is up and has FPX Middleware enabled! -
-
-
- ); -} - -function CollapsibleBodyContainer({ - className, - defaultCollapsed = false, - title = "Body", - children, -}: { - emptyMessage?: string; - className?: string; - defaultCollapsed?: boolean; - title?: string; - children: React.ReactNode; -}) { - const [isOpen, setIsOpen] = useState(!defaultCollapsed); - const toggleIsOpen = () => setIsOpen((o) => !o); - - return ( -
- - - - {isOpen ? ( - - ) : ( - - )} - {title} - - - {children} - -
- ); -} diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/FailedRequest.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/FailedRequest.tsx new file mode 100644 index 000000000..007a37007 --- /dev/null +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/FailedRequest.tsx @@ -0,0 +1,33 @@ +import { LinkBreak2Icon } from "@radix-ui/react-icons"; +import type { ProxiedRequestResponse } from "../../queries"; +import { + type RequestorActiveResponse, + isRequestorActiveResponse, +} from "../../store/types"; + +export function FailedRequest({ + response, +}: { response?: ProxiedRequestResponse | RequestorActiveResponse }) { + // TODO - Show a more friendly error message + const failureReason = isRequestorActiveResponse(response) + ? null + : response?.app_responses?.failureReason; + const friendlyMessage = + failureReason === "fetch failed" ? "Service unreachable" : null; + // const failureDetails = response?.app_responses?.failureDetails; + return ( +
+
+ +
+ {friendlyMessage + ? `Request failed: ${friendlyMessage}` + : "Request failed"} +
+
+ Make sure your api is up and has FPX Middleware enabled! +
+
+
+ ); +} diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBody.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBody.tsx new file mode 100644 index 000000000..1c3929e89 --- /dev/null +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBody.tsx @@ -0,0 +1,178 @@ +import { CodeMirrorJsonEditor } from "@/components/CodeMirrorEditor"; +import { SubSectionHeading } from "@/components/Timeline"; +import { TextOrJsonViewer } from "@/components/Timeline/DetailsList/TextJsonViewer"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn, isJson, noop, safeParseJson } from "@/utils"; +import { + CaretDownIcon, + CaretRightIcon, + QuestionMarkIcon, +} from "@radix-ui/react-icons"; +import { useState } from "react"; +import type { ProxiedRequestResponse } from "../../queries"; +import { + type RequestorActiveResponse, + isRequestorActiveResponse, +} from "../../store/types"; +import { FailedRequest } from "./FailedRequest"; +import { ResponseBodyBinary } from "./ResponseBodyBinary"; +import { ResponseBodyText } from "./ResponseBodyText"; + +export function ResponseBody({ + response, + className, +}: { + response?: ProxiedRequestResponse | RequestorActiveResponse; + className?: string; +}) { + const isFailure = isRequestorActiveResponse(response) + ? response?.isFailure + : response?.app_responses?.isFailure; + + // This means we couldn't even contact the service + if (isFailure) { + return ; + } + + // NOTE - This means we have the *actual* response from the service in the store, + // which may contain binary data that we can render in the UI. + // This is different from history responses, which will only have whatever data was stored in the trace + if (isRequestorActiveResponse(response)) { + const body = response?.responseBody; + if (body?.type === "error") { + return ; + } + + if (body?.type === "text" || body?.type === "html") { + return ( +
+ +
+ ); + } + + if (body?.type === "json") { + const prettyBody = JSON.stringify(safeParseJson(body.value), null, 2); + + return ( +
+ +
+ ); + } + + if (body?.type === "binary") { + return ( +
+ + + +
+ ); + } + + // TODO - Stylize + if (body?.type === "unknown") { + return ; + } + + return ; + } + + if (!isRequestorActiveResponse(response)) { + const body = response?.app_responses?.responseBody; + + // Special rendering for JSON + if (body && isJson(body)) { + const prettyBody = JSON.stringify(JSON.parse(body), null, 2); + + return ( +
+ +
+ ); + } + + // For text responses, just split into lines and render with rudimentary line numbers + // TODO - if response is empty, show that in a ux friendly way, with 204 for example + + return ( +
+ +
+ ); + } +} + +function UnknownResponse({ + className, +}: { + className?: string; +}) { + return ( +
+ +
+ + + Unknown response type, cannot render body + +
+
+
+ ); +} + +function CollapsibleBodyContainer({ + className, + defaultCollapsed = false, + title = "Body", + children, +}: { + emptyMessage?: string; + className?: string; + defaultCollapsed?: boolean; + title?: string; + children: React.ReactNode; +}) { + const [isOpen, setIsOpen] = useState(!defaultCollapsed); + const toggleIsOpen = () => setIsOpen((o) => !o); + + return ( +
+ + + + {isOpen ? ( + + ) : ( + + )} + {title} + + + {children} + +
+ ); +} diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyBinary.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyBinary.tsx new file mode 100644 index 000000000..cd7370e5c --- /dev/null +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyBinary.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; + +function ImageBody({ + value, + contentType, +}: { value: ArrayBuffer; contentType: string }) { + const [imageUrl, setImageUrl] = useState(null); + + useEffect(() => { + const blob = new Blob([value], { type: contentType }); + const url = URL.createObjectURL(blob); + setImageUrl(url); + + return () => { + URL.revokeObjectURL(url); + }; + }, [value, contentType]); + + if (!imageUrl) { + return null; + } + + return Response; +} + +function AudioBody({ + value, + contentType, +}: { value: ArrayBuffer; contentType: string }) { + const [audioUrl, setAudioUrl] = useState(null); + + useEffect(() => { + const blob = new Blob([value], { type: contentType }); + const url = URL.createObjectURL(blob); + setAudioUrl(url); + + return () => { + URL.revokeObjectURL(url); + }; + }, [value, contentType]); + + if (!audioUrl) { + return null; + } + + return ( + // biome-ignore lint/a11y/useMediaCaption: we do not have captions + + ); +} + +export function ResponseBodyBinary({ + body, +}: { + body: { contentType: string; type: "binary"; value: ArrayBuffer }; +}) { + const isImage = body.contentType.startsWith("image/"); + const isAudio = body.contentType.startsWith("audio/"); + + if (isImage) { + return ; + } + + if (isAudio) { + return ; + } + + // TODO - Stylize for other binary types + return
Binary response: {body.contentType}
; +} diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyText.tsx b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyText.tsx new file mode 100644 index 000000000..370f92884 --- /dev/null +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/ResponseBodyText.tsx @@ -0,0 +1,117 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils"; +import { useMemo, useState } from "react"; + +export function ResponseBodyText({ + body, + maxPreviewLength = null, + maxPreviewLines = null, + defaultExpanded = false, + className, +}: { + body: string; + maxPreviewLength?: number | null; + maxPreviewLines?: number | null; + defaultExpanded?: boolean; + className?: string; +}) { + const [isExpanded, setIsExpanded] = useState(!!defaultExpanded); + const toggleIsExpanded = () => setIsExpanded((e) => !e); + + // For text responses, just split into lines and render with rudimentary line numbers + const { lines, hiddenLinesCount, hiddenCharsCount, shouldShowExpandButton } = + useTextPreview(body, isExpanded, maxPreviewLength, maxPreviewLines); + + // TODO - if response is empty, show that in a ux friendly way, with 204 for example + + return ( +
+
+        {lines}
+      
+ {shouldShowExpandButton && ( +
+ {!isExpanded && ( +
+ {hiddenLinesCount > 0 ? ( + <>{hiddenLinesCount} lines hidden + ) : ( + <>{hiddenCharsCount} characters hidden + )} +
+ )} + +
+ )} +
+ ); +} + +function useTextPreview( + body: string, + isExpanded: boolean, + maxPreviewLength: number | null, + maxPreviewLines: number | null, +) { + return useMemo(() => { + let hiddenCharsCount = 0; + let hiddenLinesCount = 0; + const allLinesCount = body.split("\n")?.length; + + const exceedsMaxPreviewLength = maxPreviewLength + ? body.length > maxPreviewLength + : false; + + const exceedsMaxPreviewLines = maxPreviewLines + ? allLinesCount > maxPreviewLines + : false; + + // If we're not expanded, we want to show a preview of the body depending on the maxPreviewLength + let previewBody = body; + if (maxPreviewLength && exceedsMaxPreviewLength && !isExpanded) { + previewBody = body ? `${body.slice(0, maxPreviewLength)}...` : ""; + hiddenCharsCount = body.length - maxPreviewLength; + } + + let previewLines = previewBody?.split("\n"); + if ( + maxPreviewLines && + !isExpanded && + previewLines.length > maxPreviewLines + ) { + previewLines = previewLines.slice(0, maxPreviewLines); + previewBody = `${previewLines.join("\n")}...`; + hiddenLinesCount = allLinesCount - previewLines.length; + } + + const lines = (isExpanded ? body : previewBody) + ?.split("\n") + ?.map((line, index) => ( +
+ + {index + 1} + + {line} +
+ )); + + return { + lines, + shouldShowExpandButton: exceedsMaxPreviewLength || exceedsMaxPreviewLines, + hiddenLinesCount, + hiddenCharsCount, + }; + }, [body, maxPreviewLines, maxPreviewLength, isExpanded]); +} diff --git a/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/index.ts b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/index.ts new file mode 100644 index 000000000..56e3c2963 --- /dev/null +++ b/studio/src/pages/RequestorPage/ResponsePanel/ResponseBody/index.ts @@ -0,0 +1,3 @@ +export * from "./ResponseBody"; +export * from "./ResponseBodyText"; +export * from "./FailedRequest"; diff --git a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts index 07d2ca528..4c1509ece 100644 --- a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts +++ b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts @@ -193,7 +193,13 @@ export const requestResponseSlice: StateCreator< showResponseBodyFromHistory: (traceId) => set((state) => { state.activeHistoryResponseTraceId = traceId; - state.activeResponse = null; + // Recall that an 'active response' is one for which we will have the body we received from the service + // This means it can contain binary data, whereas the history response will not + // We should prefer to keep the active response as response to render, so long as its traceId matches the current history response traceId + const activeTraceId = state.activeResponse?.traceId; + if (!activeTraceId || activeTraceId !== traceId) { + state.activeResponse = null; + } }), clearResponseBodyFromHistory: () => set((state) => { diff --git a/www/src/content/changelog/!canary.mdx b/www/src/content/changelog/!canary.mdx index 22f44edcd..1ab032dac 100644 --- a/www/src/content/changelog/!canary.mdx +++ b/www/src/content/changelog/!canary.mdx @@ -12,6 +12,8 @@ draft: true - **Improved Route Updating** We've added some improvements to how we refresh your list of API routes in the Studio. +- **Render audio files** When your api returns a binary audio response, we will render an audio player for you to listen to it. + ### Bug fixes - **D1 autoinstrumentation** The client library, `@fiberplane/hono-otel`, now instruments D1 queries in latest versions of wrangler/miniflare. @@ -21,3 +23,5 @@ draft: true - **Side panel not closing** Fixed an issue where the side panel would not close when clicking outside of it. - **Missing log information** Fixed an issue where logs would not be displayed fully in the logs panel. + +- **Render images returned from API** Fixed an issue where images returned from an API request made through Studio would not be rendered in the response panel.