From 298286ff9b3420d4001bbe858369f2d513efbacd Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Wed, 1 May 2024 22:49:19 -0400 Subject: [PATCH] HTTPSnippets (#769) --- packages/ui/app/package.json | 1 + .../app/src/api-page/examples/code-example.ts | 17 +- .../ui/app/src/contexts/FeatureFlagContext.ts | 2 + .../app/src/resolver/ApiDefinitionResolver.ts | 55 ++++--- .../app/src/resolver/resolveCodeSnippets.ts | 154 +++++++++++++++++- .../src/pages/api/fern-docs/feature-flags.ts | 5 + .../src/pages/[[...slug]].tsx | 1 + pnpm-lock.yaml | 31 +++- 8 files changed, 238 insertions(+), 28 deletions(-) diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 3036329e59..1bcf9eca4f 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -68,6 +68,7 @@ "hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-string": "^3.0.0", "hastscript": "^9.0.0", + "httpsnippet-lite": "^3.0.5", "instantsearch.js": "^4.63.0", "jotai": "^2.7.0", "jsonpath": "^1.1.1", diff --git a/packages/ui/app/src/api-page/examples/code-example.ts b/packages/ui/app/src/api-page/examples/code-example.ts index d21ef8afa6..f5b3397d76 100644 --- a/packages/ui/app/src/api-page/examples/code-example.ts +++ b/packages/ui/app/src/api-page/examples/code-example.ts @@ -54,7 +54,7 @@ export function generateCodeExamples(examples: ResolvedExampleEndpointCall[]): C ...sortBy( Array.from(codeExamples.entries()).map(([language, examples]) => ({ language, - languageDisplayName: titleCase(language), + languageDisplayName: getLanguageDisplayName(language), icon: getIconForClient(language), examples, })), @@ -99,6 +99,21 @@ function getIconForClient(clientId: string) { } } +function getLanguageDisplayName(language: string) { + switch (language) { + case "go": + case "golang": + return "Go"; + case ".net": + return ".NET"; + case "c#": + case "csharp": + return "C#"; + default: + return titleCase(language); + } +} + // export interface CodeExampleClientCurl { // id: "curl"; // name: string; diff --git a/packages/ui/app/src/contexts/FeatureFlagContext.ts b/packages/ui/app/src/contexts/FeatureFlagContext.ts index d8ea068628..5700b8e1b4 100644 --- a/packages/ui/app/src/contexts/FeatureFlagContext.ts +++ b/packages/ui/app/src/contexts/FeatureFlagContext.ts @@ -7,6 +7,7 @@ export interface FeatureFlags { isSeoDisabled: boolean; isTocDefaultEnabled: boolean; isSnippetTemplatesEnabled: boolean; + isHttpSnippetsEnabled: boolean; } export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { @@ -16,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { isSeoDisabled: false, isTocDefaultEnabled: false, isSnippetTemplatesEnabled: false, + isHttpSnippetsEnabled: false, }; export const FeatureFlagContext = createContext(DEFAULT_FEATURE_FLAGS); diff --git a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts index 2574d2c3d3..67a1801c6d 100644 --- a/packages/ui/app/src/resolver/ApiDefinitionResolver.ts +++ b/packages/ui/app/src/resolver/ApiDefinitionResolver.ts @@ -351,27 +351,40 @@ export class ApiDefinitionResolver { snippetTemplates: endpoint.snippetTemplates, }; - toRet.examples = endpoint.examples.map((example) => { - const requestBody = this.resolveExampleEndpointRequest(example.requestBodyV3, toRet.requestBody[0]?.shape); - const responseBody = this.resolveExampleEndpointResponse(example.responseBodyV3, toRet.responseBody?.shape); - return { - name: example.name, - description: example.description, - path: example.path, - pathParameters: example.pathParameters, - queryParameters: example.queryParameters, - headers: example.headers, - requestBody, - responseStatusCode: example.responseStatusCode, - responseBody, - // TODO: handle this differently for streaming/file responses - // responseHast: - // responseBody != null - // ? highlight(highlighter, JSON.stringify(responseBody.value, undefined, 2), "json") - // : undefined, - snippets: resolveCodeSnippets(toRet, example, requestBody), - }; - }); + toRet.examples = await Promise.all( + endpoint.examples.map(async (example) => { + const requestBody = this.resolveExampleEndpointRequest( + example.requestBodyV3, + toRet.requestBody[0]?.shape, + ); + const responseBody = this.resolveExampleEndpointResponse( + example.responseBodyV3, + toRet.responseBody?.shape, + ); + return { + name: example.name, + description: example.description, + path: example.path, + pathParameters: example.pathParameters, + queryParameters: example.queryParameters, + headers: example.headers, + requestBody, + responseStatusCode: example.responseStatusCode, + responseBody, + // TODO: handle this differently for streaming/file responses + // responseHast: + // responseBody != null + // ? highlight(highlighter, JSON.stringify(responseBody.value, undefined, 2), "json") + // : undefined, + snippets: await resolveCodeSnippets( + toRet, + example, + requestBody, + this.featureFlags.isHttpSnippetsEnabled, + ), + }; + }), + ); return toRet; } diff --git a/packages/ui/app/src/resolver/resolveCodeSnippets.ts b/packages/ui/app/src/resolver/resolveCodeSnippets.ts index 73a9152365..654a882d0c 100644 --- a/packages/ui/app/src/resolver/resolveCodeSnippets.ts +++ b/packages/ui/app/src/resolver/resolveCodeSnippets.ts @@ -1,25 +1,45 @@ import { APIV1Read } from "@fern-api/fdr-sdk"; +import { noop, visitDiscriminatedUnion } from "@fern-ui/core-utils"; +import { HTTPSnippet, type HarRequest, type TargetId } from "httpsnippet-lite"; import { convertEndpointExampleToHttpRequestExample } from "../api-page/examples/HttpRequestExample"; import { stringifyHttpRequestExampleToCurl } from "../api-page/examples/stringifyHttpRequestExampleToCurl"; +import { buildRequestUrl, unknownToString } from "../api-playground/utils"; import { ResolvedCodeSnippet, ResolvedEndpointDefinition, ResolvedExampleEndpointRequest } from "./types"; -export function resolveCodeSnippets( +interface HTTPSnippetClient { + targetId: TargetId; + clientId: string; +} + +const CLIENTS: HTTPSnippetClient[] = [ + { targetId: "python", clientId: "requests" }, + { targetId: "javascript", clientId: "fetch" }, + { targetId: "go", clientId: "native" }, + { targetId: "ruby", clientId: "native" }, + { targetId: "java", clientId: "unirest" }, + { targetId: "php", clientId: "guzzle" }, + { targetId: "csharp", clientId: "restsharp" }, + { targetId: "swift", clientId: "nsurlsession" }, +]; + +export async function resolveCodeSnippets( endpoint: ResolvedEndpointDefinition, example: APIV1Read.ExampleEndpointCall, requestBody: ResolvedExampleEndpointRequest | undefined, - // highlighter: Highlighter, -): ResolvedCodeSnippet[] { + isHttpSnippetsEnabled: boolean, +): Promise { let toRet: ResolvedCodeSnippet[] = []; + const snippet = new HTTPSnippet(getHarRequest(endpoint, example, requestBody)); + const curlCode = stringifyHttpRequestExampleToCurl( convertEndpointExampleToHttpRequestExample(endpoint, example, requestBody), ); - toRet.push({ name: undefined, language: "curl", install: undefined, - code: curlCode, + code: unknownToString(curlCode), // hast: highlight(highlighter, curlCode, "bash"), generated: true, }); @@ -82,6 +102,32 @@ export function resolveCodeSnippets( }); }); + if (isHttpSnippetsEnabled) { + for (const { clientId, targetId } of CLIENTS) { + if (toRet.some((snippet) => cleanLanguage(snippet.language) === targetId)) { + continue; + } + + if ( + targetId === "javascript" && + toRet.some((snippet) => cleanLanguage(snippet.language) === "typescript") + ) { + continue; + } + + const code = await snippet.convert(targetId, clientId); + if (code != null) { + toRet.push({ + name: undefined, + language: targetId, + install: undefined, + code: typeof code === "string" ? code : code[0], + generated: true, + }); + } + } + } + return toRet; } @@ -105,3 +151,101 @@ function cleanLanguage(language: string): string { return language; } + +function getHarRequest( + endpoint: ResolvedEndpointDefinition, + example: APIV1Read.ExampleEndpointCall, + requestBody: ResolvedExampleEndpointRequest | undefined, +): HarRequest { + const request: HarRequest = { + httpVersion: "1.1", + method: "GET", + url: "", + headers: [], + headersSize: -1, + queryString: [], + cookies: [], + bodySize: -1, + }; + request.url = buildRequestUrl(endpoint.defaultEnvironment?.baseUrl, endpoint.path, example.pathParameters); + request.method = endpoint.method; + request.queryString = Object.entries(example.queryParameters).map(([name, value]) => ({ + name, + value: unknownToString(value), + })); + request.headers = Object.entries(example.headers).map(([name, value]) => ({ name, value: unknownToString(value) })); + + let mimeType = endpoint.requestBody[0]?.contentType as string | undefined; + + if (requestBody != null) { + if (mimeType == null) { + mimeType = requestBody.type === "json" ? "application/json" : "multipart/form-data"; + } + request.postData = { + mimeType, + }; + + if (requestBody.type === "json") { + request.postData.text = JSON.stringify(requestBody.value, null, 2); + } else if (requestBody.type === "form") { + request.postData.params = []; + + for (const [name, value] of Object.entries(requestBody.value)) { + if (value.type === "json") { + request.postData.params.push({ + name, + value: JSON.stringify(value.value, null, 2), + }); + } else if (value.type === "file") { + request.postData.params.push({ + name, + fileName: value.fileName, + }); + } else if (value.type === "fileArray") { + for (const fileName of value.fileNames) { + request.postData.params.push({ + name, + fileName, + }); + } + } + } + } else if (requestBody.type === "stream") { + // TODO: verify this is correct + request.postData.params = [{ name: "file", fileName: requestBody.fileName }]; + } + } + + if (endpoint.auth != null) { + visitDiscriminatedUnion(endpoint.auth, "type")._visit({ + basicAuth: ({ usernameName = "username", passwordName = "password" }) => { + request.headers.push({ + name: "Authorization", + value: `Basic <${usernameName}>:<${passwordName}>`, + }); + }, + bearerAuth: ({ tokenName = "token" }) => { + request.headers.push({ + name: "Authorization", + value: `Bearer <${tokenName}>`, + }); + }, + header: ({ headerWireValue, nameOverride = headerWireValue, prefix }) => { + request.headers.push({ + name: headerWireValue, + value: prefix != null ? `${prefix} <${nameOverride}>` : `<${nameOverride}>`, + }); + }, + _other: noop, + }); + } + + if (mimeType != null) { + request.headers.push({ + name: "Content-Type", + value: mimeType, + }); + } + + return request; +} diff --git a/packages/ui/docs-bundle/src/pages/api/fern-docs/feature-flags.ts b/packages/ui/docs-bundle/src/pages/api/fern-docs/feature-flags.ts index 9b65fb72aa..30c6b4a244 100644 --- a/packages/ui/docs-bundle/src/pages/api/fern-docs/feature-flags.ts +++ b/packages/ui/docs-bundle/src/pages/api/fern-docs/feature-flags.ts @@ -11,6 +11,7 @@ interface EdgeConfigResponse { "seo-disabled": string[]; "toc-default-enabled": string[]; // toc={true} in Steps, Tabs, and Accordions "snippet-template-enabled": string[]; + "http-snippets-enabled": string[]; } export default async function handler(req: NextRequest): Promise> { @@ -26,6 +27,7 @@ export async function getFeatureFlags(domain: string): Promise { "whitelabeled", "seo-disabled", "toc-default-enabled", + "snippet-template-enabled", ]); const isApiPlaygroundEnabled = checkDomainMatchesCustomers(domain, config["api-playground-enabled"]); @@ -34,6 +36,7 @@ export async function getFeatureFlags(domain: string): Promise { const isSeoDisabled = checkDomainMatchesCustomers(domain, config["seo-disabled"]); const isTocDefaultEnabled = checkDomainMatchesCustomers(domain, config["toc-default-enabled"]); const isSnippetTemplatesEnabled = checkDomainMatchesCustomers(domain, config["snippet-template-enabled"]); + const isHttpSnippetsEnabled = checkDomainMatchesCustomers(domain, config["http-snippets-enabled"]); return { isApiPlaygroundEnabled: isApiPlaygroundEnabledOverrides(domain) || isApiPlaygroundEnabled, @@ -42,6 +45,7 @@ export async function getFeatureFlags(domain: string): Promise { isSeoDisabled: isSeoDisabledOverrides(domain) || isSeoDisabled, isTocDefaultEnabled, isSnippetTemplatesEnabled: isSnippetTemplatesEnabled || isDevelopment(domain), + isHttpSnippetsEnabled, }; } catch (e) { // eslint-disable-next-line no-console @@ -53,6 +57,7 @@ export async function getFeatureFlags(domain: string): Promise { isSeoDisabled: isSeoDisabledOverrides(domain), isTocDefaultEnabled: false, isSnippetTemplatesEnabled: isDevelopment(domain), + isHttpSnippetsEnabled: false, }; } } diff --git a/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx b/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx index cb4b38d105..ad9d276d07 100644 --- a/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx +++ b/packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx @@ -129,6 +129,7 @@ async function getDocsPageProps( isSeoDisabled: true, isTocDefaultEnabled: false, isSnippetTemplatesEnabled: false, + isHttpSnippetsEnabled: false, }; const resolvedPath = await convertNavigatableToResolvedPath({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee78a27407..a74bd229a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -861,6 +861,9 @@ importers: hastscript: specifier: ^9.0.0 version: 9.0.0 + httpsnippet-lite: + specifier: ^3.0.5 + version: 3.0.5 instantsearch.js: specifier: ^4.63.0 version: 4.66.0(algoliasearch@4.22.1) @@ -9169,7 +9172,6 @@ packages: /@types/har-format@1.2.15: resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==} - dev: true /@types/hast@3.0.4: resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -14084,6 +14086,14 @@ packages: combined-stream: 1.0.8 mime-types: 2.1.35 + /formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + dev: false + /forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -14958,6 +14968,15 @@ packages: - supports-color dev: true + /httpsnippet-lite@3.0.5: + resolution: {integrity: sha512-So4qTXY5iFj5XtFDwyz2PicUu+8NWrI8e8h+ZeZoVtMNcFQp4FFIntBHUE+JPUG6QQU8o1VHCy+X4ETRDwt9CA==} + engines: {node: '>=14.13'} + dependencies: + '@types/har-format': 1.2.15 + formdata-node: 4.4.1 + stringify-object: 3.3.0 + dev: false + /httpsnippet@2.0.0(mkdirp@3.0.1): resolution: {integrity: sha512-Hb2ttfB5OhasYxwChZ8QKpYX3v4plNvwMaMulUIC7M3RHRDf1Op6EMp47LfaU2sgQgfvo5spWK4xRAirMEisrg==} engines: {node: '>=10'} @@ -17641,6 +17660,11 @@ packages: minimatch: 3.1.2 dev: true + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} dev: true @@ -22374,6 +22398,11 @@ packages: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} dev: false + /web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + dev: false + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}