diff --git a/app/(api-reference)/api-reference/[[...slug]]/loading.tsx b/app/(api-reference)/api-reference/[[...slug]]/loading.tsx new file mode 100644 index 000000000..70c96dada --- /dev/null +++ b/app/(api-reference)/api-reference/[[...slug]]/loading.tsx @@ -0,0 +1,18 @@ +export default function Loading() { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(api-reference)/api-reference/[[...slug]]/page.tsx b/app/(api-reference)/api-reference/[[...slug]]/page.tsx new file mode 100644 index 000000000..b815bcbc5 --- /dev/null +++ b/app/(api-reference)/api-reference/[[...slug]]/page.tsx @@ -0,0 +1,146 @@ +import { Suspense } from "react"; +import { getStainlessSpec } from "../../../../lib/openapi/loader"; +import { + buildSchemaReferences, + getSidebarContent, +} from "../../../../lib/openapi/helpers"; +import { + RESOURCE_ORDER, + API_REFERENCE_OVERVIEW_CONTENT, +} from "../../../../data/sidebars/apiOverviewSidebar"; +import { + PageShell, + ResourceSection, + PreContent, + ResourceSectionSkeleton, +} from "../components"; +import type { Metadata } from "next"; + +export const dynamic = "force-static"; +export const revalidate = 3600; + +export async function generateMetadata(): Promise { + return { + title: "API reference | Knock Docs", + description: "Complete reference documentation for the Knock API.", + alternates: { + canonical: "/api-reference", + }, + }; +} + +// Generate all possible static paths +export async function generateStaticParams() { + const stainless = await getStainlessSpec("api"); + const paths: { slug: string[] }[] = []; + + // Root path (no slug) + paths.push({ slug: [] }); + + // Overview paths + API_REFERENCE_OVERVIEW_CONTENT.forEach((section) => { + if (section.pages) { + section.pages.forEach((page) => { + const slug = page.slug.replace(/^\//, "").split("/").filter(Boolean); + if (slug.length > 0) { + paths.push({ slug: ["overview", ...slug] }); + } + }); + } + }); + paths.push({ slug: ["overview"] }); + + // Resource paths + function addResourcePaths( + resourceName: string, + resource: (typeof stainless.resources)[string], + basePath: string[] = [], + ) { + const resourcePath = [...basePath, resourceName]; + paths.push({ slug: resourcePath }); + + // Method paths + if (resource.methods) { + Object.keys(resource.methods).forEach((methodName) => { + paths.push({ slug: [...resourcePath, methodName] }); + }); + } + + // Schema paths + if (resource.models) { + paths.push({ slug: [...resourcePath, "schemas"] }); + Object.keys(resource.models).forEach((modelName) => { + paths.push({ slug: [...resourcePath, "schemas", modelName] }); + }); + } + + // Subresource paths + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subName, subResource]) => { + addResourcePaths(subName, subResource, resourcePath); + }, + ); + } + } + + RESOURCE_ORDER.forEach((resourceName) => { + const resource = stainless.resources[resourceName]; + if (resource) { + addResourcePaths(resourceName, resource); + } + }); + + return paths; +} + +export default async function ApiReferencePage() { + const stainless = await getStainlessSpec("api"); + const schemaReferences = await buildSchemaReferences("api", "/api-reference"); + const baseUrl = stainless.environments.production; + + // Transform sidebar content for compatibility + const sidebarContent = await getSidebarContent( + "api", + RESOURCE_ORDER, + "/api-reference", + API_REFERENCE_OVERVIEW_CONTENT.map((section) => ({ + ...section, + title: section.title || "", + })), + ); + + // Transform to SidebarSection format + const transformedSidebar = sidebarContent.map((section) => ({ + title: section.title, + slug: section.slug, + pages: section.pages || [], + sidebarMenuDefaultOpen: + API_REFERENCE_OVERVIEW_CONTENT.find((s) => s.slug === section.slug) + ?.sidebarMenuDefaultOpen ?? false, + })); + + return ( + + }> + + + + {RESOURCE_ORDER.map((resourceName) => ( + }> + + + ))} + + ); +} diff --git a/app/(api-reference)/api-reference/components/api-sections.tsx b/app/(api-reference)/api-reference/components/api-sections.tsx new file mode 100644 index 000000000..463009fc8 --- /dev/null +++ b/app/(api-reference)/api-reference/components/api-sections.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Box, Stack } from "@telegraph/layout"; +import { Text, Heading } from "@telegraph/typography"; +import Link from "next/link"; +import { SectionHeadingAppRouter } from "./section-heading"; +import { ContentActionsAppRouter } from "./content-actions"; + +export const Section = ({ + title, + children, + isIdempotent = false, + isRetentionSubject = false, + path, + mdPath, +}: { + title?: string; + children: React.ReactNode; + isIdempotent?: boolean; + isRetentionSubject?: boolean; + path?: string; + mdPath?: string; +}) => { + const handleRetentionClick = (e: React.MouseEvent) => { + e.preventDefault(); + const element = document.querySelector( + '[data-resource-path="/overview/data-retention"]', + ); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + } else { + window.location.href = "/api-reference/overview/data-retention"; + } + }; + + const handleIdempotentClick = (e: React.MouseEvent) => { + e.preventDefault(); + const element = document.querySelector( + '[data-resource-path="/overview/idempotent-requests"]', + ); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + } else { + window.location.href = "/api-reference/overview/idempotent-requests"; + } + }; + + return ( + + {title && ( + <> + {isIdempotent && ( + + + + Idempotent + + + + )} + {isRetentionSubject && ( + + + + Retention policy applied + + + + )} + + + {title} + + {mdPath && } + + + )} + + {children} + + + ); +}; + +export const ContentColumn = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export const ExampleColumn = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + +); + +export const ErrorExample = ({ + title, + description, +}: { + title: string; + description: React.ReactNode; +}) => ( +
+ + {title} + + + {description} + +
+); diff --git a/app/(api-reference)/api-reference/components/content-actions.tsx b/app/(api-reference)/api-reference/components/content-actions.tsx new file mode 100644 index 000000000..1913cfb44 --- /dev/null +++ b/app/(api-reference)/api-reference/components/content-actions.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Button } from "@telegraph/button"; +import { Stack } from "@telegraph/layout"; +import { Icon } from "@telegraph/icon"; +import { Check, Link2 } from "lucide-react"; + +interface ContentActionsProps { + mdPath?: string; + showOnMobile?: boolean; + style?: React.CSSProperties; +} + +export function ContentActionsAppRouter({ + mdPath, + showOnMobile = false, + style, +}: ContentActionsProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + const handleCopyLink = async () => { + const url = window.location.href; + await navigator.clipboard.writeText(url); + setCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setCopied(false); + }, 2000); + }; + + return ( + + + + + + ); +} diff --git a/app/(api-reference)/api-reference/components/expandable-response.tsx b/app/(api-reference)/api-reference/components/expandable-response.tsx new file mode 100644 index 000000000..ccd1a083c --- /dev/null +++ b/app/(api-reference)/api-reference/components/expandable-response.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { AnimatePresence, motion } from "framer-motion"; +import Link from "next/link"; +import { Box, Stack } from "@telegraph/layout"; +import { Text, Code } from "@telegraph/typography"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface ExpandableResponseProps { + responseSchema: OpenAPIV3.SchemaObject; + schemaReferences: Record; +} + +export function ExpandableResponse({ + responseSchema, + schemaReferences, +}: ExpandableResponseProps) { + const [isExpanded, setIsExpanded] = useState(false); + const schemaRef = schemaReferences[responseSchema.title ?? ""]; + + return ( + + + + {schemaRef ? ( + + {responseSchema.title} + + ) : ( + + {responseSchema.title} + + )} + + {responseSchema.description && ( + + {responseSchema.description} + + )} + + {responseSchema.properties && ( + <> + + + + {isExpanded && ( + + + + + + )} + + + )} + + + ); +} + +// Inline client version of schema properties for nested rendering +function SchemaPropertiesClient({ + schema, + schemaReferences, + hideRequired = false, +}: { + schema: OpenAPIV3.SchemaObject; + schemaReferences: Record; + hideRequired?: boolean; +}) { + return ( + + {Object.entries(schema.properties || {}).map( + ([propertyName, property]) => { + const prop = property as OpenAPIV3.SchemaObject; + const isRequired = + !hideRequired && schema.required?.includes(propertyName); + const typeString = getTypeString(prop); + const typeRef = schemaReferences[typeString]; + + return ( + + + + {propertyName} + + {typeRef ? ( + + {typeString} + + ) : ( + + {typeString} + + )} + {isRequired && ( + + required + + )} + + {prop.description && ( + + {prop.description} + + )} + {prop.enum && ( + + One of: {prop.enum.map((e) => `"${e}"`).join(", ")} + + )} + + ); + }, + )} + + ); +} + +function getTypeString(schema: OpenAPIV3.SchemaObject): string { + if (schema.title) return schema.title; + if (schema.type === "array" && schema.items) { + const items = schema.items as OpenAPIV3.SchemaObject; + return `${items.title || items.type || "unknown"}[]`; + } + return schema.type || "unknown"; +} diff --git a/app/(api-reference)/api-reference/components/index.ts b/app/(api-reference)/api-reference/components/index.ts new file mode 100644 index 000000000..d9c953211 --- /dev/null +++ b/app/(api-reference)/api-reference/components/index.ts @@ -0,0 +1,7 @@ +export { PageShell } from "./page-shell"; +export { ResourceSection } from "./resource-section"; +export { ResourceSectionClient } from "./resource-section-client"; +export { MethodContentClient } from "./method-content-client"; +export { SchemaSectionClient } from "./schema-section-client"; +export { PreContent } from "./pre-content"; +export { ResourceSectionSkeleton } from "./loading-skeleton"; diff --git a/app/(api-reference)/api-reference/components/loading-skeleton.tsx b/app/(api-reference)/api-reference/components/loading-skeleton.tsx new file mode 100644 index 000000000..e52c347f0 --- /dev/null +++ b/app/(api-reference)/api-reference/components/loading-skeleton.tsx @@ -0,0 +1,27 @@ +export function ResourceSectionSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/app/(api-reference)/api-reference/components/method-content-client.tsx b/app/(api-reference)/api-reference/components/method-content-client.tsx new file mode 100644 index 000000000..917dd1ffb --- /dev/null +++ b/app/(api-reference)/api-reference/components/method-content-client.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useState } from "react"; +import Markdown from "react-markdown"; +import Link from "next/link"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Section, ContentColumn, ExampleColumn } from "./api-sections"; +import { RateLimitAppRouter } from "./rate-limit"; +import { CodeBlock } from "../../../../components/ui/CodeBlock"; + +interface MethodContentClientProps { + methodName: string; + methodType: string; + endpoint: string; + path: string; + mdPath: string; + operation: Record; + baseUrl: string; + schemaReferences: Record; +} + +export function MethodContentClient({ + methodName, + methodType, + endpoint, + path, + mdPath, + operation, + baseUrl, + schemaReferences, +}: MethodContentClientProps) { + const [isResponseExpanded, setIsResponseExpanded] = useState(false); + + const parameters = (operation.parameters as any[]) || []; + const pathParameters = parameters.filter((p) => p.in === "path"); + const queryParameters = parameters.filter((p) => p.in === "query"); + + const requestBody = (operation.requestBody as any)?.content?.[ + "application/json" + ]?.schema; + + const responses = operation.responses as Record; + const responseSchemas = Object.values(responses || {}) + .map((r) => r.content?.["application/json"]?.schema) + .filter((r) => !!r) + .map((responseSchema: any) => { + if (responseSchema?.allOf) { + return responseSchema.allOf[0]; + } + return responseSchema; + }); + + const rateLimitRaw = operation["x-ratelimit-tier"] as number | null; + const rateLimit = + rateLimitRaw && [1, 2, 3, 4, 5].includes(rateLimitRaw) + ? (rateLimitRaw as 1 | 2 | 3 | 4 | 5) + : null; + const isIdempotent = (operation["x-idempotent"] as boolean) ?? false; + const isRetentionSubject = + (operation["x-retention-policy"] as boolean) ?? false; + const isBeta = (operation["x-beta"] as boolean) ?? false; + + const snippets = operation["x-stainless-snippets"] as + | Record + | undefined; + + const curlSnippet = `curl -X ${methodType.toUpperCase()} ${baseUrl}${endpoint} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer sk_test_12345"${ + requestBody?.example + ? ` \\ + -d '${JSON.stringify(requestBody.example)}'` + : "" + }`; + + const examples = { curl: curlSnippet, ...(snippets || {}) }; + + return ( +
+ + {(operation.description as string) ?? ""} + + {isBeta && ( +
+

+ 🚧 This endpoint is currently in beta. If you'd like early access, + or this is blocking your adoption of Knock, please{" "} + + get in touch + + . +

+
+ )} + +

