Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-openapi-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"leadtype": patch
---

Use named OpenAPI request body examples in generated code samples and avoid repeating them as standalone request examples before the snippets.
76 changes: 64 additions & 12 deletions apps/fumadocs-example/lib/mdx-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,32 +299,79 @@ function JsonExample({ value }: { value: unknown }) {
);
}

function MediaTypeExamples({ media }: { media: ApiMediaType }) {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isExternalOnlyExampleObject(value: unknown): boolean {
return (
isRecord(value) &&
Object.hasOwn(value, "externalValue") &&
!Object.hasOwn(value, "value")
);
}

function preferredNamedExampleName(media: ApiMediaType): string | undefined {
const entries = Object.entries(media.examples ?? {});
if (entries.length === 0) {
return;
}
if (
Object.hasOwn(media.examples ?? {}, "default") &&
!isExternalOnlyExampleObject(media.examples?.default)
) {
return "default";
}
return entries.find(([, value]) => !isExternalOnlyExampleObject(value))?.[0];
}

function MediaTypeExamples({
media,
omittedExampleName,
}: {
media: ApiMediaType;
omittedExampleName?: string;
}) {
const namedExamples = Object.entries(media.examples ?? {});
if (namedExamples.length > 0) {
return namedExamples.map(([name, value]) => (
<div key={name}>
<p className="text-sm opacity-80">
Example: <code>{name}</code>
</p>
<JsonExample value={value} />
</div>
));
return namedExamples
.filter(([name]) => name !== omittedExampleName)
.map(([name, value]) => (
<div key={name}>
<p className="text-sm opacity-80">
Example: <code>{name}</code>
</p>
<JsonExample value={value} />
</div>
));
}
if (media.example === undefined) {
return null;
}
return <JsonExample value={media.example} />;
}

function MediaType({ media }: { media: ApiMediaType }) {
function MediaType({
includeSingleExample = true,
media,
omittedExampleName,
}: {
includeSingleExample?: boolean;
media: ApiMediaType;
omittedExampleName?: string;
}) {
return (
<div className="my-3">
<p className="text-sm">
Content type <code>{media.mediaType}</code>
</p>
<SchemaRows schema={media.schema} />
<MediaTypeExamples media={media} />
{media.examples || includeSingleExample ? (
<MediaTypeExamples
media={media}
omittedExampleName={omittedExampleName}
/>
) : null}
{media.rawSchema === undefined ? null : (
<details className="my-2 rounded-lg border p-3 text-sm">
<summary className="cursor-pointer font-medium">JSON Schema</summary>
Expand All @@ -344,7 +391,12 @@ function ApiRequestBody({ body }: ApiRequestBodyProps) {
{body.description ? ` - ${body.description}` : ""}
</p>
{body.content.map((media) => (
<MediaType key={media.mediaType} media={media} />
<MediaType
includeSingleExample={false}
key={media.mediaType}
media={media}
omittedExampleName={preferredNamedExampleName(media)}
/>
))}
</div>
);
Expand Down
76 changes: 64 additions & 12 deletions apps/tanstack/src/components/docs-mdx/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,32 +81,79 @@ function JsonExample({ value }: { value: unknown }) {
);
}

function MediaTypeExamples({ media }: { media: ApiMediaType }) {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isExternalOnlyExampleObject(value: unknown): boolean {
return (
isRecord(value) &&
Object.hasOwn(value, "externalValue") &&
!Object.hasOwn(value, "value")
);
}

function preferredNamedExampleName(media: ApiMediaType): string | undefined {
const entries = Object.entries(media.examples ?? {});
if (entries.length === 0) {
return;
}
if (
Object.hasOwn(media.examples ?? {}, "default") &&
!isExternalOnlyExampleObject(media.examples?.default)
) {
return "default";
}
return entries.find(([, value]) => !isExternalOnlyExampleObject(value))?.[0];
}

function MediaTypeExamples({
media,
omittedExampleName,
}: {
media: ApiMediaType;
omittedExampleName?: string;
}) {
const namedExamples = Object.entries(media.examples ?? {});
if (namedExamples.length > 0) {
return namedExamples.map(([name, value]) => (
<div key={name}>
<p data-leadtype-api-meta="">
Example: <code>{name}</code>
</p>
<JsonExample value={value} />
</div>
));
return namedExamples
.filter(([name]) => name !== omittedExampleName)
.map(([name, value]) => (
<div key={name}>
<p data-leadtype-api-meta="">
Example: <code>{name}</code>
</p>
<JsonExample value={value} />
</div>
));
}
if (media.example === undefined) {
return null;
}
return <JsonExample value={media.example} />;
}

function MediaType({ media }: { media: ApiMediaType }) {
function MediaType({
includeSingleExample = true,
media,
omittedExampleName,
}: {
includeSingleExample?: boolean;
media: ApiMediaType;
omittedExampleName?: string;
}) {
return (
<div data-leadtype-api-media="">
<p data-leadtype-api-meta="">
Content type <code>{media.mediaType}</code>
</p>
<SchemaTable schema={media.schema} />
<MediaTypeExamples media={media} />
{media.examples || includeSingleExample ? (
<MediaTypeExamples
media={media}
omittedExampleName={omittedExampleName}
/>
) : null}
{media.rawSchema === undefined ? null : (
<details data-leadtype-api-schema="">
<summary>JSON Schema</summary>
Expand Down Expand Up @@ -239,7 +286,12 @@ export function ApiRequestBody({ body }: ApiRequestBodyProps) {
{body.description ? ` — ${body.description}` : ""}
</p>
{body.content.map((media) => (
<MediaType key={media.mediaType} media={media} />
<MediaType
includeSingleExample={false}
key={media.mediaType}
media={media}
omittedExampleName={preferredNamedExampleName(media)}
/>
))}
</section>
);
Expand Down
4 changes: 2 additions & 2 deletions docs/paths.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
},
{
"path": "/docs/reference/openapi",
"hash": "9b7f5adbe50ebf5d"
"hash": "a90449172a503ec5"
},
{
"path": "/docs/reference/search",
Expand Down Expand Up @@ -187,7 +187,7 @@
},
{
"path": "/docs/rest-api/search/search-docs",
"hash": "0e8e40298bddb63e"
"hash": "e718978ae1e75a8b"
},
{
"path": "/docs/search/add-search",
Expand Down
12 changes: 7 additions & 5 deletions docs/reference/openapi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,13 @@ Every operation page carries the full request/response contract:
schemes those requirements reference.
- **Parameters** — path/query/header/cookie tables. Nested object and
array-item properties flatten into dotted rows such as `results[].title`.
- **Request body and responses** — property tables plus a JSON example. When
the spec provides `example`/`examples` they are used verbatim; otherwise a
representative payload is synthesized from the schema (defaults, enums, and
formats included). The dereferenced **JSON Schema** ships alongside as the
precise contract (disable with `includeSchemas: false`).
- **Request body and responses** — property tables plus response JSON examples.
Request body examples are folded into the generated cURL and `fetch`
snippets instead of repeated as a standalone block. When the spec provides
`example`/`examples` they are used verbatim; otherwise a representative
payload is synthesized from the schema (defaults, enums, and formats
included). The dereferenced **JSON Schema** ships alongside as the precise
contract (disable with `includeSchemas: false`).
- **Code samples** — generated cURL and `fetch` snippets with real example
bodies, required query parameters, and auth headers derived from the
security scheme (`Authorization: Bearer <token>`, API-key headers, and so
Expand Down
48 changes: 45 additions & 3 deletions packages/leadtype/src/markdown/plugins/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,38 @@ function renderJsonExample(value: unknown): Code {
return createCodeBlock(text, "json");
}

function renderMediaType(media: OpenApiMediaType): RootContent[] {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isExternalOnlyExampleObject(value: unknown): boolean {
return (
isRecord(value) &&
Object.hasOwn(value, "externalValue") &&
!Object.hasOwn(value, "value")
);
}

function preferredNamedExampleName(
media: OpenApiMediaType
): string | undefined {
const entries = Object.entries(media.examples ?? {});
if (entries.length === 0) {
return;
}
if (
Object.hasOwn(media.examples ?? {}, "default") &&
!isExternalOnlyExampleObject(media.examples?.default)
) {
return "default";
}
return entries.find(([, value]) => !isExternalOnlyExampleObject(value))?.[0];
}

function renderMediaType(
media: OpenApiMediaType,
options: { includeSingleExample?: boolean; omitExampleName?: string } = {}
): RootContent[] {
const nodes: RootContent[] = [
createParagraph(`Content type: ${media.mediaType}`),
];
Expand All @@ -96,10 +127,16 @@ function renderMediaType(media: OpenApiMediaType): RootContent[] {
}
if (media.examples) {
for (const [name, value] of Object.entries(media.examples)) {
if (name === options.omitExampleName) {
continue;
}
nodes.push(createParagraph(`Example: ${name}`));
nodes.push(renderJsonExample(value));
}
} else if (media.example !== undefined) {
} else if (
(options.includeSingleExample ?? true) &&
media.example !== undefined
) {
nodes.push(createParagraph("Example:"));
nodes.push(renderJsonExample(media.example));
}
Expand Down Expand Up @@ -242,7 +279,12 @@ export function apiRequestBodyToMarkdown(node: MdxNode): RootContent[] {
),
];
for (const media of body.content) {
nodes.push(...renderMediaType(media));
nodes.push(
...renderMediaType(media, {
includeSingleExample: false,
omitExampleName: preferredNamedExampleName(media),
})
);
}
return nodes;
}
Expand Down
30 changes: 28 additions & 2 deletions packages/leadtype/src/openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,13 +1165,39 @@ function sampleHeaders(operation: OpenApiOperation): Map<string, string> {
return headers;
}

function preferredMediaExample(media: OpenApiMediaType): unknown {
if (media.examples) {
if (
Object.hasOwn(media.examples, "default") &&
!isExternalOnlyExampleObject(media.examples.default)
) {
return media.examples.default;
}
for (const value of Object.values(media.examples)) {
if (!isExternalOnlyExampleObject(value)) {
return value;
}
}
}
return media.example;
}

function isExternalOnlyExampleObject(value: unknown): boolean {
return (
isRecord(value) &&
Object.hasOwn(value, "externalValue") &&
!Object.hasOwn(value, "value")
);
}

function sampleRequestBody(operation: OpenApiOperation): unknown {
const media = operation.requestBody?.content[0];
if (!media) {
return;
}
if (media.example !== undefined) {
return media.example;
const example = preferredMediaExample(media);
if (example !== undefined) {
return example;
}
if (media.schema) {
return buildSchemaExample(media.schema);
Expand Down
Loading
Loading