diff --git a/packages/commons/react/react-commons/src/useCopyToClipboard.ts b/packages/commons/react/react-commons/src/useCopyToClipboard.ts index 9bbf7f3bae..48b93e4c22 100644 --- a/packages/commons/react/react-commons/src/useCopyToClipboard.ts +++ b/packages/commons/react/react-commons/src/useCopyToClipboard.ts @@ -8,7 +8,9 @@ export declare namespace useCopyToClipboard { } } -export function useCopyToClipboard(content: string | (() => string) | undefined): useCopyToClipboard.Return { +export function useCopyToClipboard( + content: string | (() => string | Promise) | undefined, +): useCopyToClipboard.Return { const [wasJustCopied, setWasJustCopied] = useState(false); const copyToClipboard = useMemo(() => { @@ -17,7 +19,7 @@ export function useCopyToClipboard(content: string | (() => string) | undefined) } return async () => { setWasJustCopied(true); - await navigator.clipboard.writeText(typeof content === "function" ? content() : content); + await navigator.clipboard.writeText(typeof content === "function" ? await content() : content); }; }, [content]); diff --git a/packages/ui/app/src/api-page/examples/__test__/stringifyHttpRequestExampleToCurl.test.ts b/packages/ui/app/src/api-page/examples/__test__/stringifyHttpRequestExampleToCurl.test.ts index 444139c58c..55b89255f3 100644 --- a/packages/ui/app/src/api-page/examples/__test__/stringifyHttpRequestExampleToCurl.test.ts +++ b/packages/ui/app/src/api-page/examples/__test__/stringifyHttpRequestExampleToCurl.test.ts @@ -17,7 +17,7 @@ const MOCK_ENDPOINT: ResolvedEndpointDefinition = { auth: undefined, availability: undefined, defaultEnvironment: MOCK_ENV, - apiSectionId: "", + apiDefinitionId: "", environments: [MOCK_ENV], method: "GET", title: "", diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx index b5cd7f7af9..6bc1c6c4db 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpointContent.tsx @@ -25,9 +25,9 @@ import { PlaygroundRequestPreview } from "./PlaygroundRequestPreview"; import { PlaygroundResponsePreview } from "./PlaygroundResponsePreview"; import { PlaygroundSendRequestButton } from "./PlaygroundSendRequestButton"; import { HorizontalSplitPane, VerticalSplitPane } from "./VerticalSplitPane"; +import { PlaygroundCodeSnippetResolverBuilder } from "./code-snippets/resolver"; import { PlaygroundEndpointRequestFormState, ProxyResponse } from "./types"; import { PlaygroundResponse } from "./types/playgroundResponse"; -import { stringifyCurl, stringifyFetch, stringifyPythonRequests } from "./utils"; interface PlaygroundEndpointContentProps { endpoint: ResolvedEndpointDefinition; @@ -133,31 +133,12 @@ export const PlaygroundEndpointContent: FC = ({ { 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, - }) - : ""; + const resolver = new PlaygroundCodeSnippetResolverBuilder( + endpoint, + isSnippetTemplatesEnabled, + domain, + ).create(authState, formState); + return resolver.resolve(requestType); }} className="-mr-2" /> diff --git a/packages/ui/app/src/api-playground/PlaygroundEndpointFormButtons.tsx b/packages/ui/app/src/api-playground/PlaygroundEndpointFormButtons.tsx index 2beed9c6ca..07cca33e86 100644 --- a/packages/ui/app/src/api-playground/PlaygroundEndpointFormButtons.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundEndpointFormButtons.tsx @@ -34,7 +34,7 @@ export function PlaygroundEndpointFormButtons({ diff --git a/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx b/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx index 5fa4dbaebc..a361413c3d 100644 --- a/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundRequestPreview.tsx @@ -1,11 +1,11 @@ import { useAtomValue } from "jotai"; import { FC, useMemo } from "react"; import { PLAYGROUND_AUTH_STATE_ATOM, useDomain, useFeatureFlags } from "../atoms"; -import { useSelectedEnvironmentId } from "../atoms/environment"; -import { ResolvedEndpointDefinition, resolveEnvironmentUrlInCodeSnippet } from "../resolver/types"; +import { ResolvedEndpointDefinition } from "../resolver/types"; import { FernSyntaxHighlighter } from "../syntax-highlighting/FernSyntaxHighlighter"; +import { PlaygroundCodeSnippetResolverBuilder } from "./code-snippets/resolver"; +import { useSnippet } from "./code-snippets/useSnippet"; import { PlaygroundEndpointRequestFormState } from "./types"; -import { stringifyCurl, stringifyFetch, stringifyPythonRequests } from "./utils"; interface PlaygroundRequestPreviewProps { endpoint: ResolvedEndpointDefinition; @@ -17,41 +17,18 @@ export const PlaygroundRequestPreview: FC = ({ en const { isSnippetTemplatesEnabled } = useFeatureFlags(); const authState = useAtomValue(PLAYGROUND_AUTH_STATE_ATOM); const domain = useDomain(); - const selectedEnvironmentId = useSelectedEnvironmentId(); - 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, - }) - : "", - [authState, domain, endpoint, formState, isSnippetTemplatesEnabled, requestType], + const builder = useMemo( + () => new PlaygroundCodeSnippetResolverBuilder(endpoint, isSnippetTemplatesEnabled, domain), + [domain, endpoint, isSnippetTemplatesEnabled], ); + const resolver = useMemo(() => builder.createRedacted(authState, formState), [authState, builder, formState]); + const code = useSnippet(resolver, requestType); + return ( diff --git a/packages/ui/app/src/api-playground/code-snippets/__test__/__snapshots__/code-snippets.test.ts.snap b/packages/ui/app/src/api-playground/code-snippets/__test__/__snapshots__/code-snippets.test.ts.snap new file mode 100644 index 0000000000..a3c05d3c18 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/__test__/__snapshots__/code-snippets.test.ts.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PlaygroundCodeSnippetBuilder > should render curl 1`] = ` +"curl -X POST https://example.com/test/hello%40example \\ + -H "Accept: application/json" \\ + -H "Test: test" \\ + -d '{ + "test": "hello", + "deeply": { + "nested": 1 + } +}'" +`; + +exports[`PlaygroundCodeSnippetBuilder > should render python 1`] = ` +"import requests + +# My endpoint (POST /test/:test) +response = requests.post( + "https://example.com/test/hello%40example", + headers={ + "Accept": "application/json", + "Test": "test" + }, + json={ + "test": "hello", + "deeply": { + "nested": 1 + } + }, +) + +print(response.json())" +`; + +exports[`PlaygroundCodeSnippetBuilder > should render typescript 1`] = ` +"// My endpoint (POST /test/:test) +const response = await fetch("https://example.com/test/hello%40example", { + method: "POST", + headers: { + "Accept": "application/json", + "Test": "test" + }, + body: JSON.stringify({ + "test": "hello", + "deeply": { + "nested": 1 + } + }), +}); + +const body = await response.json(); +console.log(body);" +`; diff --git a/packages/ui/app/src/api-playground/code-snippets/__test__/code-snippets.test.ts b/packages/ui/app/src/api-playground/code-snippets/__test__/code-snippets.test.ts new file mode 100644 index 0000000000..f5075d3789 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/__test__/code-snippets.test.ts @@ -0,0 +1,96 @@ +import { FernNavigation } from "@fern-api/fdr-sdk"; +import { ResolvedEndpointDefinition } from "../../../resolver/types"; +import { PlaygroundEndpointRequestFormState } from "../../types"; +import { CurlSnippetBuilder } from "../builders/curl"; +import { PythonRequestSnippetBuilder } from "../builders/python"; +import { TypescriptFetchSnippetBuilder } from "../builders/typescript"; + +describe("PlaygroundCodeSnippetBuilder", () => { + const endpoint: ResolvedEndpointDefinition = { + type: "endpoint", + nodeId: FernNavigation.NodeId(""), + id: "", + apiDefinitionId: "", + slug: FernNavigation.Slug(""), + auth: undefined, + availability: undefined, + defaultEnvironment: { + id: "Prod", + baseUrl: "https://example.com", + }, + environments: [], + method: "POST", + title: "My endpoint", + path: [ + { type: "literal", value: "/test/" }, + { + key: "test", + type: "pathParameter", + valueShape: { + type: "primitive", + value: { type: "string" }, + description: undefined, + availability: undefined, + }, + hidden: false, + description: undefined, + availability: undefined, + }, + ], + pathParameters: [ + { + key: "test", + valueShape: { + type: "primitive", + value: { type: "string" }, + description: undefined, + availability: undefined, + }, + hidden: false, + description: undefined, + availability: undefined, + }, + ], + queryParameters: [], + headers: [], + requestBody: undefined, + responseBody: undefined, + errors: [], + examples: [], + snippetTemplates: undefined, + stream: undefined, + description: undefined, + }; + const formState: PlaygroundEndpointRequestFormState = { + type: "endpoint", + headers: { + Accept: "application/json", + Test: "test", + }, + pathParameters: { + test: "hello@example", + }, + queryParameters: {}, + body: { + type: "json", + value: { + test: "hello", + deeply: { + nested: 1, + }, + }, + }, + }; + + it("should render curl", () => { + expect(new CurlSnippetBuilder(endpoint, formState).build()).toMatchSnapshot(); + }); + + it("should render python", () => { + expect(new PythonRequestSnippetBuilder(endpoint, formState).build()).toMatchSnapshot(); + }); + + it("should render typescript", () => { + expect(new TypescriptFetchSnippetBuilder(endpoint, formState).build()).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/app/src/api-playground/code-snippets/builders/common.ts b/packages/ui/app/src/api-playground/code-snippets/builders/common.ts new file mode 100644 index 0000000000..8485410bdf --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/builders/common.ts @@ -0,0 +1,26 @@ +import { ResolvedEndpointPathParts } from "../../../resolver/types"; +import { unknownToString } from "../../utils"; + +export function buildPath(path: ResolvedEndpointPathParts[], pathParameters?: Record): string { + return path + .map((part) => { + if (part.type === "pathParameter") { + const stateValue = unknownToString(pathParameters?.[part.key]); + return stateValue.length > 0 ? encodeURIComponent(stateValue) : ":" + part.key; + } + return part.value; + }) + .join(""); +} + +export function indentAfter(str: string, indent: number, afterLine?: number): string { + return str + .split("\n") + .map((line, idx) => { + if (afterLine == null || idx > afterLine) { + return " ".repeat(indent) + line; + } + return line; + }) + .join("\n"); +} diff --git a/packages/ui/app/src/api-playground/code-snippets/builders/curl.ts b/packages/ui/app/src/api-playground/code-snippets/builders/curl.ts new file mode 100644 index 0000000000..06267792a5 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/builders/curl.ts @@ -0,0 +1,59 @@ +import { visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { isEmpty } from "lodash-es"; +import { stringifyHttpRequestExampleToCurl } from "../../../api-page/examples/stringifyHttpRequestExampleToCurl"; +import { ResolvedExampleEndpointRequest, ResolvedFormValue } from "../../../resolver/types"; +import { convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest } from "../../types"; +import { PlaygroundCodeSnippetBuilder } from "./types"; + +export class CurlSnippetBuilder extends PlaygroundCodeSnippetBuilder { + private isFileForgeHackEnabled: boolean = false; + + public setFileForgeHackEnabled(isFileForgeHackEnabled: boolean): CurlSnippetBuilder { + this.isFileForgeHackEnabled = isFileForgeHackEnabled; + return this; + } + + public override build(): string { + return stringifyHttpRequestExampleToCurl({ + method: this.endpoint.method, + url: this.url, + urlQueries: this.formState.queryParameters, + headers: this.formState.headers, + body: this.#convertFormStateToBody(), + }); + } + + #convertFormStateToBody(): ResolvedExampleEndpointRequest | undefined { + if (this.formState.body == null) { + return undefined; + } + return visitDiscriminatedUnion(this.formState.body, "type")._visit({ + json: ({ value }) => ({ type: "json", value }), + "form-data": ({ value }): ResolvedExampleEndpointRequest.Form | undefined => { + const properties = + this.endpoint.requestBody?.shape.type === "formData" + ? this.endpoint.requestBody.shape.properties + : []; + const newValue: Record = {}; + for (const [key, v] of Object.entries(value)) { + const property = properties.find((property) => property.key === key); + const convertedV = convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest( + v, + property, + this.isFileForgeHackEnabled, + ); + if (convertedV != null) { + newValue[key] = convertedV; + } + } + if (isEmpty(newValue)) { + return undefined; + } + return { type: "form", value: newValue }; + }, + "octet-stream": ({ value }): ResolvedExampleEndpointRequest.Bytes | undefined => + value != null ? { type: "bytes", fileName: value.name, value: undefined } : undefined, + _other: () => undefined, + }); + } +} diff --git a/packages/ui/app/src/api-playground/code-snippets/builders/python.ts b/packages/ui/app/src/api-playground/code-snippets/builders/python.ts new file mode 100644 index 0000000000..d8e0fead31 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/builders/python.ts @@ -0,0 +1,100 @@ +import { isNonNullish, visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { PlaygroundFormDataEntryValue } from "../../types"; +import { buildPath, indentAfter } from "./common"; +import { PlaygroundCodeSnippetBuilder } from "./types"; + +interface PythonRequestParams { + json?: string; + data?: string; + files?: string; +} + +export class PythonRequestSnippetBuilder extends PlaygroundCodeSnippetBuilder { + // TODO: write more tests for this + #buildRequests({ json, data, files }: PythonRequestParams) { + if (this.endpoint == null) { + return ""; + } + return `# ${this.endpoint.title} (${this.endpoint.method} ${buildPath(this.endpoint.path)}) +response = requests.${this.endpoint.method.toLowerCase()}( + "${this.url}", + headers=${indentAfter(JSON.stringify(this.formState.headers, undefined, 2), 2, 0)},${json != null ? `\n json=${indentAfter(json, 2, 0)},` : ""}${ + data != null ? `\n data=${indentAfter(data, 2, 0)},` : "" + }${files != null ? `\n files=${indentAfter(files, 2, 0)},` : ""} +) + +print(response.json())`; + } + + public override build(): string { + const imports = ["requests"]; + + if (this.formState.body == null) { + return `${imports.map((pkg) => `import ${pkg}`).join("\n")} + +${this.#buildRequests({})}`; + } + + return visitDiscriminatedUnion(this.formState.body, "type")._visit({ + json: ({ value }) => `${imports.map((pkg) => `import ${pkg}`).join("\n")} + +${this.#buildRequests({ json: JSON.stringify(value, undefined, 2) })}`, + "form-data": ({ value }) => { + const singleFiles = Object.entries(value) + .filter((entry): entry is [string, PlaygroundFormDataEntryValue.SingleFile] => + PlaygroundFormDataEntryValue.isSingleFile(entry[1]), + ) + .map(([k, v]) => { + if (v.value == null) { + return undefined; + } + return `'${k}': ('${v.value.name}', open('${v.value.name}', 'rb')),`; + }) + .filter(isNonNullish); + const fileArrays = Object.entries(value) + .filter((entry): entry is [string, PlaygroundFormDataEntryValue.MultipleFiles] => + PlaygroundFormDataEntryValue.isMultipleFiles(entry[1]), + ) + .map(([k, v]) => { + const fileStrings = v.value.map((file) => `('${file.name}', open('${file.name}', 'rb'))`); + if (fileStrings.length === 0) { + return; + } + return `'${k}': [${fileStrings.length === 0 ? fileStrings[0] : indentAfter(`\n${fileStrings.join(",\n")},\n`, 2, 0)}],`; + }) + .filter(isNonNullish); + + const fileEntries = [...singleFiles, ...fileArrays].join("\n"); + const files = fileEntries.length > 0 ? `{\n${indentAfter(fileEntries, 2)}\n}` : undefined; + + const dataEntries = Object.entries(value) + .filter((entry): entry is [string, PlaygroundFormDataEntryValue.Json] => + PlaygroundFormDataEntryValue.isJson(entry[1]), + ) + .map(([k, v]) => + v.value == null + ? undefined + : `'${k}': json.dumps(${indentAfter(JSON.stringify(v.value, undefined, 2), 2, 0)}),`, + ) + .filter(isNonNullish) + .join("\n"); + + const data = dataEntries.length > 0 ? `{\n${indentAfter(dataEntries, 2)}\n}` : undefined; + + if (data != null) { + imports.push("json"); + } + + return `${imports.map((pkg) => `import ${pkg}`).join("\n")} + +${this.#buildRequests({ data, files })}`; + }, + "octet-stream": (f) => `${imports.map((pkg) => `import ${pkg}`).join("\n")} + +${this.#buildRequests({ data: f.value != null ? `open('${f.value?.name}', 'rb').read()` : undefined })}`, + _other: () => `${imports.map((pkg) => `import ${pkg}`).join("\n")} + +${this.#buildRequests({})}`, + }); + } +} diff --git a/packages/ui/app/src/api-playground/code-snippets/builders/types.ts b/packages/ui/app/src/api-playground/code-snippets/builders/types.ts new file mode 100644 index 0000000000..c8bd4e8a80 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/builders/types.ts @@ -0,0 +1,17 @@ +import { ResolvedEndpointDefinition } from "../../../resolver/types"; +import { PlaygroundEndpointRequestFormState } from "../../types"; +import { buildEndpointUrl } from "../../utils"; + +export abstract class PlaygroundCodeSnippetBuilder { + protected url: string; + constructor( + // TODO: make this more generic and easier to test by removing dependency on "ResolvedEndpointDefinition" + protected endpoint: ResolvedEndpointDefinition, + protected formState: PlaygroundEndpointRequestFormState, + ) { + // TODO: wire through the environment from hook + this.url = buildEndpointUrl(endpoint, formState); + } + + public abstract build(): string; +} diff --git a/packages/ui/app/src/api-playground/code-snippets/builders/typescript.ts b/packages/ui/app/src/api-playground/code-snippets/builders/typescript.ts new file mode 100644 index 0000000000..4c2774cf1b --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/builders/typescript.ts @@ -0,0 +1,71 @@ +import { visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { isEmpty } from "lodash-es"; +import { buildPath, indentAfter } from "./common"; +import { PlaygroundCodeSnippetBuilder } from "./types"; + +export class TypescriptFetchSnippetBuilder extends PlaygroundCodeSnippetBuilder { + // TODO: write more tests for this + #buildFetch(body: string | undefined): string { + if (this.endpoint == null) { + return ""; + } + return `// ${this.endpoint.title} (${this.endpoint.method} ${buildPath(this.endpoint.path)}) +const response = await fetch("${this.url}", { + method: "${this.endpoint.method}", + headers: ${indentAfter(JSON.stringify(this.formState.headers, undefined, 2), 2, 0)},${!isEmpty(body) ? `\n body: ${body},` : ""} +}); + +const body = await response.json(); +console.log(body);`; + } + + public override build(): string { + if (this.formState.body == null) { + return this.#buildFetch(undefined); + } + + return visitDiscriminatedUnion(this.formState.body, "type")._visit({ + "octet-stream": () => this.#buildFetch('document.querySelector("input[type=file]").files[0]'), // TODO: implement this + json: ({ value }) => + this.#buildFetch( + value != null + ? indentAfter(`JSON.stringify(${JSON.stringify(value, undefined, 2)})`, 2, 0) + : undefined, + ), + "form-data": ({ value }) => { + const file = Object.entries(value) + .filter(([, v]) => v.type === "file") + .map(([k]) => { + return `const ${k}File = document.getElementById("${k}").files[0]; +formData.append("${k}", ${k}File);`; + }) + .join("\n\n"); + + const fileArrays = Object.entries(value) + .filter(([, v]) => v.type === "fileArray") + .map(([k]) => { + return `const ${k}Files = document.getElementById("${k}").files; +${k}Files.forEach((file) => { + formData.append("${k}", file); +});`; + }) + .join("\n\n"); + + const jsons = Object.entries(value) + .filter(([, v]) => v.type === "json") + .map(([k, v]) => { + return `formData.append("${k}", ${indentAfter(`JSON.stringify(${JSON.stringify(v.value, undefined, 2)})`, 2, 0)});`; + }) + .join("\n\n"); + + const appendStatements = [file, fileArrays, jsons].filter((v) => v.length > 0).join("\n\n"); + + return `// Create a new FormData instance +const formData = new FormData();${appendStatements.length > 0 ? "\n\n" + appendStatements : ""} + +${this.#buildFetch("formData")}`; + }, + _other: () => this.#buildFetch(undefined), + }); + } +} diff --git a/packages/ui/app/src/api-playground/code-snippets/resolver.ts b/packages/ui/app/src/api-playground/code-snippets/resolver.ts new file mode 100644 index 0000000000..3b24216798 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/resolver.ts @@ -0,0 +1,167 @@ +import { APIV1Read } from "@fern-api/fdr-sdk"; +import { SnippetTemplateResolver } from "@fern-api/template-resolver"; +import { UnreachableCaseError } from "ts-essentials"; +import { ResolvedEndpointDefinition, stringifyResolvedEndpointPathPartsTemplate } from "../../resolver/types"; +import { PlaygroundAuthState, PlaygroundEndpointRequestFormState } from "../types"; +import { buildAuthHeaders, convertToCustomSnippetPayload } from "../utils"; +import { CurlSnippetBuilder } from "./builders/curl"; +import { PythonRequestSnippetBuilder } from "./builders/python"; +import { TypescriptFetchSnippetBuilder } from "./builders/typescript"; + +export class PlaygroundCodeSnippetResolverBuilder { + constructor( + private endpoint: ResolvedEndpointDefinition, + private isSnippetTemplatesEnabled: boolean, + private docsHost: string, + ) {} + + public create( + authState: PlaygroundAuthState, + formState: PlaygroundEndpointRequestFormState, + ): PlaygroundCodeSnippetResolver { + return new PlaygroundCodeSnippetResolver( + this.endpoint, + authState, + formState, + false, + this.isSnippetTemplatesEnabled, + this.docsHost.includes("fileforge"), + ); + } + + public createRedacted( + authState: PlaygroundAuthState, + formState: PlaygroundEndpointRequestFormState, + ): PlaygroundCodeSnippetResolver { + return new PlaygroundCodeSnippetResolver( + this.endpoint, + authState, + formState, + true, + this.isSnippetTemplatesEnabled, + this.docsHost.includes("fileforge"), + ); + } +} + +export class PlaygroundCodeSnippetResolver { + // TODO: use Headers class for case-insensitive keyes + private headers: Record = {}; + private typescriptSdkResolver: SnippetTemplateResolver | undefined; + private pythonRequestsResolver: SnippetTemplateResolver | undefined; + + public resolve(lang: "curl" | "python" | "typescript", apiDefinition?: APIV1Read.ApiDefinition): string { + if (lang === "curl") { + return this.toCurl(); + } else if (lang === "typescript") { + return this.toTypescriptSdkSnippet(apiDefinition) ?? this.toTypescriptFetch(); + } else if (lang === "python") { + return this.toPythonSdkSnippet(apiDefinition) ?? this.toPythonRequests(); + } else { + throw new UnreachableCaseError(lang); + } + } + + constructor( + public endpoint: ResolvedEndpointDefinition, + authState: PlaygroundAuthState, + private formState: PlaygroundEndpointRequestFormState, + isAuthHeadersRedacted: boolean, + public isSnippetTemplatesEnabled: boolean, + private isFileForgeHackEnabled: boolean, + ) { + const authHeaders = buildAuthHeaders(endpoint.auth, authState, { redacted: isAuthHeadersRedacted }); + this.headers = { ...authHeaders, ...formState.headers }; + if (endpoint.method !== "GET" && endpoint.requestBody?.contentType != null) { + this.headers["Content-Type"] = endpoint.requestBody.contentType; + } + + if (isSnippetTemplatesEnabled && endpoint.snippetTemplates != null) { + if (endpoint.snippetTemplates.typescript != null) { + this.typescriptSdkResolver = new SnippetTemplateResolver({ + payload: convertToCustomSnippetPayload(formState), + endpointSnippetTemplate: { + sdk: { + type: "typescript", + package: "", + version: "", + }, + endpointId: { + path: stringifyResolvedEndpointPathPartsTemplate(endpoint.path), + method: endpoint.method, + }, + snippetTemplate: endpoint.snippetTemplates.typescript, + }, + }); + } + + if (endpoint.snippetTemplates.python != null) { + this.pythonRequestsResolver = new SnippetTemplateResolver({ + payload: convertToCustomSnippetPayload(formState), + endpointSnippetTemplate: { + sdk: { + type: "python", + package: "", + version: "", + }, + endpointId: { + path: stringifyResolvedEndpointPathPartsTemplate(endpoint.path), + method: endpoint.method, + }, + snippetTemplate: endpoint.snippetTemplates.python, + }, + }); + } + } + } + + public toCurl(): string { + const formState = { ...this.formState, headers: this.headers }; + return new CurlSnippetBuilder(this.endpoint, formState) + .setFileForgeHackEnabled(this.isFileForgeHackEnabled) + .build(); + } + + public toTypescriptFetch(): string { + const headers = { ...this.headers }; + + // TODO: ensure case insensitivity + if (headers["Content-Type"] === "multipart/form-data") { + delete headers["Content-Type"]; // fetch will set this automatically + } + + const formState = { ...this.formState, headers }; + return new TypescriptFetchSnippetBuilder(this.endpoint, formState).build(); + } + + public toPythonRequests(): string { + const formState = { ...this.formState, headers: this.headers }; + return new PythonRequestSnippetBuilder(this.endpoint, formState).build(); + } + + public toTypescriptSdkSnippet(apiDefinition?: APIV1Read.ApiDefinition): string | undefined { + if (this.typescriptSdkResolver == null) { + return undefined; + } + + const resolvedTemplate = this.typescriptSdkResolver.resolve(apiDefinition); + + if (resolvedTemplate.type === "typescript") { + return resolvedTemplate.client; + } + return undefined; + } + + public toPythonSdkSnippet(apiDefinition?: APIV1Read.ApiDefinition): string | undefined { + if (this.pythonRequestsResolver == null) { + return undefined; + } + + const resolvedTemplate = this.pythonRequestsResolver.resolve(apiDefinition); + + if (resolvedTemplate.type === "python") { + return resolvedTemplate.sync_client; + } + return undefined; + } +} diff --git a/packages/ui/app/src/api-playground/code-snippets/useApiDefinition.ts b/packages/ui/app/src/api-playground/code-snippets/useApiDefinition.ts new file mode 100644 index 0000000000..6fabacd79a --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/useApiDefinition.ts @@ -0,0 +1,21 @@ +import { APIV1Read, FdrAPI } from "@fern-api/fdr-sdk"; +import useSWRImmutable from "swr/immutable"; +import { REGISTRY_SERVICE } from "../../services/registry"; + +export function useApiDefinition( + apiId: FdrAPI.ApiDefinitionId, + isSnippetTemplatesEnabled: boolean, +): APIV1Read.ApiDefinition | undefined { + const { data } = useSWRImmutable(apiId, (apiId) => { + if (!isSnippetTemplatesEnabled) { + return undefined; + } + return REGISTRY_SERVICE.api.v1.read.getApi(apiId); + }); + + if (data?.ok) { + return data.body; + } + + return undefined; +} diff --git a/packages/ui/app/src/api-playground/code-snippets/useSnippet.ts b/packages/ui/app/src/api-playground/code-snippets/useSnippet.ts new file mode 100644 index 0000000000..fa11fa5402 --- /dev/null +++ b/packages/ui/app/src/api-playground/code-snippets/useSnippet.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import { useSelectedEnvironmentId } from "../../atoms/environment"; +import { resolveEnvironmentUrlInCodeSnippet } from "../../resolver/types"; +import { PlaygroundCodeSnippetResolver } from "./resolver"; +import { useApiDefinition } from "./useApiDefinition"; + +export function useSnippet(resolver: PlaygroundCodeSnippetResolver, lang: "curl" | "python" | "typescript"): string { + const selectedEnvironmentId = useSelectedEnvironmentId(); + const apiDefinition = useApiDefinition(resolver.endpoint.apiDefinitionId, resolver.isSnippetTemplatesEnabled); + + // Resolve the code snippet + const code = useMemo(() => resolver.resolve(lang, apiDefinition), [resolver, lang, apiDefinition]); + return resolveEnvironmentUrlInCodeSnippet(resolver.endpoint, code, selectedEnvironmentId); +} diff --git a/packages/ui/app/src/api-playground/types/index.ts b/packages/ui/app/src/api-playground/types/index.ts index 82bf616c39..c73129283c 100644 --- a/packages/ui/app/src/api-playground/types/index.ts +++ b/packages/ui/app/src/api-playground/types/index.ts @@ -21,7 +21,7 @@ export declare namespace PlaygroundFormStateBody { export function convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest( value: PlaygroundFormDataEntryValue, property: ResolvedFormDataRequestProperty | undefined, - domain: string, + isFileForgeHackEnabled: boolean, ): ResolvedFormValue | undefined { switch (value.type) { case "file": @@ -51,7 +51,7 @@ export function convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequ value: value.value, // this is a hack to allow the API Playground to send JSON blobs in form data // revert this once we have a better solution - contentType: contentType ?? (domain.includes("fileforge") ? "application/json" : undefined), + contentType: contentType ?? (isFileForgeHackEnabled ? "application/json" : undefined), }; } default: diff --git a/packages/ui/app/src/api-playground/utils.ts b/packages/ui/app/src/api-playground/utils.ts index ae417095de..8d86f7bb7b 100644 --- a/packages/ui/app/src/api-playground/utils.ts +++ b/packages/ui/app/src/api-playground/utils.ts @@ -1,14 +1,10 @@ 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 { stringifyHttpRequestExampleToCurl } from "../api-page/examples/stringifyHttpRequestExampleToCurl"; +import { isPlainObject, visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { mapValues } from "lodash-es"; import { ResolvedEndpointDefinition, ResolvedEndpointPathParts, ResolvedExampleEndpointCall, - ResolvedExampleEndpointRequest, - ResolvedFormValue, ResolvedHttpRequestBodyShape, ResolvedObjectProperty, ResolvedTypeDefinition, @@ -16,7 +12,6 @@ import { ResolvedWebSocketChannel, dereferenceObjectProperties, resolveEnvironment, - stringifyResolvedEndpointPathPartsTemplate, unwrapReference, visitResolvedHttpRequestBodyShape, } from "../resolver/types"; @@ -28,7 +23,6 @@ import { PlaygroundFormStateBody, PlaygroundRequestFormState, PlaygroundWebSocketRequestFormState, - convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest, } from "./types"; export function castToRecord(value: unknown): Record { @@ -82,284 +76,6 @@ export function buildEndpointUrl( ); } -export function indentAfter(str: string, indent: number, afterLine?: number): string { - return str - .split("\n") - .map((line, idx) => { - if (afterLine == null || idx > afterLine) { - return " ".repeat(indent) + line; - } - return line; - }) - .join("\n"); -} - -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 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") { - delete headers["Content-Type"]; // fetch will set this automatically - } - - const snippetTemplate = endpoint.snippetTemplates?.typescript; - - if (snippetTemplate != null && isSnippetTemplatesEnabled) { - const resolver = new SnippetTemplateResolver({ - payload: convertToCustomSnippetPayload(formState), - endpointSnippetTemplate: { - sdk: { - type: "typescript", - package: "", - version: "", - }, - endpointId: { - path: stringifyResolvedEndpointPathPartsTemplate(endpoint.path), - method: endpoint.method, - }, - snippetTemplate, - }, - }); - const resolvedTemplate = resolver.resolve(); - - if (resolvedTemplate.type === "typescript") { - return resolvedTemplate.client; - } - } - - function buildFetch(body: string | undefined) { - if (endpoint == null) { - return ""; - } - return `// ${endpoint.title} (${endpoint.method} ${endpoint.path - .map((part) => (part.type === "literal" ? part.value : `:${part.key}`)) - .join("")}) -const response = await fetch("${buildEndpointUrl(endpoint, formState)}", { - method: "${endpoint.method}", - headers: ${indentAfter(JSON.stringify(headers, undefined, 2), 2, 0)},${!isEmpty(body) ? `\n body: ${body},` : ""} -}); - -const body = await response.json(); -console.log(body);`; - } - - if (formState.body == null) { - return buildFetch(undefined); - } - - return visitDiscriminatedUnion(formState.body, "type")._visit({ - "octet-stream": () => buildFetch('document.querySelector("input[type=file]").files[0]'), // TODO: implement this - json: ({ value }) => - buildFetch( - value != null ? indentAfter(`JSON.stringify(${JSON.stringify(value, undefined, 2)})`, 2, 0) : undefined, - ), - "form-data": ({ value }) => { - const file = Object.entries(value) - .filter(([, v]) => v.type === "file") - .map(([k]) => { - return `const ${k}File = document.getElementById("${k}").files[0]; -formData.append("${k}", ${k}File);`; - }) - .join("\n\n"); - - const fileArrays = Object.entries(value) - .filter(([, v]) => v.type === "fileArray") - .map(([k]) => { - return `const ${k}Files = document.getElementById("${k}").files; -${k}Files.forEach((file) => { - formData.append("${k}", file); -});`; - }) - .join("\n\n"); - - const jsons = Object.entries(value) - .filter(([, v]) => v.type === "json") - .map(([k, v]) => { - return `formData.append("${k}", ${indentAfter(`JSON.stringify(${JSON.stringify(v.value, undefined, 2)})`, 2, 0)});`; - }) - .join("\n\n"); - - const appendStatements = [file, fileArrays, jsons].filter((v) => v.length > 0).join("\n\n"); - - return `// Create a new FormData instance -const formData = new FormData();${appendStatements.length > 0 ? "\n\n" + appendStatements : ""} - -${buildFetch("formData")}`; - }, - _other: () => buildFetch(undefined), - }); -} - -export function stringifyPythonRequests({ - endpoint, - formState, - authState, - redacted, - isSnippetTemplatesEnabled, -}: { - endpoint: ResolvedEndpointDefinition | undefined; - formState: PlaygroundEndpointRequestFormState; - authState: PlaygroundAuthState; - redacted: boolean; - isSnippetTemplatesEnabled: boolean; -}): string { - if (endpoint == null) { - return ""; - } - - 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"]; - - interface PythonRequestParams { - json?: string; - data?: string; - files?: string; - } - - const snippetTemplate = endpoint.snippetTemplates?.python; - - if (snippetTemplate != null && isSnippetTemplatesEnabled) { - const resolver = new SnippetTemplateResolver({ - payload: convertToCustomSnippetPayload(formState), - endpointSnippetTemplate: { - sdk: { - type: "python", - package: "", - version: "", - }, - endpointId: { - path: stringifyResolvedEndpointPathPartsTemplate(endpoint.path), - method: endpoint.method, - }, - snippetTemplate, - }, - }); - - const resolvedTemplate = resolver.resolve(); - - if (resolvedTemplate.type === "python") { - return resolvedTemplate.sync_client; - } - } - - function buildRequests({ json, data, files }: PythonRequestParams) { - if (endpoint == null) { - return ""; - } - return `# ${endpoint.title} (${endpoint.method} ${buildPath(endpoint.path)}) -response = requests.${endpoint.method.toLowerCase()}( - "${buildEndpointUrl(endpoint, formState)}", - headers=${indentAfter(JSON.stringify(headers, undefined, 2), 2, 0)},${json != null ? `\n json=${indentAfter(json, 2, 0)},` : ""}${ - data != null ? `\n data=${indentAfter(data, 2, 0)},` : "" - }${files != null ? `\n files=${indentAfter(files, 2, 0)},` : ""} -) - -print(response.json())`; - } - - if (formState.body == null) { - return `${imports.map((pkg) => `import ${pkg}`).join("\n")} - -${buildRequests({})}`; - } - - return visitDiscriminatedUnion(formState.body, "type")._visit({ - json: ({ value }) => `${imports.map((pkg) => `import ${pkg}`).join("\n")} - -${buildRequests({ json: JSON.stringify(value, undefined, 2) })}`, - "form-data": ({ value }) => { - const singleFiles = Object.entries(value) - .filter((entry): entry is [string, PlaygroundFormDataEntryValue.SingleFile] => - PlaygroundFormDataEntryValue.isSingleFile(entry[1]), - ) - .map(([k, v]) => { - if (v.value == null) { - return undefined; - } - return `'${k}': ('${v.value.name}', open('${v.value.name}', 'rb')),`; - }) - .filter(isNonNullish); - const fileArrays = Object.entries(value) - .filter((entry): entry is [string, PlaygroundFormDataEntryValue.MultipleFiles] => - PlaygroundFormDataEntryValue.isMultipleFiles(entry[1]), - ) - .map(([k, v]) => { - const fileStrings = v.value.map((file) => `('${file.name}', open('${file.name}', 'rb'))`); - if (fileStrings.length === 0) { - return; - } - return `'${k}': [${fileStrings.length === 0 ? fileStrings[0] : indentAfter(`\n${fileStrings.join(",\n")},\n`, 2, 0)}],`; - }) - .filter(isNonNullish); - - const fileEntries = [...singleFiles, ...fileArrays].join("\n"); - const files = fileEntries.length > 0 ? `{\n${indentAfter(fileEntries, 2)}\n}` : undefined; - - const dataEntries = Object.entries(value) - .filter((entry): entry is [string, PlaygroundFormDataEntryValue.Json] => - PlaygroundFormDataEntryValue.isJson(entry[1]), - ) - .map(([k, v]) => - v.value == null - ? undefined - : `'${k}': json.dumps(${indentAfter(JSON.stringify(v.value, undefined, 2), 2, 0)}),`, - ) - .filter(isNonNullish) - .join("\n"); - - const data = dataEntries.length > 0 ? `{\n${indentAfter(dataEntries, 2)}\n}` : undefined; - - if (data != null) { - imports.push("json"); - } - - return `${imports.map((pkg) => `import ${pkg}`).join("\n")} - -${buildRequests({ data, files })}`; - }, - "octet-stream": (f) => `${imports.map((pkg) => `import ${pkg}`).join("\n")} - -${buildRequests({ data: f.value != null ? `open('${f.value?.name}', 'rb').read()` : undefined })}`, - _other: () => `${imports.map((pkg) => `import ${pkg}`).join("\n")} - -${buildRequests({})}`, - }); -} - export function convertToCustomSnippetPayload( formState: PlaygroundEndpointRequestFormState, ): Snippets.CustomSnippetPayload { @@ -420,73 +136,6 @@ export function buildAuthHeaders( 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 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, - // TODO: wire through the hook based environment - url: buildRequestUrl(resolveEnvironment(endpoint)?.baseUrl, endpoint?.path, formState?.pathParameters), - urlQueries: formState.queryParameters, - headers, - body: - formState.body == null - ? undefined - : visitDiscriminatedUnion(formState.body, "type")._visit({ - json: ({ value }) => ({ type: "json", value }), - "form-data": ({ value }): ResolvedExampleEndpointRequest.Form | undefined => { - const properties = - endpoint.requestBody?.shape.type === "formData" - ? endpoint.requestBody.shape.properties - : []; - const newValue: Record = {}; - for (const [key, v] of Object.entries(value)) { - const property = properties.find((property) => property.key === key); - const convertedV = convertPlaygroundFormDataEntryValueToResolvedExampleEndpointRequest( - v, - property, - domain, - ); - if (convertedV != null) { - newValue[key] = convertedV; - } - } - if (isEmpty(newValue)) { - return undefined; - } - return { type: "form", value: newValue }; - }, - "octet-stream": ({ value }): ResolvedExampleEndpointRequest.Bytes | undefined => - value != null ? { type: "bytes", fileName: value.name, value: undefined } : undefined, - _other: () => undefined, - }), - }); -} - export function getDefaultValueForObjectProperties( properties: ResolvedObjectProperty[] = [], types: Record, diff --git a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts index 19d2d75640..a962a00d81 100644 --- a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts +++ b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts @@ -331,7 +331,7 @@ export class ApiDefinitionResolver { description, auth, availability: endpoint.availability, - apiSectionId: node.apiDefinitionId, + apiDefinitionId: node.apiDefinitionId, environments: endpoint.environments, method: endpoint.method, examples: [], diff --git a/packages/ui/app/src/resolver/__test__/__snapshots__/ApiDefinitionResolver.test.ts.snap b/packages/ui/app/src/resolver/__test__/__snapshots__/ApiDefinitionResolver.test.ts.snap index 09dbe78869..02059371a6 100644 --- a/packages/ui/app/src/resolver/__test__/__snapshots__/ApiDefinitionResolver.test.ts.snap +++ b/packages/ui/app/src/resolver/__test__/__snapshots__/ApiDefinitionResolver.test.ts.snap @@ -13,7 +13,7 @@ exports[`resolveApiDefinition > should finish resolving 1`] = ` "description": undefined, "items": [ { - "apiSectionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", + "apiDefinitionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", "auth": { "tokenName": "token", "type": "bearerAuth", @@ -127,7 +127,7 @@ exports[`resolveApiDefinition > should resolve authed and unauthed endpoints 1`] }, "items": [ { - "apiSectionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", + "apiDefinitionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", "auth": undefined, "availability": undefined, "defaultEnvironment": { @@ -247,7 +247,7 @@ exports[`resolveApiDefinition > should resolve authed and unauthed endpoints 1`] "type": "endpoint", }, { - "apiSectionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", + "apiDefinitionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", "auth": { "tokenName": "token", "type": "bearerAuth", @@ -305,7 +305,7 @@ exports[`resolveApiDefinition > should resolve authed and unauthed endpoints 1`] "type": "endpoint", }, { - "apiSectionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", + "apiDefinitionId": "054ca3d6-33a5-4d53-a813-f2a33e4062cc", "auth": { "headerWireValue": "test-api-key", "type": "header", diff --git a/packages/ui/app/src/resolver/types.ts b/packages/ui/app/src/resolver/types.ts index 61cc042078..4729ad7598 100644 --- a/packages/ui/app/src/resolver/types.ts +++ b/packages/ui/app/src/resolver/types.ts @@ -220,7 +220,7 @@ export const ResolvedApiDefinition = { export interface ResolvedSubpackage extends WithMetadata, ResolvedWithApiDefinition { type: "subpackage"; - // apiSectionId: FdrAPI.ApiDefinitionId; + // apiDefinitionId: FdrAPI.ApiDefinitionId; // id: APIV1Read.SubpackageId; // name: string; title: string; @@ -243,7 +243,7 @@ export interface ResolvedEndpointDefinition extends WithMetadata { type: "endpoint"; nodeId: FernNavigation.NodeId; id: APIV1Read.EndpointId; - apiSectionId: FdrAPI.ApiDefinitionId; + apiDefinitionId: FdrAPI.ApiDefinitionId; // apiPackageId: FdrAPI.ApiDefinitionId | APIV1Read.SubpackageId; slug: FernNavigation.Slug; auth: APIV1Read.ApiAuth | undefined; diff --git a/packages/ui/components/src/CopyToClipboardButton.tsx b/packages/ui/components/src/CopyToClipboardButton.tsx index 35cf959c70..dd149755b7 100644 --- a/packages/ui/components/src/CopyToClipboardButton.tsx +++ b/packages/ui/components/src/CopyToClipboardButton.tsx @@ -8,7 +8,7 @@ import { FernTooltip, FernTooltipProvider } from "./FernTooltip"; export declare namespace CopyToClipboardButton { export interface Props { className?: string; - content?: string | (() => string); + content?: string | (() => string | Promise); testId?: string; children?: (onClick: ((e: React.MouseEvent) => void) | undefined) => React.ReactNode; onClick?: (e: React.MouseEvent) => void;