+ Endpoint +

+ +
+ + {methodType.toUpperCase()} + + {endpoint} +
+ + {rateLimit && ( +
+

Rate limit

+ +
+ )} + + {pathParameters.length > 0 && ( + <> +

+ Path parameters +

+ + + )} + + {queryParameters.length > 0 && ( + <> +

+ Query parameters +

+ + + )} + + {requestBody && ( + <> +

+ Request body +

+ + + )} + +

+ Returns +

+ + {responseSchemas.length > 0 ? ( + responseSchemas.map((responseSchema: any, index: number) => { + const schemaRef = schemaReferences[responseSchema.title ?? ""]; + + return ( +
+
+ {schemaRef ? ( + + {responseSchema.title} + + ) : ( + {responseSchema.title} + )} +
+ {responseSchema.description && ( +

+ {responseSchema.description} +

+ )} + + {responseSchema.properties && ( + <> + + + + {isResponseExpanded && ( + +
+ +
+
+ )} +
+ + )} +
+ ); + }) + ) : ( +
+ {Object.entries(responses || {}).map(([statusCode, resp]: any) => ( + + {resp.description + ? `${statusCode} ${resp.description}` + : statusCode} + + ))} +
+ )} +
+ + + + {examples.curl} + + {responseSchemas.map( + (responseSchema: any) => + responseSchema?.example && ( + + {JSON.stringify(responseSchema?.example, null, 2)} + + ), + )} + +
+ ); +} + +function ParameterList({ + parameters, + schemaReferences, +}: { + parameters: any[]; + schemaReferences: Record; +}) { + return ( +
+ {parameters.map((param) => { + const schema = param.schema; + const typeString = getTypeString(schema); + const typeRef = schemaReferences[typeString]; + + return ( +
+
+ {param.name} + {typeRef ? ( + + {typeString} + + ) : ( + {typeString} + )} + {param.required && ( + required + )} +
+ {param.description && ( +

{param.description}

+ )} + {schema?.enum && ( +

+ One of: {schema.enum.map((e: string) => `"${e}"`).join(", ")} +

+ )} + {schema?.default !== undefined && ( +

+ Default: {JSON.stringify(schema.default)} +

+ )} +
+ ); + })} +
+ ); +} + +function SchemaProperties({ + schema, + schemaReferences, + hideRequired = false, +}: { + schema: any; + schemaReferences: Record; + hideRequired?: boolean; +}) { + if (!schema.properties) return null; + + return ( +
+ {Object.entries(schema.properties).map(([propName, propSchema]: any) => { + const typeString = getTypeString(propSchema); + const typeRef = schemaReferences[typeString]; + const isRequired = !hideRequired && schema.required?.includes(propName); + + return ( +
+
+ {propName} + {typeRef ? ( + + {typeString} + + ) : ( + {typeString} + )} + {isRequired && ( + required + )} + {propSchema.nullable && ( + nullable + )} +
+ {propSchema.description && ( +

{propSchema.description}

+ )} + {propSchema.enum && ( +

+ One of:{" "} + {propSchema.enum.map((e: string) => `"${e}"`).join(", ")} +

+ )} + {propSchema.default !== undefined && ( +

+ Default: {JSON.stringify(propSchema.default)} +

+ )} +
+ ); + })} +
+ ); +} + +function getMethodColor(method: string): string { + switch (method.toLowerCase()) { + case "get": + return "bg-green-100 text-green-800"; + case "post": + return "bg-blue-100 text-blue-800"; + case "put": + return "bg-yellow-100 text-yellow-800"; + case "patch": + return "bg-orange-100 text-orange-800"; + case "delete": + return "bg-red-100 text-red-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} + +function getTypeString(schema: any): string { + if (!schema) return "unknown"; + if (schema.title) return schema.title; + if (schema.type === "array" && schema.items) { + return `${schema.items.title || schema.items.type || "unknown"}[]`; + } + return schema.type || "unknown"; +} diff --git a/app/(api-reference)/api-reference/components/method-content.tsx b/app/(api-reference)/api-reference/components/method-content.tsx new file mode 100644 index 000000000..9a25252bb --- /dev/null +++ b/app/(api-reference)/api-reference/components/method-content.tsx @@ -0,0 +1,258 @@ +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import Markdown from "react-markdown"; +import { Box } from "@telegraph/layout"; +import { Code, Heading } from "@telegraph/typography"; +import { Callout } from "../../../../components/ui/Callout"; +import { ContentColumn, ExampleColumn, Section } from "./api-sections"; +import { RateLimitAppRouter } from "./rate-limit"; +import { CodeBlock } from "../../../../components/ui/CodeBlock"; +import { Endpoint } from "../../../../components/ui/Endpoints"; +import { getOpenApiSpec } from "../../../../lib/openapi/loader"; +import { + getOperation, + augmentSnippetsWithCurlRequest, + formatResponseStatusCodes, + resolveResponseSchemas, +} from "../../../../lib/openapi/helpers"; +import type { SpecName } from "../../../../lib/openapi/types"; +import { OperationParametersServer } from "./operation-parameters-server"; +import { SchemaPropertiesServer } from "./schema-properties-server"; +import { ExpandableResponse } from "./expandable-response"; +import { MultiLangExampleServer } from "./multi-lang-example-server"; + +interface MethodContentProps { + specName: SpecName; + methodName: string; + methodType: "get" | "post" | "put" | "delete"; + endpoint: string; + path: string; + mdPath: string; + baseUrl: string; + schemaReferences: Record; +} + +export async function MethodContent({ + specName, + methodName, + methodType, + endpoint, + path, + mdPath, + baseUrl, + schemaReferences, +}: MethodContentProps) { + const openApiSpec = await getOpenApiSpec(specName); + const method = getOperation(openApiSpec, methodType, endpoint); + + if (!method) { + return null; + } + + const parameters = (method.parameters || []) as OpenAPIV3.ParameterObject[]; + const pathParameters = parameters.filter((p) => p.in === "path"); + const queryParameters = parameters.filter((p) => p.in === "query"); + + const responseSchemas = resolveResponseSchemas(method); + const requestBody = ( + method.requestBody as OpenAPIV3.RequestBodyObject | undefined + )?.content?.["application/json"]?.schema as + | OpenAPIV3.SchemaObject + | undefined; + + const rateLimitRaw = (method as Record)?.[ + "x-ratelimit-tier" + ] as number | null; + const rateLimit = + rateLimitRaw && [1, 2, 3, 4, 5].includes(rateLimitRaw) + ? (rateLimitRaw as 1 | 2 | 3 | 4 | 5) + : null; + const isIdempotent = + ((method as Record)?.["x-idempotent"] as boolean) ?? false; + const isRetentionSubject = + ((method as Record)?.["x-retention-policy"] as boolean) ?? + false; + const isBeta = + ((method as Record)?.["x-beta"] as boolean) ?? false; + + const snippets = (method as Record)?.[ + "x-stainless-snippets" + ] as Record | undefined; + + return ( +
+ + {method.description ?? ""} + {isBeta && ( + + This endpoint is currently in beta. If you'd like early access, + or this is blocking your adoption of Knock, please{" "} + + get in touch + + . + + } + /> + )} + + + Endpoint + + + + + {rateLimit && ( + + + Rate limit + + + + )} + + {pathParameters.length > 0 && ( + <> + + Path parameters + + + + )} + + {queryParameters.length > 0 && ( + <> + + Query parameters + + + + )} + + {requestBody && ( + <> + + Request body + + + + )} + + + Returns + + + {responseSchemas.length > 0 && + responseSchemas.map((responseSchema) => ( + + ))} + + {responseSchemas.length === 0 && ( + + {formatResponseStatusCodes(method).map((formattedStatus, index) => ( + + {formattedStatus} + + ))} + + )} + + + + {responseSchemas.map( + (responseSchema) => + responseSchema?.example && ( + + {JSON.stringify(responseSchema?.example, null, 2)} + + ), + )} + +
+ ); +} diff --git a/app/(api-reference)/api-reference/components/multi-lang-example-server.tsx b/app/(api-reference)/api-reference/components/multi-lang-example-server.tsx new file mode 100644 index 000000000..4b850998d --- /dev/null +++ b/app/(api-reference)/api-reference/components/multi-lang-example-server.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEventEmitter } from "@byteclaw/use-event-emitter"; +import { useEffect, useMemo } from "react"; + +import { useIsMounted } from "../../../../hooks/useIsMounted"; +import useLocalStorage from "../../../../hooks/useLocalStorage"; +import { + CodeBlock, + SupportedLanguage, +} from "../../../../components/ui/CodeBlock"; +import { + EVENT_NAME, + LOCAL_STORAGE_KEY, +} from "../../../../components/ui/MultiLangCodeBlock"; + +interface MultiLangExampleServerProps { + examples: Record; + title: string; +} + +function resolveLanguageToSnippet(language: string) { + switch (language) { + case "node": + return "typescript"; + default: + return language; + } +} + +function resolveSnippetLanguages(language: string) { + switch (language) { + case "typescript": + return "node"; + default: + return language; + } +} + +export function MultiLangExampleServer({ + examples, + title, +}: MultiLangExampleServerProps) { + const isMounted = useIsMounted(); + const eventEmitter = useEventEmitter(); + + const languages = useMemo( + () => Object.keys(examples).map(resolveSnippetLanguages), + [examples], + ); + + const [language, setLanguage] = useLocalStorage( + LOCAL_STORAGE_KEY, + "node", + ); + + const resolvedLanguage = resolveLanguageToSnippet(language); + + useEffect(() => { + const unsubscribe = eventEmitter.on(EVENT_NAME, setLanguage); + return () => unsubscribe(); + }, [eventEmitter, setLanguage]); + + useEffect(() => { + eventEmitter.emit(EVENT_NAME, language); + }, [language, eventEmitter]); + + const exampleContent = useMemo(() => { + const exampleInSelectedLanguage = examples[resolvedLanguage]; + const listedLanguage = languages && languages[0]; + + if (!exampleInSelectedLanguage) { + return examples[listedLanguage]; + } + + return exampleInSelectedLanguage; + }, [examples, resolvedLanguage, languages]); + + if (!isMounted) return null; + + return ( + + {exampleContent} + + ); +} diff --git a/app/(api-reference)/api-reference/components/operation-parameters-server.tsx b/app/(api-reference)/api-reference/components/operation-parameters-server.tsx new file mode 100644 index 000000000..ea3928d3f --- /dev/null +++ b/app/(api-reference)/api-reference/components/operation-parameters-server.tsx @@ -0,0 +1,87 @@ +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import Link from "next/link"; +import { Box, Stack } from "@telegraph/layout"; +import { Text, Code } from "@telegraph/typography"; + +interface OperationParametersServerProps { + parameters: OpenAPIV3.ParameterObject[]; + schemaReferences: Record; +} + +export function OperationParametersServer({ + parameters, + schemaReferences, +}: OperationParametersServerProps) { + return ( + + {parameters.map((parameter) => { + const schema = parameter.schema as OpenAPIV3.SchemaObject | undefined; + const typeString = getTypeString(schema); + const typeRef = schemaReferences[typeString]; + + return ( + + + + {parameter.name} + + {typeRef ? ( + + {typeString} + + ) : ( + + {typeString} + + )} + {parameter.required && ( + + required + + )} + + {parameter.description && ( + + {parameter.description} + + )} + {schema?.enum && ( + + One of: {schema.enum.map((e) => `"${e}"`).join(", ")} + + )} + {schema?.default !== undefined && ( + + Default: {JSON.stringify(schema.default)} + + )} + + ); + })} + + ); +} + +function getTypeString(schema?: OpenAPIV3.SchemaObject): string { + if (!schema) return "unknown"; + if (schema.title) return schema.title; + if (schema.type === "array" && schema.items) { + const items = schema.items as OpenAPIV3.SchemaObject; + return `${items.title || items.type || "unknown"}[]`; + } + return schema.type || "unknown"; +} diff --git a/app/(api-reference)/api-reference/components/page-shell.tsx b/app/(api-reference)/api-reference/components/page-shell.tsx new file mode 100644 index 000000000..628c5165d --- /dev/null +++ b/app/(api-reference)/api-reference/components/page-shell.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useRef, useState, createContext } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Menu, ChevronDown, ChevronRight, X } from "lucide-react"; +import { ContentActionsAppRouter } from "./content-actions"; + +interface SidebarPage { + slug: string; + title: string; + pages?: SidebarPage[]; +} + +interface SidebarSection { + title: string; + slug: string; + pages: SidebarPage[]; + sidebarMenuDefaultOpen?: boolean; +} + +interface PageShellProps { + children: React.ReactNode; + sidebarContent: SidebarSection[]; + title: string; + description: string; +} + +// Simple sidebar context for same-page routing +const SidebarContext = createContext<{ samePageRouting: boolean }>({ + samePageRouting: true, +}); + +export function PageShell({ + children, + sidebarContent, + title, + description, +}: PageShellProps) { + const scrollerRef = useRef(null); + const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); + + return ( +
+ {/* Header */} +
+
+
+ + + Knock Docs + +
+
+
+ + {/* Mobile Sidebar Overlay */} + {isMobileSidebarOpen && ( +
setIsMobileSidebarOpen(false)} + > +
+
e.stopPropagation()} + > +
+ Navigation + +
+ +
+
+ )} + + {/* Main Layout */} +
+ {/* Desktop Sidebar */} + + + {/* Content Area */} +
+
+
+

