From 7ee919b8270162e702b248bbdef83943f857003a Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Mon, 15 Jul 2024 18:13:41 -0400 Subject: [PATCH] feat: implement global auth state in the api playground (#1162) --- .../PlaygroundAuthorizationForm.tsx | 217 +++++++----------- .../src/api-playground/PlaygroundEndpoint.tsx | 36 ++- .../PlaygroundEndpointContent.tsx | 39 +--- .../PlaygroundRequestPreview.tsx | 9 +- .../api-playground/PlaygroundWebSocket.tsx | 29 ++- .../PlaygroundWebSocketHandshakeForm.tsx | 14 +- .../ui/app/src/api-playground/types/auth.ts | 43 ++-- .../ui/app/src/api-playground/types/index.ts | 3 - packages/ui/app/src/api-playground/utils.ts | 188 +++++---------- packages/ui/app/src/atoms/playground.ts | 70 ++++-- .../src/atoms/utils/atomWithStorageString.ts | 83 +------ .../atoms/utils/atomWithStorageValidation.ts | 95 ++++++++ 12 files changed, 399 insertions(+), 427 deletions(-) create mode 100644 packages/ui/app/src/atoms/utils/atomWithStorageValidation.ts diff --git a/packages/ui/app/src/api-playground/PlaygroundAuthorizationForm.tsx b/packages/ui/app/src/api-playground/PlaygroundAuthorizationForm.tsx index 57784bdbd1..5738d98be3 100644 --- a/packages/ui/app/src/api-playground/PlaygroundAuthorizationForm.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundAuthorizationForm.tsx @@ -3,37 +3,33 @@ import { FernButton, FernCard, FernCollapse, FernInput } from "@fern-ui/componen import { visitDiscriminatedUnion } from "@fern-ui/core-utils"; import { useBooleanState } from "@fern-ui/react-commons"; import { GlobeIcon, PersonIcon } from "@radix-ui/react-icons"; +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { isEmpty } from "lodash-es"; import { useRouter } from "next/router"; -import { Dispatch, FC, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react"; +import { FC, ReactElement, SetStateAction, useCallback, useEffect, useState } from "react"; import { Key } from "react-feather"; +import { useMemoOne } from "use-memo-one"; +import { + PLAYGROUND_AUTH_STATE_ATOM, + PLAYGROUND_AUTH_STATE_BASIC_AUTH_ATOM, + PLAYGROUND_AUTH_STATE_BEARER_TOKEN_ATOM, + PLAYGROUND_AUTH_STATE_HEADER_ATOM, +} from "../atoms"; import { Callout } from "../mdx/components/callout"; import { useApiKeyInjectionConfig } from "../services/useApiKeyInjectionConfig"; import { PasswordInputGroup } from "./PasswordInputGroup"; import { PlaygroundSecretsModal, SecretBearer } from "./PlaygroundSecretsModal"; -import { PlaygroundRequestFormAuth } from "./types"; +import { PlaygroundAuthState } from "./types"; interface PlaygroundAuthorizationFormProps { auth: APIV1Read.ApiAuth; - value: PlaygroundRequestFormAuth | undefined; - onChange: (newAuthValue: PlaygroundRequestFormAuth) => void; disabled: boolean; } -function BearerAuthForm({ - bearerAuth, - value, - onChange, - disabled, -}: { bearerAuth: APIV1Read.BearerAuth } & Omit) { - const handleChange = useCallback( - (newValue: string) => - onChange({ - type: "bearerAuth", - token: newValue, - }), - [onChange], - ); +function BearerAuthForm({ bearerAuth, disabled }: { bearerAuth: APIV1Read.BearerAuth; disabled?: boolean }) { + const [value, setValue] = useAtom(PLAYGROUND_AUTH_STATE_BEARER_TOKEN_ATOM); + const handleChange = useCallback((newValue: string) => setValue({ token: newValue }), [setValue]); + const { value: isSecretsModalOpen, setTrue: openSecretsModal, @@ -55,22 +51,20 @@ function BearerAuthForm({
- {value?.type === "bearerAuth" && ( - } - variant="minimal" - /> - } - disabled={disabled} - /> - )} + } + variant="minimal" + /> + } + disabled={disabled} + />
) { +function BasicAuthForm({ basicAuth, disabled }: { basicAuth: APIV1Read.BasicAuth; disabled?: boolean }) { + const [value, setValue] = useAtom(PLAYGROUND_AUTH_STATE_BASIC_AUTH_ATOM); const handleChangeUsername = useCallback( - (newValue: string) => - onChange({ - type: "basicAuth", - username: newValue, - password: value?.type === "basicAuth" ? value.password : "", - }), - [onChange, value], + (newValue: string) => setValue((prev) => ({ ...prev, username: newValue })), + [setValue], ); const handleChangePassword = useCallback( - (newValue: string) => - onChange({ - type: "basicAuth", - username: value?.type === "basicAuth" ? value.username : "", - password: newValue, - }), - [onChange, value], + (newValue: string) => setValue((prev) => ({ ...prev, password: newValue })), + [setValue], ); const { value: isSecretsModalOpen, @@ -128,7 +108,7 @@ function BasicAuthForm({
} rightElement={{"string"}} disabled={disabled} @@ -144,7 +124,7 @@ function BasicAuthForm({
) { - const handleChange = useCallback( - (newValue: string) => - onChange({ - type: "header", - headers: { [header.headerWireValue]: newValue }, - }), - [header.headerWireValue, onChange], +function HeaderAuthForm({ header, disabled }: { header: APIV1Read.HeaderAuth; disabled?: boolean }) { + const [value, setValue] = useAtom( + useMemoOne( + () => + atom( + (get) => get(PLAYGROUND_AUTH_STATE_HEADER_ATOM).headers[header.headerWireValue], + (_get, set, change: SetStateAction) => { + set(PLAYGROUND_AUTH_STATE_HEADER_ATOM, ({ headers }) => ({ + headers: { + ...headers, + [header.headerWireValue]: + typeof change === "function" ? change(headers[header.headerWireValue]) : change, + }, + })); + }, + ), + [header.headerWireValue], + ), ); const { value: isSecretsModalOpen, @@ -189,9 +174,9 @@ function HeaderAuthForm({ const handleSelectSecret = useCallback( (secret: SecretBearer) => { closeSecretsModal(); - handleChange(secret.token); + setValue(secret.token); }, - [closeSecretsModal, handleChange], + [closeSecretsModal, setValue], ); return ( @@ -201,8 +186,8 @@ function HeaderAuthForm({
= ({ - auth, - value, - onChange, - disabled, -}) => { +export const PlaygroundAuthorizationForm: FC = ({ auth, disabled }) => { return (
    {visitDiscriminatedUnion(auth, "type")._visit({ - bearerAuth: (bearerAuth) => ( - - ), - basicAuth: (basicAuth) => ( - - ), - header: (header) => ( - - ), + bearerAuth: (bearerAuth) => , + basicAuth: (basicAuth) => , + header: (header) => , _other: () => null, })}
@@ -251,23 +225,25 @@ export const PlaygroundAuthorizationForm: FC = interface PlaygroundAuthorizationFormCardProps { auth: APIV1Read.ApiAuth; - authState: PlaygroundRequestFormAuth | undefined; - setAuthorization: Dispatch>; disabled: boolean; } export function PlaygroundAuthorizationFormCard({ auth, - authState, - setAuthorization, disabled, }: PlaygroundAuthorizationFormCardProps): ReactElement | null { + const authState = useAtomValue(PLAYGROUND_AUTH_STATE_ATOM); + const setBearerAuth = useSetAtom(PLAYGROUND_AUTH_STATE_BEARER_TOKEN_ATOM); const isOpen = useBooleanState(false); const apiKeyInjection = useApiKeyInjectionConfig(); const router = useRouter(); const apiKey = apiKeyInjection.enabled && apiKeyInjection.authenticated ? apiKeyInjection.access_token : null; const [loginError, setLoginError] = useState(null); + const handleResetBearerAuth = useCallback(() => { + setBearerAuth({ token: apiKey ?? "" }); + }, [apiKey, setBearerAuth]); + const redirectOrOpenAuthForm = () => { if (apiKeyInjection.enabled && !apiKeyInjection.authenticated) { // const redirect_uri = urlJoin(window.location.origin, basePath ?? "", "/api/fern-docs/auth/login"), @@ -297,19 +273,16 @@ export function PlaygroundAuthorizationFormCard({ } }, [router.query, router.isReady]); - // TODO change this login + // TODO change this to on-login useEffect(() => { - if (apiKey && authState && authState.type === "bearerAuth") { - if (authState.token === "") { - setAuthorization({ type: "bearerAuth", token: apiKey }); - } + if (apiKey != null) { + setBearerAuth({ token: apiKey }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [apiKey]); + }, [apiKey, setBearerAuth]); return (
- {apiKeyInjection.enabled && !apiKey && ( + {apiKeyInjection.enabled && apiKey == null && ( <> {loginError && {loginError}} @@ -336,7 +309,7 @@ export function PlaygroundAuthorizationFormCard({ )} - {apiKeyInjection.enabled && apiKey && ( + {apiKeyInjection.enabled && apiKey != null && ( <>
- +
- {authState?.type === "bearerAuth" && apiKey !== authState.token && ( + {apiKey !== authState?.bearerAuth?.token && (
{apiKey && ( } - onClick={() => setAuthorization({ type: "bearerAuth", token: apiKey })} + onClick={handleResetBearerAuth} size="normal" variant="outlined" /> @@ -374,7 +342,7 @@ export function PlaygroundAuthorizationFormCard({ )} - {!apiKeyInjection.enabled && (isAuthed(auth, authState) || apiKey) && ( + {!apiKeyInjection.enabled && (isAuthed(auth, authState) || apiKey != null) && ( )} - {!apiKeyInjection.enabled && !(isAuthed(auth, authState) || apiKey) && ( + {!apiKeyInjection.enabled && !(isAuthed(auth, authState) || apiKey != null) && (
- +
- {apiKey && ( + {apiKey != null && ( setAuthorization({ type: "bearerAuth", token: apiKey })} + onClick={handleResetBearerAuth} size="normal" variant="outlined" /> @@ -437,18 +400,12 @@ export function PlaygroundAuthorizationFormCard({ ); } -function isAuthed(auth: APIV1Read.ApiAuth, authState: PlaygroundRequestFormAuth | undefined): boolean { - if (authState == null) { - return false; - } - - return visitDiscriminatedUnion(auth, "type")._visit({ - bearerAuth: () => authState.type === "bearerAuth" && !isEmpty(authState.token.trim()), +function isAuthed(auth: APIV1Read.ApiAuth, authState: PlaygroundAuthState): boolean { + return visitDiscriminatedUnion(auth)._visit({ + bearerAuth: () => !isEmpty(authState.bearerAuth?.token.trim()), basicAuth: () => - authState.type === "basicAuth" && - !isEmpty(authState.username.trim()) && - !isEmpty(authState.password.trim()), - header: (header) => authState.type === "header" && !isEmpty(authState.headers[header.headerWireValue]?.trim()), + !isEmpty(authState.basicAuth?.username.trim()) && !isEmpty(authState.basicAuth?.password.trim()), + header: (header) => !isEmpty(authState.header?.headers[header.headerWireValue].trim()), _other: () => false, }); } diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx index f347f7f601..e16d71f80e 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx @@ -2,13 +2,20 @@ import { FernTooltipProvider } from "@fern-ui/components"; import { assertNever, isNonNullish } from "@fern-ui/core-utils"; import { Loadable, failed, loaded, loading, notStartedLoading } from "@fern-ui/loadable"; import { PaperPlaneIcon } from "@radix-ui/react-icons"; -import { compact, once } from "lodash-es"; +import { compact, mapValues, once } from "lodash-es"; import { FC, ReactElement, useCallback, useState } from "react"; import urljoin from "url-join"; import { useCallbackOne } from "use-memo-one"; import { capturePosthogEvent } from "../analytics/posthog"; import { captureSentryError } from "../analytics/sentry"; -import { useBasePath, useDomain, useFeatureFlags, usePlaygroundEndpointFormState } from "../atoms"; +import { + PLAYGROUND_AUTH_STATE_ATOM, + store, + useBasePath, + useDomain, + useFeatureFlags, + usePlaygroundEndpointFormState, +} from "../atoms"; import { ResolvedEndpointDefinition, ResolvedFormDataRequestProperty, @@ -24,10 +31,11 @@ import { executeProxyStream } from "./fetch-utils/executeProxyStream"; import type { PlaygroundFormStateBody, ProxyRequest, SerializableFile, SerializableFormDataEntryValue } from "./types"; import { PlaygroundResponse } from "./types/playgroundResponse"; import { + buildAuthHeaders, buildEndpointUrl, - buildUnredactedHeaders, getInitialEndpointRequestFormState, getInitialEndpointRequestFormStateWithExample, + unknownToString, } from "./utils"; interface PlaygroundEndpointProps { @@ -55,13 +63,11 @@ export const PlaygroundEndpoint: FC = ({ endpoint, type const [formState, setFormState] = usePlaygroundEndpointFormState(endpoint); const resetWithExample = useCallbackOne(() => { - setFormState( - getInitialEndpointRequestFormStateWithExample(endpoint.auth, endpoint, endpoint.examples[0], types), - ); + setFormState(getInitialEndpointRequestFormStateWithExample(endpoint, endpoint.examples[0], types)); }, [endpoint, types]); const resetWithoutExample = useCallbackOne(() => { - setFormState(getInitialEndpointRequestFormState(endpoint.auth, endpoint, types)); + setFormState(getInitialEndpointRequestFormState(endpoint, types)); }, []); const domain = useDomain(); @@ -90,10 +96,22 @@ export const PlaygroundEndpoint: FC = ({ endpoint, type method: endpoint.method, docsRoute: `/${endpoint.slug}`, }); - const req = { + const authHeaders = buildAuthHeaders(endpoint.auth, store.get(PLAYGROUND_AUTH_STATE_ATOM), { + redacted: false, + }); + const headers = { + ...authHeaders, + ...mapValues(formState.headers ?? {}, unknownToString), + }; + + if (endpoint.method !== "GET" && endpoint.requestBody?.contentType != null) { + headers["Content-Type"] = endpoint.requestBody.contentType; + } + + const req: ProxyRequest = { url: buildEndpointUrl(endpoint, formState), method: endpoint.method, - headers: buildUnredactedHeaders(endpoint, formState), + headers, body: await serializeFormStateBody( uploadEnvironment, endpoint.requestBody?.shape, diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx index 1d102f8b09..b5cd7f7af9 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx @@ -15,10 +15,9 @@ import { useAtom, useAtomValue } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { isEmpty, round } from "lodash-es"; import { Dispatch, FC, SetStateAction, useEffect, useRef, useState } from "react"; -import { IS_MOBILE_SCREEN_ATOM, useDomain, useFeatureFlags } from "../atoms"; +import { IS_MOBILE_SCREEN_ATOM, PLAYGROUND_AUTH_STATE_ATOM, store, useDomain, useFeatureFlags } from "../atoms"; import { FernErrorTag } from "../components/FernErrorBoundary"; import { ResolvedEndpointDefinition, ResolvedTypeDefinition } from "../resolver/types"; -import { useApiKeyInjectionConfig } from "../services/useApiKeyInjectionConfig"; import { PlaygroundAuthorizationFormCard } from "./PlaygroundAuthorizationForm"; import { PlaygroundEndpointForm } from "./PlaygroundEndpointForm"; import { PlaygroundEndpointFormButtons } from "./PlaygroundEndpointFormButtons"; @@ -62,16 +61,6 @@ export const PlaygroundEndpointContent: FC = ({ const isMobileScreen = useAtomValue(IS_MOBILE_SCREEN_ATOM); - const config = useApiKeyInjectionConfig(); - const apiKey = config.enabled && config.authenticated ? config.access_token : null; - - if (apiKey && formState.auth == null) { - formState.auth = { - type: "bearerAuth", - token: apiKey, - }; - } - useEffect(() => { if (typeof window === "undefined" || scrollAreaRef.current == null) { return; @@ -89,19 +78,7 @@ export const PlaygroundEndpointContent: FC = ({ const form = (
- {endpoint.auth != null && ( - - setFormState((oldState) => ({ - ...oldState, - auth: typeof newState === "function" ? newState(oldState.auth) : newState, - })) - } - disabled={false} - /> - )} + {endpoint.auth != null && } = ({ - requestType === "curl" + content={() => { + const authState = store.get(PLAYGROUND_AUTH_STATE_ATOM); + return requestType === "curl" ? stringifyCurl({ endpoint, formState, + authState, redacted: false, domain, }) @@ -166,6 +145,7 @@ export const PlaygroundEndpointContent: FC = ({ ? stringifyFetch({ endpoint, formState, + authState, redacted: false, isSnippetTemplatesEnabled, }) @@ -173,11 +153,12 @@ export const PlaygroundEndpointContent: FC = ({ ? stringifyPythonRequests({ endpoint, formState, + authState, redacted: false, isSnippetTemplatesEnabled, }) - : "" - } + : ""; + }} className="-mr-2" />
diff --git a/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx b/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx index 3a0176b8b4..dacea88519 100644 --- a/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx @@ -1,5 +1,6 @@ +import { useAtomValue } from "jotai"; import { FC, useMemo } from "react"; -import { useDomain, useFeatureFlags } from "../atoms"; +import { PLAYGROUND_AUTH_STATE_ATOM, useDomain, useFeatureFlags } from "../atoms"; import { ResolvedEndpointDefinition } from "../resolver/types"; import { FernSyntaxHighlighter } from "../syntax-highlighting/FernSyntaxHighlighter"; import { PlaygroundEndpointRequestFormState } from "./types"; @@ -13,6 +14,7 @@ interface PlaygroundRequestPreviewProps { export const PlaygroundRequestPreview: FC = ({ endpoint, formState, requestType }) => { const { isSnippetTemplatesEnabled } = useFeatureFlags(); + const authState = useAtomValue(PLAYGROUND_AUTH_STATE_ATOM); const domain = useDomain(); const code = useMemo( () => @@ -20,6 +22,7 @@ export const PlaygroundRequestPreview: FC = ({ en ? stringifyCurl({ endpoint, formState, + authState, redacted: true, domain, }) @@ -27,6 +30,7 @@ export const PlaygroundRequestPreview: FC = ({ en ? stringifyFetch({ endpoint, formState, + authState, redacted: true, isSnippetTemplatesEnabled, }) @@ -34,11 +38,12 @@ export const PlaygroundRequestPreview: FC = ({ en ? stringifyPythonRequests({ endpoint, formState, + authState, redacted: true, isSnippetTemplatesEnabled, }) : "", - [domain, endpoint, formState, isSnippetTemplatesEnabled, requestType], + [authState, domain, endpoint, formState, isSnippetTemplatesEnabled, requestType], ); return ( = ({ websocket, t socket.current = new WebSocket(WEBSOCKET_PROXY_URI); socket.current.onopen = () => { - socket.current?.send( - JSON.stringify({ - type: "handshake", - url, - headers: buildUnredactedHeadersWebsocket(websocket, formState), - }), - ); + const authState = store.get(PLAYGROUND_AUTH_STATE_ATOM); + const authHeaders = buildAuthHeaders(websocket.auth, authState, { redacted: false }); + const headers = { + ...authHeaders, + ...formState.headers, + }; + + socket.current?.send(JSON.stringify({ type: "handshake", url, headers })); }; socket.current.onmessage = (event) => { @@ -98,7 +99,15 @@ export const PlaygroundWebSocket: FC = ({ websocket, t console.error(event); }; }); - }, [formState, pushMessage, websocket]); + }, [ + formState.headers, + formState.pathParameters, + formState.queryParameters, + pushMessage, + websocket.auth, + websocket.defaultEnvironment?.baseUrl, + websocket.path, + ]); const handleSendMessage = useCallback( async (message: ResolvedWebSocketMessage, data: unknown) => { diff --git a/packages/ui/app/src/api-playground/PlaygroundWebSocketHandshakeForm.tsx b/packages/ui/app/src/api-playground/PlaygroundWebSocketHandshakeForm.tsx index c04c072567..4ed18c68b0 100644 --- a/packages/ui/app/src/api-playground/PlaygroundWebSocketHandshakeForm.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundWebSocketHandshakeForm.tsx @@ -71,19 +71,7 @@ export const PlaygroundWebSocketHandshakeForm: FC )} - {websocket.auth != null && ( - - setFormState((oldState) => ({ - ...oldState, - auth: typeof newState === "function" ? newState(oldState.auth) : newState, - })) - } - disabled={disabled} - /> - )} + {websocket.auth != null && }
{websocket.headers.length > 0 && ( diff --git a/packages/ui/app/src/api-playground/types/auth.ts b/packages/ui/app/src/api-playground/types/auth.ts index 0732ef12a4..a41ae50c53 100644 --- a/packages/ui/app/src/api-playground/types/auth.ts +++ b/packages/ui/app/src/api-playground/types/auth.ts @@ -1,22 +1,27 @@ -export declare namespace PlaygroundRequestFormAuth { - interface BearerAuth { - type: "bearerAuth"; - token: string; - } +import { z } from "zod"; - interface Header { - type: "header"; - headers: Record; - } +export const PlaygroundAuthStateBearerTokenSchema = z.strictObject({ + token: z.string(), +}); +export type PlaygroundAuthStateBearerToken = z.infer; +export const PLAYGROUND_AUTH_STATE_BEARER_TOKEN_INITIAL: PlaygroundAuthStateBearerToken = { token: "" }; - interface BasicAuth { - type: "basicAuth"; - username: string; - password: string; - } -} +export const PlaygroundAuthStateHeaderSchema = z.strictObject({ + headers: z.record(z.string()), +}); +export type PlaygroundAuthStateHeader = z.infer; +export const PLAYGROUND_AUTH_STATE_HEADER_INITIAL: PlaygroundAuthStateHeader = { headers: {} }; -export type PlaygroundRequestFormAuth = - | PlaygroundRequestFormAuth.BearerAuth - | PlaygroundRequestFormAuth.Header - | PlaygroundRequestFormAuth.BasicAuth; +export const PlaygroundAuthStateBasicAuthSchema = z.strictObject({ + username: z.string(), + password: z.string(), +}); +export type PlaygroundAuthStateBasicAuth = z.infer; +export const PLAYGROUND_AUTH_STATE_BASIC_AUTH_INITIAL: PlaygroundAuthStateBasicAuth = { username: "", password: "" }; + +export const PlaygroundAuthStateSchema = z.strictObject({ + bearerAuth: PlaygroundAuthStateBearerTokenSchema.optional(), + header: PlaygroundAuthStateHeaderSchema.optional(), + basicAuth: PlaygroundAuthStateBasicAuthSchema.optional(), +}); +export type PlaygroundAuthState = z.infer; diff --git a/packages/ui/app/src/api-playground/types/index.ts b/packages/ui/app/src/api-playground/types/index.ts index 8e30e73ec2..82bf616c39 100644 --- a/packages/ui/app/src/api-playground/types/index.ts +++ b/packages/ui/app/src/api-playground/types/index.ts @@ -1,7 +1,6 @@ import { assertNever } from "@fern-ui/core-utils"; import { compact } from "lodash-es"; import { ResolvedFormDataRequestProperty, ResolvedFormValue } from "../../resolver/types"; -import { PlaygroundRequestFormAuth } from "./auth"; import { PlaygroundFormDataEntryValue } from "./formDataEntryValue"; import { JsonVariant } from "./jsonVariant"; @@ -67,7 +66,6 @@ export type PlaygroundFormStateBody = export interface PlaygroundEndpointRequestFormState { type: "endpoint"; - auth: PlaygroundRequestFormAuth | undefined; headers: Record; pathParameters: Record; queryParameters: Record; @@ -76,7 +74,6 @@ export interface PlaygroundEndpointRequestFormState { export interface PlaygroundWebSocketRequestFormState { type: "websocket"; - auth: PlaygroundRequestFormAuth | undefined; headers: Record; pathParameters: Record; queryParameters: Record; diff --git a/packages/ui/app/src/api-playground/utils.ts b/packages/ui/app/src/api-playground/utils.ts index af9e6d92a0..850b4e2ed4 100644 --- a/packages/ui/app/src/api-playground/utils.ts +++ b/packages/ui/app/src/api-playground/utils.ts @@ -2,7 +2,6 @@ import { APIV1Read, Snippets } from "@fern-api/fdr-sdk"; import { SnippetTemplateResolver } from "@fern-api/template-resolver"; import { isNonNullish, isPlainObject, visitDiscriminatedUnion } from "@fern-ui/core-utils"; import { isEmpty, mapValues } from "lodash-es"; -import { noop } from "ts-essentials"; import { stringifyHttpRequestExampleToCurl } from "../api-page/examples/stringifyHttpRequestExampleToCurl"; import { ResolvedEndpointDefinition, @@ -22,10 +21,10 @@ import { } from "../resolver/types"; import { unknownToString } from "../util/unknownToString"; import { + PlaygroundAuthState, PlaygroundEndpointRequestFormState, PlaygroundFormDataEntryValue, PlaygroundFormStateBody, - PlaygroundRequestFormAuth, PlaygroundRequestFormState, PlaygroundWebSocketRequestFormState, convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest, @@ -97,18 +96,29 @@ export function indentAfter(str: string, indent: number, afterLine?: number): st export function stringifyFetch({ endpoint, formState, + authState, redacted, isSnippetTemplatesEnabled, }: { endpoint: ResolvedEndpointDefinition | undefined; formState: PlaygroundEndpointRequestFormState; + authState: PlaygroundAuthState; redacted: boolean; isSnippetTemplatesEnabled: boolean; }): string { if (endpoint == null) { return ""; } - const headers = redacted ? buildRedactedHeaders(endpoint, formState) : buildUnredactedHeaders(endpoint, formState); + const authHeaders = buildAuthHeaders(endpoint.auth, authState, { redacted }); + + const headers = { + ...authHeaders, + ...formState.headers, + }; + + if (endpoint.method !== "GET" && endpoint.requestBody?.contentType != null) { + headers["Content-Type"] = endpoint.requestBody.contentType; + } // TODO: ensure case insensitivity if (headers["Content-Type"] === "multipart/form-data") { @@ -206,11 +216,13 @@ ${buildFetch("formData")}`; export function stringifyPythonRequests({ endpoint, formState, + authState, redacted, isSnippetTemplatesEnabled, }: { endpoint: ResolvedEndpointDefinition | undefined; formState: PlaygroundEndpointRequestFormState; + authState: PlaygroundAuthState; redacted: boolean; isSnippetTemplatesEnabled: boolean; }): string { @@ -218,7 +230,16 @@ export function stringifyPythonRequests({ return ""; } - const headers = redacted ? buildRedactedHeaders(endpoint, formState) : buildUnredactedHeaders(endpoint, formState); + const authHeaders = buildAuthHeaders(endpoint.auth, authState, { redacted }); + + const headers = { + ...authHeaders, + ...formState.headers, + }; + + if (endpoint.method !== "GET" && endpoint.requestBody?.contentType != null) { + headers["Content-Type"] = endpoint.requestBody.contentType; + } const imports = ["requests"]; @@ -361,138 +382,69 @@ export function obfuscateSecret(secret: string): string { return secret.slice(0, 12) + "...." + secret.slice(-12); } -function buildRedactedHeaders( - endpoint: ResolvedEndpointDefinition, - formState: PlaygroundRequestFormState, -): Record { - const headers: Record = { ...mapValues(formState.headers, unknownToString) }; - - if (endpoint.auth != null && formState.auth != null) { - const { auth } = endpoint; - visitDiscriminatedUnion(formState.auth, "type")._visit({ - bearerAuth: (bearerAuth) => { - if (auth.type === "bearerAuth") { - headers["Authorization"] = `Bearer ${obfuscateSecret(bearerAuth.token)}`; - } - }, - header: (header) => { - if (auth.type === "header") { - const value = header.headers[auth.headerWireValue]; - if (value != null) { - headers[auth.headerWireValue] = obfuscateSecret( - auth.prefix != null ? `${auth.prefix} ${value}` : value, - ); - } - } - }, - basicAuth: (basicAuth) => { - // is this right? - if (auth.type === "basicAuth") { - headers["Authorization"] = `Basic ${btoa( - `${basicAuth.username}:${obfuscateSecret(basicAuth.password)}`, - )}`; - } - }, - _other: noop, - }); - } - - const requestBody = endpoint.requestBody; - if (endpoint.method !== "GET" && requestBody?.contentType != null) { - headers["Content-Type"] = requestBody.contentType; - } - - return headers; -} - -export function buildUnredactedHeadersWebsocket( - websocket: ResolvedWebSocketChannel, - formState: PlaygroundRequestFormState, -): Record { - const headers: Record = { ...mapValues(formState.headers, unknownToString) }; - - if (websocket.auth != null && formState.auth != null) { - const { auth } = websocket; - visitDiscriminatedUnion(formState.auth, "type")._visit({ - bearerAuth: (bearerAuth) => { - if (auth.type === "bearerAuth") { - headers["Authorization"] = `Bearer ${bearerAuth.token}`; - } - }, - header: (header) => { - if (auth.type === "header") { - const value = header.headers[auth.headerWireValue]; - if (value != null) { - headers[auth.headerWireValue] = value; - } - } - }, - basicAuth: (basicAuth) => { - if (auth.type === "basicAuth") { - headers["Authorization"] = `Basic ${btoa(`${basicAuth.username}:${basicAuth.password}`)}`; - } - }, - _other: noop, - }); - } - - return headers; -} - -export function buildUnredactedHeaders( - endpoint: ResolvedEndpointDefinition, - formState: PlaygroundRequestFormState, +export function buildAuthHeaders( + auth: APIV1Read.ApiAuth | undefined, + authState: PlaygroundAuthState, + { redacted }: { redacted: boolean }, ): Record { - const headers: Record = { ...mapValues(formState.headers, unknownToString) }; - - if (endpoint.auth != null && formState?.auth != null) { - const { auth } = endpoint; - visitDiscriminatedUnion(formState?.auth, "type")._visit({ - bearerAuth: (bearerAuth) => { - if (auth.type === "bearerAuth") { - headers["Authorization"] = `Bearer ${bearerAuth.token}`; + const headers: Record = {}; + + if (auth != null) { + visitDiscriminatedUnion(auth)._visit({ + bearerAuth: () => { + let token = authState.bearerAuth?.token ?? ""; + if (redacted) { + token = obfuscateSecret(token); } + headers["Authorization"] = `Bearer ${token}`; }, header: (header) => { - if (auth.type === "header") { - const value = header.headers[auth.headerWireValue]; - if (value != null) { - headers[auth.headerWireValue] = auth.prefix != null ? `${auth.prefix} ${value}` : value; - } + let value = authState.header?.headers[header.headerWireValue] ?? ""; + if (redacted) { + value = obfuscateSecret(value); } + headers[header.headerWireValue] = header.prefix != null ? `${header.prefix} ${value}` : value; }, - basicAuth: (basicAuth) => { - if (auth.type === "basicAuth") { - headers["Authorization"] = `Basic ${btoa(`${basicAuth.username}:${basicAuth.password}`)}`; + basicAuth: () => { + const username = authState.basicAuth?.username ?? ""; + let password = authState.basicAuth?.password ?? ""; + if (redacted) { + password = obfuscateSecret(password); } + headers["Authorization"] = `Basic ${btoa(`${username}:${obfuscateSecret(password)}`)}`; }, - _other: noop, }); } - const requestBody = endpoint.requestBody; - if (endpoint.method !== "GET" && requestBody?.contentType != null) { - headers["Content-Type"] = requestBody.contentType; - } - return headers; } export function stringifyCurl({ endpoint, formState, + authState, redacted, domain, }: { endpoint: ResolvedEndpointDefinition | undefined; formState: PlaygroundEndpointRequestFormState; + authState: PlaygroundAuthState; redacted: boolean; domain: string; }): string { if (endpoint == null) { return ""; } - const headers = redacted ? buildRedactedHeaders(endpoint, formState) : buildUnredactedHeaders(endpoint, formState); + const authHeaders = buildAuthHeaders(endpoint.auth, authState, { redacted }); + + const headers = { + ...authHeaders, + ...formState.headers, + }; + + if (endpoint.method !== "GET" && endpoint.requestBody?.contentType != null) { + headers["Content-Type"] = endpoint.requestBody.contentType; + } return stringifyHttpRequestExampleToCurl({ method: endpoint.method, @@ -820,13 +772,11 @@ export function shouldRenderInline( export { unknownToString }; export function getInitialEndpointRequestFormState( - auth: APIV1Read.ApiAuth | null | undefined, endpoint: ResolvedEndpointDefinition | undefined, types: Record, ): PlaygroundEndpointRequestFormState { return { type: "endpoint", - auth: getInitialAuthState(auth), headers: getDefaultValueForObjectProperties(endpoint?.headers, types), pathParameters: getDefaultValueForObjectProperties(endpoint?.pathParameters, types), queryParameters: getDefaultValueForObjectProperties(endpoint?.queryParameters, types), @@ -841,8 +791,6 @@ export function getInitialWebSocketRequestFormState( ): PlaygroundWebSocketRequestFormState { return { type: "websocket", - - auth: getInitialAuthState(auth), headers: getDefaultValueForObjectProperties(webSocket?.headers, types), pathParameters: getDefaultValueForObjectProperties(webSocket?.pathParameters, types), queryParameters: getDefaultValueForObjectProperties(webSocket?.queryParameters, types), @@ -853,30 +801,16 @@ export function getInitialWebSocketRequestFormState( }; } -function getInitialAuthState(auth: APIV1Read.ApiAuth | null | undefined): PlaygroundRequestFormAuth | undefined { - if (auth == null) { - return undefined; - } - return visitDiscriminatedUnion(auth, "type")._visit({ - header: (header) => ({ type: "header", headers: { [header.headerWireValue]: "" } }), - bearerAuth: () => ({ type: "bearerAuth", token: "" }), - basicAuth: () => ({ type: "basicAuth", username: "", password: "" }), - _other: () => undefined, - }); -} - export function getInitialEndpointRequestFormStateWithExample( - auth: APIV1Read.ApiAuth | null | undefined, endpoint: ResolvedEndpointDefinition | undefined, exampleCall: ResolvedExampleEndpointCall | undefined, types: Record, ): PlaygroundEndpointRequestFormState { if (exampleCall == null) { - return getInitialEndpointRequestFormState(auth, endpoint, types); + return getInitialEndpointRequestFormState(endpoint, types); } return { type: "endpoint", - auth: getInitialAuthState(auth), headers: exampleCall.headers, pathParameters: exampleCall.pathParameters, queryParameters: exampleCall.queryParameters, diff --git a/packages/ui/app/src/atoms/playground.ts b/packages/ui/app/src/atoms/playground.ts index ae656fe00e..561d7e9385 100644 --- a/packages/ui/app/src/atoms/playground.ts +++ b/packages/ui/app/src/atoms/playground.ts @@ -6,10 +6,18 @@ import { atomFamily, atomWithStorage, useAtomCallback } from "jotai/utils"; import { Dispatch, SetStateAction, useEffect } from "react"; import { useCallbackOne } from "use-memo-one"; import { capturePosthogEvent } from "../analytics/posthog"; -import type { - PlaygroundEndpointRequestFormState, - PlaygroundRequestFormState, - PlaygroundWebSocketRequestFormState, +import { + PLAYGROUND_AUTH_STATE_BASIC_AUTH_INITIAL, + PLAYGROUND_AUTH_STATE_BEARER_TOKEN_INITIAL, + PLAYGROUND_AUTH_STATE_HEADER_INITIAL, + PlaygroundAuthStateBasicAuth, + PlaygroundAuthStateBearerToken, + PlaygroundAuthStateHeader, + PlaygroundAuthStateSchema, + type PlaygroundAuthState, + type PlaygroundEndpointRequestFormState, + type PlaygroundRequestFormState, + type PlaygroundWebSocketRequestFormState, } from "../api-playground/types"; import { getInitialEndpointRequestFormStateWithExample } from "../api-playground/utils"; import { isEndpoint, type ResolvedEndpointDefinition, type ResolvedWebSocketChannel } from "../resolver/types"; @@ -19,6 +27,7 @@ import { useAtomEffect } from "./hooks"; import { BELOW_HEADER_HEIGHT_ATOM } from "./layout"; import { LOCATION_ATOM } from "./location"; import { NAVIGATION_NODES_ATOM } from "./navigation"; +import { atomWithStorageValidation } from "./utils/atomWithStorageValidation"; const PLAYGROUND_IS_OPEN_ATOM = atom(false); PLAYGROUND_IS_OPEN_ATOM.debugLabel = "PLAYGROUND_IS_OPEN_ATOM"; @@ -172,9 +181,51 @@ export function useInitPlaygroundRouter(): void { ); } +export const PLAYGROUND_AUTH_STATE_ATOM = atomWithStorageValidation( + "playground-auth-state", + {}, + { validate: PlaygroundAuthStateSchema, isSession: true, getOnInit: true }, +); + +export const PLAYGROUND_AUTH_STATE_BEARER_TOKEN_ATOM = atom( + (get) => get(PLAYGROUND_AUTH_STATE_ATOM).bearerAuth ?? PLAYGROUND_AUTH_STATE_BEARER_TOKEN_INITIAL, + (_get, set, update: SetStateAction) => { + set(PLAYGROUND_AUTH_STATE_ATOM, (prev) => ({ + ...prev, + bearerAuth: + typeof update === "function" + ? update(prev.bearerAuth ?? PLAYGROUND_AUTH_STATE_BEARER_TOKEN_INITIAL) + : update, + })); + }, +); + +export const PLAYGROUND_AUTH_STATE_HEADER_ATOM = atom( + (get) => get(PLAYGROUND_AUTH_STATE_ATOM).header ?? PLAYGROUND_AUTH_STATE_HEADER_INITIAL, + (_get, set, update: SetStateAction) => { + set(PLAYGROUND_AUTH_STATE_ATOM, (prev) => ({ + ...prev, + header: typeof update === "function" ? update(prev.header ?? PLAYGROUND_AUTH_STATE_HEADER_INITIAL) : update, + })); + }, +); + +export const PLAYGROUND_AUTH_STATE_BASIC_AUTH_ATOM = atom( + (get) => get(PLAYGROUND_AUTH_STATE_ATOM).basicAuth ?? PLAYGROUND_AUTH_STATE_BASIC_AUTH_INITIAL, + (_get, set, update: SetStateAction) => { + set(PLAYGROUND_AUTH_STATE_ATOM, (prev) => ({ + ...prev, + basicAuth: + typeof update === "function" + ? update(prev.basicAuth ?? PLAYGROUND_AUTH_STATE_BASIC_AUTH_INITIAL) + : update, + })); + }, +); + const playgroundFormStateFamily = atomFamily((nodeId: FernNavigation.NodeId) => { const formStateAtom = atomWithStorage(nodeId, undefined); - formStateAtom.debugLabel = `playgroundFormStateAtom-${nodeId}`; + formStateAtom.debugLabel = `playground-form-state:${nodeId}`; return formStateAtom; }); @@ -223,12 +274,7 @@ export function useSetAndOpenPlayground(): (node: FernNavigation.NavigationNodeA } set( formStateAtom, - getInitialEndpointRequestFormStateWithExample( - endpoint.auth, - endpoint, - endpoint.examples[0], - apiPackage.types, - ), + getInitialEndpointRequestFormStateWithExample(endpoint, endpoint.examples[0], apiPackage.types), ); } else if (node.type === "webSocket") { const webSocket = apiPackage.apiDefinitions.find( @@ -247,7 +293,6 @@ export function useSetAndOpenPlayground(): (node: FernNavigation.NavigationNodeA const EMPTY_ENDPOINT_REQUEST_FORM_STATE: PlaygroundEndpointRequestFormState = { type: "endpoint", - auth: undefined, headers: {}, pathParameters: {}, queryParameters: {}, @@ -284,7 +329,6 @@ export function usePlaygroundEndpointFormState( const EMPTY_WEBSOCKET_REQUEST_FORM_STATE: PlaygroundWebSocketRequestFormState = { type: "websocket", - auth: undefined, headers: {}, pathParameters: {}, queryParameters: {}, diff --git a/packages/ui/app/src/atoms/utils/atomWithStorageString.ts b/packages/ui/app/src/atoms/utils/atomWithStorageString.ts index db80b0665c..00f686e5fe 100644 --- a/packages/ui/app/src/atoms/utils/atomWithStorageString.ts +++ b/packages/ui/app/src/atoms/utils/atomWithStorageString.ts @@ -1,78 +1,17 @@ -import { atomWithStorage } from "jotai/utils"; -import { noop } from "ts-essentials"; +import { identity } from "lodash-es"; import { z } from "zod"; +import { atomWithStorageValidation } from "./atomWithStorageValidation"; export function atomWithStorageString( key: string, value: VALUE, - { validate, getOnInit }: { validate?: z.ZodType; getOnInit?: boolean } = {}, -): ReturnType> { - return atomWithStorage( - key, - value, - { - getItem: (key, initialValue) => { - if (typeof window === "undefined") { - return initialValue; - } - - try { - const stored: string | null = window.localStorage.getItem(key); - if (stored == null) { - return initialValue; - } - if (validate) { - const parsed = validate.safeParse(stored); - if (parsed.success) { - return parsed.data; - } - } - } catch { - // ignore - } - return initialValue; - }, - setItem: (key, newValue) => { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem(key, newValue); - } catch { - // ignore - } - }, - removeItem: (key) => { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.removeItem(key); - } catch { - // ignore - } - }, - subscribe: (key, callback, initialValue) => { - if (typeof window === "undefined") { - return noop; - } - - const listener = (e: StorageEvent) => { - if (e.key === key && e.newValue !== e.oldValue) { - callback( - (validate != null ? validate.safeParse(e.newValue)?.data : (e.newValue as VALUE)) ?? - initialValue, - ); - } - }; - window.addEventListener("storage", listener); - return () => { - window.removeEventListener("storage", listener); - }; - }, - }, - { getOnInit }, - ); + { + validate, + getOnInit, + }: { + validate?: z.ZodType; + getOnInit?: boolean; + } = {}, +): ReturnType> { + return atomWithStorageValidation(key, value, { validate, serialize: identity, parse: identity, getOnInit }); } diff --git a/packages/ui/app/src/atoms/utils/atomWithStorageValidation.ts b/packages/ui/app/src/atoms/utils/atomWithStorageValidation.ts new file mode 100644 index 0000000000..ae10d15e68 --- /dev/null +++ b/packages/ui/app/src/atoms/utils/atomWithStorageValidation.ts @@ -0,0 +1,95 @@ +import { atomWithStorage } from "jotai/utils"; +import { noop } from "ts-essentials"; +import { z } from "zod"; + +export function atomWithStorageValidation( + key: string, + value: VALUE, + { + validate, + serialize = JSON.stringify, + parse = JSON.parse, + getOnInit, + isSession = false, + }: { + validate?: z.ZodType; + serialize?: (value: VALUE) => string; + parse?: (value: string) => VALUE; + getOnInit?: boolean; + isSession?: boolean; + } = {}, +): ReturnType> { + return atomWithStorage( + key, + value, + { + getItem: (key, initialValue) => { + if (typeof window === "undefined") { + return initialValue; + } + + try { + const stored: string | null = (isSession ? window.sessionStorage : window.localStorage).getItem( + key, + ); + if (stored == null) { + return initialValue; + } + if (validate) { + return validate.parse(parse(stored)); + } else { + return stored as VALUE; + } + } catch { + // ignore + } + return initialValue; + }, + setItem: (key, newValue) => { + if (typeof window === "undefined") { + return; + } + + try { + (isSession ? window.sessionStorage : window.localStorage).setItem(key, serialize(newValue)); + } catch { + // ignore + } + }, + removeItem: (key) => { + if (typeof window === "undefined") { + return; + } + + try { + (isSession ? window.sessionStorage : window.localStorage).removeItem(key); + } catch { + // ignore + } + }, + subscribe: (key, callback, initialValue) => { + if (typeof window === "undefined") { + return noop; + } + + const listener = (e: StorageEvent) => { + if (e.storageArea !== (isSession ? window.sessionStorage : window.localStorage)) { + return; + } + + if (e.key === key && e.newValue !== e.oldValue) { + callback( + (validate != null ? validate.safeParse(e.newValue)?.data : (e.newValue as VALUE)) ?? + initialValue, + ); + } + }; + window.addEventListener("storage", listener); + return () => { + window.removeEventListener("storage", listener); + }; + }, + }, + { getOnInit }, + ); +}