Skip to content

Commit 2fa02f9

Browse files
authored
feat: openapi spec driven management api reference (#790)
* feat: openapi spec driven api ref * chore: nested property handling and more * chore: style tweaks * fix: empty subpages * fix: tsc * chore: more progress * chore: add multi lang examples * chore: add curl requests * chore: more tweaks * chore: fix package * chore: support markdown in all descriptions * chore: correctly handle unions * chore: hide required on response * chore: better endpoint handling * chore: style tweaks * chore: rename mapi ref * chore: clean up and only mapi for now * chore: update environment wording * chore: minor updates * chore: minor fixes and linking * feat: scrolling nav * fix: usual sidebar interaction * chore: update copy * chore: fixin * chore: latest spec
1 parent 5fffdf1 commit 2fa02f9

39 files changed

+2072
-251
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { ApiReferenceProvider } from "../../components/ApiReference/ApiReferenceContext";
5+
import { ApiReferenceSection } from "../../components/ApiReference";
6+
import MinimalHeader from "../../components/Header/MinimalHeader";
7+
import Sidebar from "../../components/Sidebar";
8+
import { Page } from "../../layouts/Page";
9+
import { useEffect, useLayoutEffect } from "react";
10+
import { useRouter } from "next/router";
11+
import { StainlessConfig } from "../../lib/openApiSpec";
12+
import { OpenAPIV3 } from "@scalar/openapi-types";
13+
import { getSidebarContent } from "./helpers";
14+
import { SidebarSection } from "../../data/types";
15+
16+
type Props = {
17+
name: string;
18+
openApiSpec: OpenAPIV3.Document;
19+
stainlessSpec: StainlessConfig;
20+
preContent?: React.ReactNode;
21+
preSidebarContent?: SidebarSection[];
22+
resourceOrder: string[];
23+
};
24+
25+
function onElementClick(e: Event) {
26+
e.preventDefault();
27+
e.stopPropagation();
28+
}
29+
30+
function ApiReference({
31+
name,
32+
openApiSpec,
33+
stainlessSpec,
34+
preContent,
35+
preSidebarContent,
36+
resourceOrder = [],
37+
}: Props) {
38+
const router = useRouter();
39+
const basePath = router.pathname.split("/")[1];
40+
41+
useEffect(() => {
42+
const path = router.asPath;
43+
44+
const resourcePath = path.replace(`/${basePath}`, "");
45+
const element = document.querySelector(
46+
`[data-resource-path="${resourcePath}"]`,
47+
);
48+
49+
element?.scrollIntoView();
50+
}, [router.asPath, basePath]);
51+
52+
useLayoutEffect(() => {
53+
document
54+
.querySelector(".sidebar a")
55+
?.addEventListener("click", onElementClick);
56+
57+
const observer = new IntersectionObserver(
58+
(entries) => {
59+
entries.forEach((entry) => {
60+
if (entry.isIntersecting) {
61+
const resourcePath =
62+
entry.target.getAttribute("data-resource-path");
63+
64+
const lastActiveElements =
65+
document.querySelectorAll(".sidebar .active");
66+
67+
const newActiveElement = document.querySelector(
68+
`.sidebar a[href*='/${basePath}${resourcePath}']`,
69+
)?.parentElement;
70+
71+
if (lastActiveElements.length > 0) {
72+
lastActiveElements.forEach((element) => {
73+
element.parentElement?.parentElement?.classList.remove(
74+
"sidebar-subsection--active",
75+
);
76+
77+
element.classList.remove("active");
78+
});
79+
}
80+
81+
if (newActiveElement) {
82+
newActiveElement.parentElement?.parentElement?.classList.add(
83+
"sidebar-subsection--active",
84+
);
85+
86+
newActiveElement.classList.add("active");
87+
newActiveElement.scrollIntoView({
88+
behavior: "smooth",
89+
block: "center",
90+
});
91+
}
92+
93+
if (resourcePath) {
94+
window.history.replaceState(
95+
null,
96+
"",
97+
`/${basePath}${resourcePath}`,
98+
);
99+
}
100+
}
101+
});
102+
},
103+
{
104+
threshold: 0.1,
105+
rootMargin: "-64px 0px -80% 0px",
106+
},
107+
);
108+
109+
// Observe all elements with data-resource-path
110+
document.querySelectorAll("[data-resource-path]").forEach((element) => {
111+
observer.observe(element);
112+
});
113+
114+
// Cleanup observer on unmount
115+
return () => observer.disconnect();
116+
}, [basePath]);
117+
118+
return (
119+
<ApiReferenceProvider
120+
openApiSpec={openApiSpec}
121+
stainlessConfig={stainlessSpec}
122+
>
123+
<div className="wrapper">
124+
<Page
125+
header={<MinimalHeader pageType="API" />}
126+
sidebar={
127+
<Sidebar
128+
content={getSidebarContent(
129+
openApiSpec,
130+
stainlessSpec,
131+
resourceOrder,
132+
basePath,
133+
preSidebarContent,
134+
)}
135+
>
136+
<Link
137+
href="/"
138+
passHref
139+
className="text-sm block font-medium text-gray-500 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
140+
>
141+
&#8592; Back to docs
142+
</Link>
143+
</Sidebar>
144+
}
145+
metaProps={{
146+
title: `Knock ${name} Reference | Knock`,
147+
description: `Complete reference documentation for the Knock ${name}.`,
148+
}}
149+
>
150+
<div className="w-full max-w-5xl lg:flex mx-auto relative">
151+
<div className="w-full flex-auto">
152+
<div className="docs-content api-docs-content">
153+
{preContent}
154+
{resourceOrder.map((resourceName) => (
155+
<ApiReferenceSection
156+
key={resourceName}
157+
resourceName={resourceName}
158+
resource={stainlessSpec.resources[resourceName]}
159+
/>
160+
))}
161+
</div>
162+
</div>
163+
</div>
164+
</Page>
165+
</div>
166+
</ApiReferenceProvider>
167+
);
168+
}
169+
170+
export default ApiReference;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createContext, useContext, ReactNode } from "react";
2+
import { OpenAPIV3 } from "@scalar/openapi-types";
3+
import { StainlessConfig } from "../../lib/openApiSpec";
4+
import { buildSchemaReferences } from "./helpers";
5+
import { useRouter } from "next/router";
6+
7+
interface ApiReferenceContextType {
8+
openApiSpec: OpenAPIV3.Document;
9+
stainlessConfig: StainlessConfig;
10+
baseUrl: string;
11+
schemaReferences: Record<string, string>;
12+
}
13+
14+
const ApiReferenceContext = createContext<ApiReferenceContextType | undefined>(
15+
undefined,
16+
);
17+
18+
interface ApiReferenceProviderProps {
19+
children: ReactNode;
20+
openApiSpec: OpenAPIV3.Document;
21+
stainlessConfig: StainlessConfig;
22+
}
23+
24+
export function ApiReferenceProvider({
25+
children,
26+
openApiSpec,
27+
stainlessConfig,
28+
}: ApiReferenceProviderProps) {
29+
const router = useRouter();
30+
const basePath = router.pathname.split("/")[1];
31+
32+
const baseUrl = stainlessConfig.environments.production;
33+
const schemaReferences = buildSchemaReferences(
34+
openApiSpec,
35+
stainlessConfig,
36+
Object.keys(stainlessConfig.resources),
37+
`/${basePath}`,
38+
);
39+
40+
return (
41+
<ApiReferenceContext.Provider
42+
value={{ openApiSpec, stainlessConfig, baseUrl, schemaReferences }}
43+
>
44+
{children}
45+
</ApiReferenceContext.Provider>
46+
);
47+
}
48+
49+
export function useApiReference() {
50+
const context = useContext(ApiReferenceContext);
51+
if (context === undefined) {
52+
throw new Error(
53+
"useApiReference must be used within an ApiReferenceProvider",
54+
);
55+
}
56+
return context;
57+
}
58+
59+
export default ApiReferenceContext;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { OpenAPIV3 } from "@scalar/openapi-types";
2+
import { useState } from "react";
3+
import Markdown from "react-markdown";
4+
5+
import { Endpoint } from "../../Endpoints";
6+
import { ContentColumn, ExampleColumn, Section } from "../../ApiSections";
7+
import { CodeBlock } from "../../CodeBlock";
8+
import { useApiReference } from "../ApiReferenceContext";
9+
import { SchemaProperties } from "../SchemaProperties";
10+
import OperationParameters from "../OperationParameters/OperationParameters";
11+
import { PropertyRow } from "../SchemaProperties/PropertyRow";
12+
import MultiLangExample from "../MultiLangExample";
13+
import { augmentSnippetsWithCurlRequest } from "../helpers";
14+
import Link from "next/link";
15+
16+
type Props = {
17+
methodName: string;
18+
methodType: "get" | "post" | "put" | "delete";
19+
endpoint: string;
20+
};
21+
22+
function ApiReferenceMethod({ methodName, methodType, endpoint }: Props) {
23+
const { openApiSpec, baseUrl, schemaReferences } = useApiReference();
24+
const [isResponseExpanded, setIsResponseExpanded] = useState(false);
25+
const method = openApiSpec.paths?.[endpoint]?.[methodType];
26+
27+
if (!method) {
28+
return null;
29+
}
30+
31+
const parameters = method.parameters || [];
32+
const responses = method.responses || {};
33+
const response = responses[Object.keys(responses)[0]];
34+
35+
const pathParameters = parameters.filter(
36+
(p) => p.in === "path",
37+
) as OpenAPIV3.ParameterObject[];
38+
const queryParameters = parameters.filter(
39+
(p) => p.in === "query",
40+
) as OpenAPIV3.ParameterObject[];
41+
42+
const responseSchema: OpenAPIV3.SchemaObject | undefined =
43+
response?.content?.["application/json"]?.schema;
44+
const requestBody: OpenAPIV3.SchemaObject | undefined =
45+
method.requestBody?.content?.["application/json"]?.schema;
46+
47+
return (
48+
<Section title={method.summary} slug={method.summary}>
49+
<ContentColumn>
50+
<Markdown>{method.description ?? ""}</Markdown>
51+
52+
<h3 className="!text-sm font-medium">Endpoint</h3>
53+
54+
<Endpoint
55+
method={methodType.toUpperCase()}
56+
path={`${endpoint}`}
57+
name={methodName}
58+
/>
59+
60+
{pathParameters.length > 0 && (
61+
<>
62+
<h3 className="!text-base font-medium">Path parameters</h3>
63+
<OperationParameters parameters={pathParameters} />
64+
</>
65+
)}
66+
67+
{queryParameters.length > 0 && (
68+
<>
69+
<h3 className="!text-base font-medium">Query parameters</h3>
70+
<OperationParameters parameters={queryParameters} />
71+
</>
72+
)}
73+
74+
{requestBody && (
75+
<>
76+
<h3 className="!text-base font-medium">Request body</h3>
77+
<SchemaProperties schema={requestBody} />
78+
</>
79+
)}
80+
81+
<h3 className="!text-base font-medium">Returns</h3>
82+
83+
{responseSchema && (
84+
<PropertyRow.Wrapper>
85+
<PropertyRow.Container>
86+
<PropertyRow.Header>
87+
<PropertyRow.Type
88+
href={schemaReferences[responseSchema.title ?? ""]}
89+
>
90+
{responseSchema.title}
91+
</PropertyRow.Type>
92+
</PropertyRow.Header>
93+
<PropertyRow.Description>
94+
<Markdown>{responseSchema.description ?? ""}</Markdown>
95+
</PropertyRow.Description>
96+
97+
{responseSchema.properties && (
98+
<>
99+
<PropertyRow.ExpandableButton
100+
isOpen={isResponseExpanded}
101+
onClick={() => setIsResponseExpanded(!isResponseExpanded)}
102+
>
103+
{isResponseExpanded ? "Hide properties" : "Show properties"}
104+
</PropertyRow.ExpandableButton>
105+
106+
{isResponseExpanded && (
107+
<PropertyRow.ChildProperties>
108+
<SchemaProperties schema={responseSchema} hideRequired />
109+
</PropertyRow.ChildProperties>
110+
)}
111+
</>
112+
)}
113+
</PropertyRow.Container>
114+
</PropertyRow.Wrapper>
115+
)}
116+
</ContentColumn>
117+
<ExampleColumn>
118+
<MultiLangExample
119+
title={`${method.summary} (example)`}
120+
examples={augmentSnippetsWithCurlRequest(
121+
method["x-stainless-snippets"],
122+
{
123+
baseUrl,
124+
methodType,
125+
endpoint,
126+
body: requestBody?.example,
127+
},
128+
)}
129+
/>
130+
{responseSchema?.example && (
131+
<CodeBlock title="Response" language="json" languages={["json"]}>
132+
{JSON.stringify(responseSchema?.example, null, 2)}
133+
</CodeBlock>
134+
)}
135+
</ExampleColumn>
136+
</Section>
137+
);
138+
}
139+
140+
export default ApiReferenceMethod;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from "./ApiReferenceMethod";

0 commit comments

Comments
 (0)