{title}

+

{description}

+
+ +
+
+
+ {children} +
+
+
+
+
+ ); +} + +// Sidebar Section Component +function SidebarSectionComponent({ section }: { section: SidebarSection }) { + const [isOpen, setIsOpen] = useState(section.sidebarMenuDefaultOpen ?? false); + + return ( +
+ + {isOpen && ( +
+ {section.pages.map((page) => ( + + ))} +
+ )} +
+ ); +} + +// Sidebar Item Component +function SidebarItem({ + item, + basePath, +}: { + item: SidebarPage; + basePath: string; +}) { + const pathname = usePathname() ?? ""; + const [isOpen, setIsOpen] = useState(false); + const hasChildren = item.pages && item.pages.length > 0; + const fullPath = `${basePath}${item.slug}`; + const isActive = pathname.includes(fullPath.replace(/\/$/, "")); + + const handleClick = () => { + if (hasChildren) { + setIsOpen(!isOpen); + } else { + // Navigate to the section + const element = document.querySelector( + `[data-resource-path="${item.slug.replace(/^\//, "")}"]`, + ); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + window.history.pushState({}, "", fullPath); + } + } + }; + + return ( +
+ + {hasChildren && isOpen && ( +
+ {item.pages?.map((subItem) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/(api-reference)/api-reference/components/pre-content.tsx b/app/(api-reference)/api-reference/components/pre-content.tsx new file mode 100644 index 000000000..5498e3bfa --- /dev/null +++ b/app/(api-reference)/api-reference/components/pre-content.tsx @@ -0,0 +1,36 @@ +import type { SpecName } from "../../../../lib/openapi/types"; + +interface PreContentProps { + specName: SpecName; +} + +// Simplified pre-content - MDX content will be rendered elsewhere +export async function PreContent({ specName }: PreContentProps) { + return ( +
+

Overview

+
+
+

+ {specName === "api" + ? "The Knock API enables you to add a complete notification engine to your product. This API provides programmatic access to integrating Knock via a REST-ful API." + : "The Knock Management API enables you to manage your Knock resources programmatically. Use this API to automate configuration changes and integrate with your CI/CD pipelines."} +

+
+
+
+

Base URL

+ + {specName === "api" + ? "https://api.knock.app/v1" + : "https://control.knock.app/v1"} + +
+
+
+
+ ); +} diff --git a/app/(api-reference)/api-reference/components/rate-limit.tsx b/app/(api-reference)/api-reference/components/rate-limit.tsx new file mode 100644 index 000000000..130b17902 --- /dev/null +++ b/app/(api-reference)/api-reference/components/rate-limit.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type Props = { + tier: 1 | 2 | 3 | 4 | 5; + isBatch?: boolean; +}; + +const tierConfig = { + 5: { + color: "bg-purple-100 text-purple-800", + tooltip: "1,000 requests / second", + }, + 4: { + color: "bg-green-100 text-green-800", + tooltip: "200 requests / second", + }, + 3: { + color: "bg-blue-100 text-blue-800", + tooltip: "60 requests / second", + }, + 2: { + color: "bg-yellow-100 text-yellow-800", + tooltip: "5 requests / second", + }, + 1: { + color: "bg-red-100 text-red-800", + tooltip: "1 request / second", + }, +} as const; + +const batchConfig = { + 1: { + tooltip: "1 update / second / entity", + color: "bg-red-100 text-red-800", + }, +}; + +export function RateLimitAppRouter({ tier, isBatch = false }: Props) { + const pathname = usePathname() ?? ""; + const paths = pathname.split("/"); + const basePath = paths[1] ?? ""; + const inOverview = paths[2] === "overview"; + + const tooltip = isBatch + ? batchConfig[tier].tooltip + : tierConfig[tier].tooltip; + const colorClass = isBatch ? batchConfig[tier].color : tierConfig[tier].color; + const renderLink = !inOverview && basePath === "api-reference"; + + const content = ( + + {isBatch ? "Batch Tier " : "Tier "} + {tier} + + ); + + if (renderLink) { + return ( + + {content} + + ); + } + + return content; +} diff --git a/app/(api-reference)/api-reference/components/resource-section-client.tsx b/app/(api-reference)/api-reference/components/resource-section-client.tsx new file mode 100644 index 000000000..8ceefeeb3 --- /dev/null +++ b/app/(api-reference)/api-reference/components/resource-section-client.tsx @@ -0,0 +1,212 @@ +"use client"; + +import React from "react"; +import Markdown from "react-markdown"; +import { Section, ContentColumn, ExampleColumn } from "./api-sections"; +import { MethodContentClient } from "./method-content-client"; +import { SchemaSectionClient } from "./schema-section-client"; + +interface EndpointData { + methodName: string; + methodType: string; + endpoint: string; +} + +interface MethodData { + methodName: string; + methodType: string; + endpoint: string; + path: string; + mdPath: string; + operation: Record; +} + +interface SchemaData { + modelName: string; + path: string; + mdPath: string; + schema: Record; +} + +interface SubresourceData { + name: string; + description?: string; + path: string; + mdPath: string; + endpoints: EndpointData[]; + methods: MethodData[]; + schemas: SchemaData[]; +} + +interface ResourceSectionClientProps { + name: string; + description?: string; + path: string; + mdPath: string; + endpoints: EndpointData[]; + methods: MethodData[]; + schemas: SchemaData[]; + subresources: SubresourceData[]; + baseUrl: string; + schemaReferences: Record; +} + +export function ResourceSectionClient({ + name, + description, + path, + mdPath, + endpoints, + methods, + schemas, + subresources, + baseUrl, + schemaReferences, +}: ResourceSectionClientProps) { + return ( + <> +
+
+ + {description && {description}} + + + {endpoints.length > 0 && ( +
+
+ Endpoints +
+
+ {endpoints.map(({ methodName, methodType, endpoint }) => ( +
+ + {methodType.toUpperCase()} + + {endpoint} +
+ ))} +
+
+ )} +
+
+
+ + {methods.map((method) => ( +
+ +
+ ))} + + {subresources.map((subresource) => ( + +
+
+ + {subresource.description && ( + {subresource.description} + )} + + + {subresource.endpoints.length > 0 && ( +
+
+ Endpoints +
+
+ {subresource.endpoints.map( + ({ methodName, methodType, endpoint }) => ( +
+ + {methodType.toUpperCase()} + + + {endpoint} + +
+ ), + )} +
+
+ )} +
+
+
+ + {subresource.methods.map((method) => ( +
+ +
+ ))} + + {subresource.schemas.map((schema) => ( +
+ +
+ ))} +
+ ))} + + {schemas.map((schema) => ( +
+ +
+ ))} + + ); +} + +function getMethodColor(method: string): string { + switch (method.toLowerCase()) { + case "get": + return "bg-green-100 text-green-800"; + case "post": + return "bg-blue-100 text-blue-800"; + case "put": + return "bg-yellow-100 text-yellow-800"; + case "patch": + return "bg-orange-100 text-orange-800"; + case "delete": + return "bg-red-100 text-red-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} diff --git a/app/(api-reference)/api-reference/components/resource-section.tsx b/app/(api-reference)/api-reference/components/resource-section.tsx new file mode 100644 index 000000000..fbbe47d24 --- /dev/null +++ b/app/(api-reference)/api-reference/components/resource-section.tsx @@ -0,0 +1,179 @@ +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { + getOpenApiSpec, + getStainlessSpec, +} from "../../../../lib/openapi/loader"; +import { + resolveEndpoint, + getSchemaByRef, + getOperation, +} from "../../../../lib/openapi/helpers"; +import type { + SpecName, + StainlessResource, +} from "../../../../lib/openapi/types"; +import { ResourceSectionClient } from "./resource-section-client"; +import { MethodContentClient } from "./method-content-client"; +import { SchemaSectionClient } from "./schema-section-client"; + +interface ResourceSectionProps { + specName: SpecName; + resourceName: string; + basePath: string; + baseUrl: string; + schemaReferences: Record; + path?: string; +} + +export async function ResourceSection({ + specName, + resourceName, + basePath, + baseUrl, + schemaReferences, + path, +}: ResourceSectionProps) { + const [openApiSpec, stainlessSpec] = await Promise.all([ + getOpenApiSpec(specName), + getStainlessSpec(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; + } + + const methods = resource.methods || {}; + const models = resource.models || {}; + const resourcePath = path ?? `/${resourceName}`; + const apiSurface = basePath.replace("/", ""); + + // Prepare endpoints data for client + const endpoints = Object.entries(methods).map( + ([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpoint(endpointOrMethodConfig); + return { methodName, methodType, endpoint }; + }, + ); + + // Prepare method data for client + const methodsData = Object.entries(methods) + .map(([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpoint(endpointOrMethodConfig); + const operation = getOperation(openApiSpec, methodType, endpoint); + if (!operation) return null; + + const methodPath = `${resourcePath}/${methodName}`; + const methodMdPath = `/${apiSurface}${resourcePath}/${methodName}.md`; + + return { + methodName, + methodType, + endpoint, + path: methodPath, + mdPath: methodMdPath, + operation: JSON.parse(JSON.stringify(operation)), // Serialize for client + }; + }) + .filter(Boolean); + + // Prepare schema data for client + const schemasData = Object.entries(models) + .map(([modelName, modelReference]) => { + const schema = getSchemaByRef(openApiSpec, modelReference); + if (!schema) return null; + + const schemaPath = `${resourcePath}/schemas/${modelName}`; + const schemaMdPath = `/${apiSurface}${resourcePath}/schemas/${modelName}.md`; + + return { + modelName, + path: schemaPath, + mdPath: schemaMdPath, + schema: JSON.parse(JSON.stringify(schema)), // Serialize for client + }; + }) + .filter(Boolean); + + // Prepare subresources data + const subresourcesData = await Promise.all( + Object.entries(resource.subresources ?? {}).map( + async ([subresourceName, subresource]) => { + const subPath = `${resourcePath}/${subresourceName}`; + const subMethods = subresource.methods || {}; + const subModels = subresource.models || {}; + + const subEndpoints = Object.entries(subMethods).map( + ([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpoint( + endpointOrMethodConfig, + ); + return { methodName, methodType, endpoint }; + }, + ); + + const subMethodsData = Object.entries(subMethods) + .map(([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpoint( + endpointOrMethodConfig, + ); + const operation = getOperation(openApiSpec, methodType, endpoint); + if (!operation) return null; + + return { + methodName, + methodType, + endpoint, + path: `${subPath}/${methodName}`, + mdPath: `/${apiSurface}${subPath}/${methodName}.md`, + operation: JSON.parse(JSON.stringify(operation)), + }; + }) + .filter(Boolean); + + const subSchemasData = Object.entries(subModels) + .map(([modelName, modelReference]) => { + const schema = getSchemaByRef(openApiSpec, modelReference); + if (!schema) return null; + + return { + modelName, + path: `${subPath}/schemas/${modelName}`, + mdPath: `/${apiSurface}${subPath}/schemas/${modelName}.md`, + schema: JSON.parse(JSON.stringify(schema)), + }; + }) + .filter(Boolean); + + return { + name: subresource.name || subresourceName, + description: subresource.description, + path: subPath, + mdPath: `/${apiSurface}${subPath}/index.md`, + endpoints: subEndpoints, + methods: subMethodsData.filter( + (m): m is NonNullable => m !== null, + ), + schemas: subSchemasData.filter( + (s): s is NonNullable => s !== null, + ), + }; + }, + ), + ); + + return ( + + ); +} diff --git a/app/(api-reference)/api-reference/components/schema-properties-server.tsx b/app/(api-reference)/api-reference/components/schema-properties-server.tsx new file mode 100644 index 000000000..15f00d7ee --- /dev/null +++ b/app/(api-reference)/api-reference/components/schema-properties-server.tsx @@ -0,0 +1,208 @@ +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import Link from "next/link"; +import { Box, Stack } from "@telegraph/layout"; +import { Text, Code } from "@telegraph/typography"; + +interface SchemaPropertiesServerProps { + schema: OpenAPIV3.SchemaObject; + schemaReferences: Record; + hideRequired?: boolean; +} + +export function SchemaPropertiesServer({ + schema, + schemaReferences, + hideRequired = false, +}: SchemaPropertiesServerProps) { + const unionSchema = schema.oneOf || schema.anyOf || schema.allOf; + const onlyUnion = + unionSchema && !schema.properties && !schema.additionalProperties; + + return ( + + {Object.entries(schema.properties || {}).map( + ([propertyName, property]) => { + const propSchema = property as OpenAPIV3.SchemaObject; + const isRequired = + !hideRequired && schema.required?.includes(propertyName); + return ( + + ); + }, + )} + {schema.additionalProperties && ( + + )} + + {onlyUnion && ( + + )} + + ); +} + +interface SchemaPropertyServerProps { + name?: string; + schema: OpenAPIV3.SchemaObject; + schemaReferences: Record; + isRequired?: boolean; +} + +function SchemaPropertyServer({ + name, + schema, + schemaReferences, + isRequired = false, +}: SchemaPropertyServerProps) { + const typeString = getTypeString(schema); + const typeRef = schemaReferences[typeString]; + const isNullable = schema.nullable; + + // Handle union types (oneOf, anyOf, allOf) + const unionSchema = schema.oneOf || schema.anyOf || schema.allOf; + + return ( + + + {name && ( + + {name} + + )} + {typeRef ? ( + + {typeString} + + ) : ( + + {typeString} + + )} + {isRequired && ( + + required + + )} + {isNullable && ( + + nullable + + )} + + {schema.description && ( + + {schema.description} + + )} + {schema.enum && ( + + One of: {schema.enum.map((e) => `"${e}"`).join(", ")} + + )} + {schema.default !== undefined && ( + + Default: {JSON.stringify(schema.default)} + + )} + + {/* Handle nested properties */} + {schema.properties && ( + + + + )} + + {/* Handle array items with properties */} + {schema.type === "array" && + schema.items && + (schema.items as OpenAPIV3.SchemaObject).properties && ( + + + + )} + + {/* Handle union types */} + {unionSchema && ( + + + {schema.oneOf ? "One of:" : schema.anyOf ? "Any of:" : "All of:"} + + + {unionSchema.map((unionItem, index) => { + const item = unionItem as OpenAPIV3.SchemaObject; + const itemTypeString = getTypeString(item); + const itemTypeRef = schemaReferences[itemTypeString]; + + return ( + + {itemTypeRef ? ( + + {itemTypeString} + + ) : ( + + {itemTypeString} + + )} + {item.description && ( + + {item.description} + + )} + + ); + })} + + + )} + + ); +} + +function getTypeString(schema: OpenAPIV3.SchemaObject): string { + if (schema.title) return schema.title; + if (schema.type === "array" && schema.items) { + const items = schema.items as OpenAPIV3.SchemaObject; + return `${items.title || items.type || "unknown"}[]`; + } + if (schema.oneOf) return "oneOf"; + if (schema.anyOf) return "anyOf"; + if (schema.allOf) return "allOf"; + return schema.type || "unknown"; +} diff --git a/app/(api-reference)/api-reference/components/schema-section-client.tsx b/app/(api-reference)/api-reference/components/schema-section-client.tsx new file mode 100644 index 000000000..be7db709f --- /dev/null +++ b/app/(api-reference)/api-reference/components/schema-section-client.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Markdown from "react-markdown"; +import Link from "next/link"; +import { Section, ContentColumn, ExampleColumn } from "./api-sections"; +import { CodeBlock } from "../../../../components/ui/CodeBlock"; + +interface SchemaSectionClientProps { + modelName: string; + path: string; + mdPath: string; + schema: Record; + schemaReferences: Record; +} + +export function SchemaSectionClient({ + modelName, + path, + mdPath, + schema, + schemaReferences, +}: SchemaSectionClientProps) { + const title = (schema.title as string) || modelName; + const description = schema.description as string | undefined; + const properties = schema.properties as Record | undefined; + const required = schema.required as string[] | undefined; + const example = schema.example; + + return ( +
+ + {description && {description}} + +

+ Attributes +

+ + {properties && ( +
+ {Object.entries(properties).map(([propName, propSchema]) => { + const typeString = getTypeString(propSchema); + const typeRef = schemaReferences[typeString]; + const isRequired = required?.includes(propName); + + return ( +
+
+ {propName} + {typeRef ? ( + + {typeString} + + ) : ( + + {typeString} + + )} + {isRequired && ( + required + )} + {propSchema.nullable && ( + nullable + )} +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + {propSchema.enum && ( +

+ One of:{" "} + {propSchema.enum.map((e: string) => `"${e}"`).join(", ")} +

+ )} + {propSchema.default !== undefined && ( +

+ Default: {JSON.stringify(propSchema.default)} +

+ )} +
+ ); + })} +
+ )} +
+ + {example !== undefined && example !== null ? ( + + {JSON.stringify(example, null, 2)} + + ) : ( +
No example available
+ )} +
+
+ ); +} + +function getTypeString(schema: any): string { + if (!schema) return "unknown"; + if (schema.title) return schema.title; + if (schema.type === "array" && schema.items) { + return `${schema.items.title || schema.items.type || "unknown"}[]`; + } + return schema.type || "unknown"; +} diff --git a/app/(api-reference)/api-reference/components/section-heading.tsx b/app/(api-reference)/api-reference/components/section-heading.tsx new file mode 100644 index 000000000..e72a2d4b8 --- /dev/null +++ b/app/(api-reference)/api-reference/components/section-heading.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useRef } from "react"; +import { Heading } from "@telegraph/typography"; +import { Box, Stack } from "@telegraph/layout"; +import { Icon } from "@telegraph/icon"; +import { Hash } from "lucide-react"; + +interface SectionHeadingProps { + tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + path?: string; + children: React.ReactNode; +} + +export function SectionHeadingAppRouter({ + tag = "h2", + path, + children, +}: SectionHeadingProps) { + const headingRef = useRef(null); + + const handleClick = () => { + if (path) { + const url = new URL(window.location.href); + url.pathname = path; + window.history.pushState({}, "", url.toString()); + + // Scroll into view + if (headingRef.current) { + headingRef.current.scrollIntoView({ behavior: "smooth" }); + } + } + }; + + const sizeMap = { + h1: "7", + h2: "6", + h3: "5", + h4: "4", + h5: "3", + h6: "2", + } as const; + + return ( + + + {children} + + {path && ( + + + + )} + + ); +} diff --git a/app/(api-reference)/layout.tsx b/app/(api-reference)/layout.tsx new file mode 100644 index 000000000..331a00ddc --- /dev/null +++ b/app/(api-reference)/layout.tsx @@ -0,0 +1,49 @@ +import { Inter } from "next/font/google"; +import { Providers } from "./providers"; +import { GA_TRACKING_ID } from "../../lib/gtag"; + +import "@algolia/autocomplete-theme-classic"; +import "../../styles/index.css"; +import "../../styles/global.css"; +import "../../styles/responsive.css"; + +const inter = Inter({ subsets: ["latin"], display: "swap" }); + +export default function ApiReferenceLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +