Skip to content

Commit

Permalink
feat: implement global auth state in the api playground (#1162)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Jul 15, 2024
1 parent bfb3ee5 commit 7ee919b
Show file tree
Hide file tree
Showing 12 changed files with 399 additions and 427 deletions.
217 changes: 87 additions & 130 deletions packages/ui/app/src/api-playground/PlaygroundAuthorizationForm.tsx

Large diffs are not rendered by default.

36 changes: 27 additions & 9 deletions packages/ui/app/src/api-playground/PlaygroundEndpoint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -55,13 +63,11 @@ export const PlaygroundEndpoint: FC<PlaygroundEndpointProps> = ({ 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();
Expand Down Expand Up @@ -90,10 +96,22 @@ export const PlaygroundEndpoint: FC<PlaygroundEndpointProps> = ({ 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,
Expand Down
39 changes: 10 additions & 29 deletions packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,16 +61,6 @@ export const PlaygroundEndpointContent: FC<PlaygroundEndpointContentProps> = ({

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;
Expand All @@ -89,19 +78,7 @@ export const PlaygroundEndpointContent: FC<PlaygroundEndpointContentProps> = ({

const form = (
<div className="mx-auto w-full max-w-5xl space-y-6 pt-6 max-sm:pt-0 sm:pb-20">
{endpoint.auth != null && (
<PlaygroundAuthorizationFormCard
auth={endpoint.auth}
authState={formState?.auth}
setAuthorization={(newState) =>
setFormState((oldState) => ({
...oldState,
auth: typeof newState === "function" ? newState(oldState.auth) : newState,
}))
}
disabled={false}
/>
)}
{endpoint.auth != null && <PlaygroundAuthorizationFormCard auth={endpoint.auth} disabled={false} />}

<PlaygroundEndpointForm
endpoint={endpoint}
Expand Down Expand Up @@ -154,30 +131,34 @@ export const PlaygroundEndpointContent: FC<PlaygroundEndpointContentProps> = ({
</FernButtonGroup>

<CopyToClipboardButton
content={() =>
requestType === "curl"
content={() => {
const authState = store.get(PLAYGROUND_AUTH_STATE_ATOM);
return requestType === "curl"
? stringifyCurl({
endpoint,
formState,
authState,
redacted: false,
domain,
})
: requestType === "typescript"
? stringifyFetch({
endpoint,
formState,
authState,
redacted: false,
isSnippetTemplatesEnabled,
})
: requestType === "python"
? stringifyPythonRequests({
endpoint,
formState,
authState,
redacted: false,
isSnippetTemplatesEnabled,
})
: ""
}
: "";
}}
className="-mr-2"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,32 +14,36 @@ interface PlaygroundRequestPreviewProps {

export const PlaygroundRequestPreview: FC<PlaygroundRequestPreviewProps> = ({ endpoint, formState, requestType }) => {
const { isSnippetTemplatesEnabled } = useFeatureFlags();
const authState = useAtomValue(PLAYGROUND_AUTH_STATE_ATOM);
const domain = useDomain();
const code = useMemo(
() =>
requestType === "curl"
? stringifyCurl({
endpoint,
formState,
authState,
redacted: true,
domain,
})
: requestType === "typescript"
? stringifyFetch({
endpoint,
formState,
authState,
redacted: true,
isSnippetTemplatesEnabled,
})
: requestType === "python"
? stringifyPythonRequests({
endpoint,
formState,
authState,
redacted: true,
isSnippetTemplatesEnabled,
})
: "",
[domain, endpoint, formState, isSnippetTemplatesEnabled, requestType],
[authState, domain, endpoint, formState, isSnippetTemplatesEnabled, requestType],
);
return (
<FernSyntaxHighlighter
Expand Down
29 changes: 19 additions & 10 deletions packages/ui/app/src/api-playground/PlaygroundWebSocket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { usePrevious } from "@fern-ui/react-commons";
import { merge } from "lodash-es";
import { FC, ReactElement, useCallback, useEffect, useRef, useState } from "react";
import { Wifi, WifiOff } from "react-feather";
import { usePlaygroundWebsocketFormState } from "../atoms";
import { PLAYGROUND_AUTH_STATE_ATOM, store, usePlaygroundWebsocketFormState } from "../atoms";
import { ResolvedTypeDefinition, ResolvedWebSocketChannel, ResolvedWebSocketMessage } from "../resolver/types";
import { PlaygroundEndpointPath } from "./PlaygroundEndpointPath";
import { PlaygroundWebSocketContent } from "./PlaygroundWebSocketContent";
import { useWebsocketMessages } from "./hooks/useWebsocketMessages";
import { buildRequestUrl, buildUnredactedHeadersWebsocket, getDefaultValueForType } from "./utils";
import { buildAuthHeaders, buildRequestUrl, getDefaultValueForType } from "./utils";

// TODO: decide if this should be an env variable, and if we should move REST proxy to the same (or separate) cloudflare worker
const WEBSOCKET_PROXY_URI = "wss://websocket.proxy.ferndocs.com/ws";
Expand Down Expand Up @@ -60,13 +60,14 @@ export const PlaygroundWebSocket: FC<PlaygroundWebSocketProps> = ({ 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) => {
Expand Down Expand Up @@ -98,7 +99,15 @@ export const PlaygroundWebSocket: FC<PlaygroundWebSocketProps> = ({ 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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,7 @@ export const PlaygroundWebSocketHandshakeForm: FC<PlaygroundWebSocketHandshakeFo
</Callout>
)}

{websocket.auth != null && (
<PlaygroundAuthorizationFormCard
auth={websocket.auth}
authState={formState?.auth}
setAuthorization={(newState) =>
setFormState((oldState) => ({
...oldState,
auth: typeof newState === "function" ? newState(oldState.auth) : newState,
}))
}
disabled={disabled}
/>
)}
{websocket.auth != null && <PlaygroundAuthorizationFormCard auth={websocket.auth} disabled={disabled} />}

<div className="col-span-2 space-y-8">
{websocket.headers.length > 0 && (
Expand Down
43 changes: 24 additions & 19 deletions packages/ui/app/src/api-playground/types/auth.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
export declare namespace PlaygroundRequestFormAuth {
interface BearerAuth {
type: "bearerAuth";
token: string;
}
import { z } from "zod";

interface Header {
type: "header";
headers: Record<string, string>;
}
export const PlaygroundAuthStateBearerTokenSchema = z.strictObject({
token: z.string(),
});
export type PlaygroundAuthStateBearerToken = z.infer<typeof PlaygroundAuthStateBearerTokenSchema>;
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<typeof PlaygroundAuthStateHeaderSchema>;
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<typeof PlaygroundAuthStateBasicAuthSchema>;
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<typeof PlaygroundAuthStateSchema>;
3 changes: 0 additions & 3 deletions packages/ui/app/src/api-playground/types/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -67,7 +66,6 @@ export type PlaygroundFormStateBody =

export interface PlaygroundEndpointRequestFormState {
type: "endpoint";
auth: PlaygroundRequestFormAuth | undefined;
headers: Record<string, unknown>;
pathParameters: Record<string, unknown>;
queryParameters: Record<string, unknown>;
Expand All @@ -76,7 +74,6 @@ export interface PlaygroundEndpointRequestFormState {

export interface PlaygroundWebSocketRequestFormState {
type: "websocket";
auth: PlaygroundRequestFormAuth | undefined;
headers: Record<string, unknown>;
pathParameters: Record<string, unknown>;
queryParameters: Record<string, unknown>;
Expand Down
Loading

0 comments on commit 7ee919b

Please sign in to comment.