Skip to content

Commit

Permalink
HTTPSnippets (#769)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored May 2, 2024
1 parent d532154 commit 298286f
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 28 deletions.
1 change: 1 addition & 0 deletions packages/ui/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion packages/ui/app/src/api-page/examples/code-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/app/src/contexts/FeatureFlagContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface FeatureFlags {
isSeoDisabled: boolean;
isTocDefaultEnabled: boolean;
isSnippetTemplatesEnabled: boolean;
isHttpSnippetsEnabled: boolean;
}

export const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
Expand All @@ -16,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
isSeoDisabled: false,
isTocDefaultEnabled: false,
isSnippetTemplatesEnabled: false,
isHttpSnippetsEnabled: false,
};

export const FeatureFlagContext = createContext<FeatureFlags>(DEFAULT_FEATURE_FLAGS);
Expand Down
55 changes: 34 additions & 21 deletions packages/ui/app/src/resolver/ApiDefinitionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
154 changes: 149 additions & 5 deletions packages/ui/app/src/resolver/resolveCodeSnippets.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedCodeSnippet[]> {
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,
});
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse<FeatureFlags>> {
Expand All @@ -26,6 +27,7 @@ export async function getFeatureFlags(domain: string): Promise<FeatureFlags> {
"whitelabeled",
"seo-disabled",
"toc-default-enabled",
"snippet-template-enabled",
]);

const isApiPlaygroundEnabled = checkDomainMatchesCustomers(domain, config["api-playground-enabled"]);
Expand All @@ -34,6 +36,7 @@ export async function getFeatureFlags(domain: string): Promise<FeatureFlags> {
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,
Expand All @@ -42,6 +45,7 @@ export async function getFeatureFlags(domain: string): Promise<FeatureFlags> {
isSeoDisabled: isSeoDisabledOverrides(domain) || isSeoDisabled,
isTocDefaultEnabled,
isSnippetTemplatesEnabled: isSnippetTemplatesEnabled || isDevelopment(domain),
isHttpSnippetsEnabled,
};
} catch (e) {
// eslint-disable-next-line no-console
Expand All @@ -53,6 +57,7 @@ export async function getFeatureFlags(domain: string): Promise<FeatureFlags> {
isSeoDisabled: isSeoDisabledOverrides(domain),
isTocDefaultEnabled: false,
isSnippetTemplatesEnabled: isDevelopment(domain),
isHttpSnippetsEnabled: false,
};
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/local-preview-bundle/src/pages/[[...slug]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ async function getDocsPageProps(
isSeoDisabled: true,
isTocDefaultEnabled: false,
isSnippetTemplatesEnabled: false,
isHttpSnippetsEnabled: false,
};

const resolvedPath = await convertNavigatableToResolvedPath({
Expand Down
Loading

0 comments on commit 298286f

Please sign in to comment.