feat(api-ref): show schema of a tag (#7297)
This commit is contained in:
@@ -77,8 +77,8 @@
|
||||
"test:integration:packages": "turbo run test:integration --concurrency=50% --no-daemon --no-cache --force --filter='./packages/*' --filter='./packages/core/*' --filter='./packages/cli/*' --filter='./packages/modules/*' --filter='./packages/modules/providers/*'",
|
||||
"test:integration:api": "turbo run test:integration:chunk --concurrency=50% --no-daemon --no-cache --force --filter=integration-tests-api",
|
||||
"test:integration:modules": "turbo run test:integration:chunk --concurrency=50% --no-daemon --no-cache --force --filter=integration-tests-modules",
|
||||
"openapi:generate": "yarn ./packages/oas/oas-github-ci run ci --with-full-file --v2",
|
||||
"medusa-oas": "yarn ./packages/oas/medusa-oas-cli run medusa-oas --v2",
|
||||
"openapi:generate": "yarn ./packages/cli/oas/oas-github-ci run ci --with-full-file --v2",
|
||||
"medusa-oas": "yarn ./packages/cli/oas/medusa-oas-cli run medusa-oas --v2",
|
||||
"release:snapshot": "changeset publish --no-git-tags --snapshot --tag snapshot",
|
||||
"release:next": "chgstangeset publish --no-git-tags --snapshot --tag next",
|
||||
"version:next": "changeset version --snapshot next",
|
||||
|
||||
73
www/apps/api-reference/app/api/schema/route.ts
Normal file
73
www/apps/api-reference/app/api/schema/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { SchemaObject, Version } from "../../../types/openapi"
|
||||
import path from "path"
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import { parseDocument } from "yaml"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
let name = searchParams.get("name")
|
||||
const area = searchParams.get("area")
|
||||
const version = (searchParams.get("version") as Version) || "1"
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Name is required.`,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (area !== "admin" && area !== "store") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `area ${area} is not allowed`,
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
name = name
|
||||
.replace("#/components/schemas/", "")
|
||||
.replaceAll("./components/schemas/", "")
|
||||
|
||||
const schemaPath = path.join(
|
||||
process.cwd(),
|
||||
version === "1" ? "specs" : "specs-v2",
|
||||
area,
|
||||
"components",
|
||||
"schemas",
|
||||
name
|
||||
)
|
||||
|
||||
if (!existsSync(schemaPath)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Schema ${name} doesn't exist.`,
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const schemaContent = await fs.readFile(schemaPath, "utf-8")
|
||||
const schema = parseDocument(schemaContent).toJS() as SchemaObject
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
schema,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,18 @@ import { forwardRef } from "react"
|
||||
type SectionContainerProps = {
|
||||
children: React.ReactNode
|
||||
noTopPadding?: boolean
|
||||
noDivider?: boolean
|
||||
}
|
||||
|
||||
const SectionContainer = forwardRef<HTMLDivElement, SectionContainerProps>(
|
||||
function SectionContainer({ children, noTopPadding = false }, ref) {
|
||||
function SectionContainer(
|
||||
{ children, noTopPadding = false, noDivider = false },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className={clsx("relative pb-7", !noTopPadding && "pt-7")} ref={ref}>
|
||||
{children}
|
||||
<SectionDivider className="-left-1.5 lg:!-left-4" />
|
||||
{!noDivider && <SectionDivider className="-left-1.5 lg:!-left-4" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { CodeBlock } from "docs-ui"
|
||||
import type { ExampleObject, ResponseObject } from "@/types/openapi"
|
||||
import type { JSONSchema7 } from "json-schema"
|
||||
import stringify from "json-stringify-pretty-compact"
|
||||
import { sample } from "openapi-sampler"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import useSchemaExample from "../../../../../../hooks/use-schema-example"
|
||||
|
||||
export type TagsOperationCodeSectionResponsesSampleProps = {
|
||||
response: ResponseObject
|
||||
@@ -13,69 +11,21 @@ const TagsOperationCodeSectionResponsesSample = ({
|
||||
response,
|
||||
className,
|
||||
}: TagsOperationCodeSectionResponsesSampleProps) => {
|
||||
const [examples, setExamples] = useState<ExampleObject[]>([])
|
||||
const contentSchema = response.content
|
||||
? Object.values(response.content)[0]
|
||||
: undefined
|
||||
const { examples } = useSchemaExample({
|
||||
schema: contentSchema?.schema,
|
||||
schemaExample: contentSchema?.example,
|
||||
schemaExamples: contentSchema?.examples,
|
||||
})
|
||||
const [selectedExample, setSelectedExample] = useState<
|
||||
ExampleObject | undefined
|
||||
>()
|
||||
|
||||
const initExamples = useCallback(() => {
|
||||
if (!response.content) {
|
||||
return []
|
||||
}
|
||||
const contentSchema = Object.values(response.content)[0]
|
||||
const tempExamples = []
|
||||
if (contentSchema.examples) {
|
||||
Object.entries(contentSchema.examples).forEach(([value, example]) => {
|
||||
if ("$ref" in example) {
|
||||
return []
|
||||
}
|
||||
|
||||
tempExamples.push({
|
||||
title: example.summary || "",
|
||||
value,
|
||||
content: stringify(example.value, {
|
||||
maxLength: 50,
|
||||
}),
|
||||
})
|
||||
})
|
||||
} else if (contentSchema.example) {
|
||||
tempExamples.push({
|
||||
title: "",
|
||||
value: "",
|
||||
content: stringify(contentSchema.example, {
|
||||
maxLength: 50,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
const contentSample = stringify(
|
||||
sample(
|
||||
{
|
||||
...contentSchema.schema,
|
||||
} as JSONSchema7,
|
||||
{
|
||||
skipNonRequired: true,
|
||||
}
|
||||
),
|
||||
{
|
||||
maxLength: 50,
|
||||
}
|
||||
)
|
||||
|
||||
tempExamples.push({
|
||||
title: "",
|
||||
value: "",
|
||||
content: contentSample,
|
||||
})
|
||||
}
|
||||
|
||||
return tempExamples
|
||||
}, [response.content])
|
||||
|
||||
useEffect(() => {
|
||||
const tempExamples = initExamples()
|
||||
setExamples(tempExamples)
|
||||
setSelectedExample(tempExamples[0])
|
||||
}, [initExamples])
|
||||
setSelectedExample(examples[0])
|
||||
}, [examples])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -64,10 +64,9 @@ const TagPaths = ({ tag, className }: TagPathsProps) => {
|
||||
{ path: tagSlugName },
|
||||
false
|
||||
)
|
||||
if (!parentItem?.children?.length) {
|
||||
const items: SidebarItemType[] = getTagChildSidebarItems(paths)
|
||||
|
||||
addItems(items, {
|
||||
const pathItems: SidebarItemType[] = getTagChildSidebarItems(paths)
|
||||
if ((parentItem?.children?.length || 0) < pathItems.length) {
|
||||
addItems(pathItems, {
|
||||
section: SidebarItemSections.BOTTOM,
|
||||
parent: {
|
||||
path: tagSlugName,
|
||||
|
||||
135
www/apps/api-reference/components/Tags/Section/Schema/index.tsx
Normal file
135
www/apps/api-reference/components/Tags/Section/Schema/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { SchemaObject } from "../../../../types/openapi"
|
||||
import TagOperationParameters from "../../Operation/Parameters"
|
||||
import {
|
||||
Badge,
|
||||
CodeBlock,
|
||||
isElmWindow,
|
||||
useScrollController,
|
||||
useSidebar,
|
||||
} from "docs-ui"
|
||||
import { SidebarItemSections } from "types"
|
||||
import getSectionId from "../../../../utils/get-section-id"
|
||||
import DividedLayout from "../../../../layouts/Divided"
|
||||
import SectionContainer from "../../../Section/Container"
|
||||
import useSchemaExample from "../../../../hooks/use-schema-example"
|
||||
import { InView } from "react-intersection-observer"
|
||||
import checkElementInViewport from "../../../../utils/check-element-in-viewport"
|
||||
|
||||
export type TagSectionSchemaProps = {
|
||||
schema: SchemaObject
|
||||
tagName: string
|
||||
}
|
||||
|
||||
const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => {
|
||||
const { addItems, setActivePath, activePath } = useSidebar()
|
||||
const tagSlugName = useMemo(() => getSectionId([tagName]), [tagName])
|
||||
const formattedName = useMemo(() => {
|
||||
if (!schema["x-schemaName"]) {
|
||||
return tagName.replaceAll(" ", "")
|
||||
}
|
||||
|
||||
return schema["x-schemaName"]
|
||||
.replaceAll(/^(Admin|Store)/g, "")
|
||||
.replaceAll(/^Create/g, "")
|
||||
}, [schema, tagName])
|
||||
const schemaSlug = useMemo(
|
||||
() => getSectionId([tagName, formattedName, "schema"]),
|
||||
[tagName, formattedName]
|
||||
)
|
||||
const { examples } = useSchemaExample({
|
||||
schema,
|
||||
options: {
|
||||
skipNonRequired: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { scrollableElement } = useScrollController()
|
||||
const root = useMemo(() => {
|
||||
return isElmWindow(scrollableElement) ? document.body : scrollableElement
|
||||
}, [scrollableElement])
|
||||
|
||||
useEffect(() => {
|
||||
addItems(
|
||||
[
|
||||
{
|
||||
path: schemaSlug,
|
||||
title: `${formattedName} Object`,
|
||||
additionalElms: <Badge variant="neutral">Schema</Badge>,
|
||||
loaded: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
section: SidebarItemSections.BOTTOM,
|
||||
parent: {
|
||||
path: tagSlugName,
|
||||
changeLoaded: true,
|
||||
},
|
||||
indexPosition: 0,
|
||||
}
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formattedName])
|
||||
|
||||
useEffect(() => {
|
||||
if (schemaSlug === (activePath || location.hash.replace("#", ""))) {
|
||||
const elm = document.getElementById(schemaSlug)
|
||||
elm?.scrollIntoView({
|
||||
block: "center",
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleViewChange = (
|
||||
inView: boolean,
|
||||
entry: IntersectionObserverEntry
|
||||
) => {
|
||||
const section = entry.target
|
||||
|
||||
if (
|
||||
(inView || checkElementInViewport(section, 40)) &&
|
||||
activePath !== schemaSlug
|
||||
) {
|
||||
// can't use next router as it doesn't support
|
||||
// changing url without scrolling
|
||||
history.pushState({}, "", `#${schemaSlug}`)
|
||||
setActivePath(schemaSlug)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<InView
|
||||
as="div"
|
||||
id={schemaSlug}
|
||||
initialInView={false}
|
||||
onChange={handleViewChange}
|
||||
root={root}
|
||||
threshold={0.1}
|
||||
>
|
||||
<DividedLayout
|
||||
mainContent={
|
||||
<SectionContainer>
|
||||
<h2>{formattedName} Object</h2>
|
||||
<h4 className="border-medusa-border-base border-b py-1.5 mt-2">
|
||||
Fields
|
||||
</h4>
|
||||
<TagOperationParameters schemaObject={schema} topLevel={true} />
|
||||
</SectionContainer>
|
||||
}
|
||||
codeContent={
|
||||
<SectionContainer noDivider>
|
||||
{examples.length && (
|
||||
<CodeBlock
|
||||
source={examples[0].content}
|
||||
lang="json"
|
||||
title={`The ${formattedName} Object`}
|
||||
/>
|
||||
)}
|
||||
</SectionContainer>
|
||||
}
|
||||
/>
|
||||
</InView>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagSectionSchema
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import getSectionId from "@/utils/get-section-id"
|
||||
import type { OpenAPIV3 } from "openapi-types"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { isElmWindow, useScrollController, useSidebar } from "docs-ui"
|
||||
import {
|
||||
getLinkWithBasePath,
|
||||
isElmWindow,
|
||||
swrFetcher,
|
||||
useScrollController,
|
||||
useSidebar,
|
||||
} from "docs-ui"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { SectionProps } from "../../Section"
|
||||
import type { MDXContentClientProps } from "../../MDXContent/Client"
|
||||
@@ -12,21 +17,29 @@ import TagPaths from "../Paths"
|
||||
import DividedLayout from "@/layouts/Divided"
|
||||
import LoadingProvider from "@/providers/loading"
|
||||
import SectionContainer from "../../Section/Container"
|
||||
import { useArea } from "../../../providers/area"
|
||||
import { useArea } from "@/providers/area"
|
||||
import SectionDivider from "../../Section/Divider"
|
||||
import clsx from "clsx"
|
||||
import { Feedback, Loading, Link } from "docs-ui"
|
||||
import { usePathname } from "next/navigation"
|
||||
import formatReportLink from "../../../utils/format-report-link"
|
||||
import formatReportLink from "@/utils/format-report-link"
|
||||
import { SchemaObject, TagObject } from "@/types/openapi"
|
||||
import useSWR from "swr"
|
||||
import { useVersion } from "@/providers/version"
|
||||
import { TagSectionSchemaProps } from "./Schema"
|
||||
|
||||
export type TagSectionProps = {
|
||||
tag: OpenAPIV3.TagObject
|
||||
tag: TagObject
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const Section = dynamic<SectionProps>(
|
||||
async () => import("../../Section")
|
||||
) as React.FC<SectionProps>
|
||||
|
||||
const TagSectionSchema = dynamic<TagSectionSchemaProps>(
|
||||
async () => import("./Schema")
|
||||
) as React.FC<TagSectionSchemaProps>
|
||||
|
||||
const MDXContentClient = dynamic<MDXContentClientProps>(
|
||||
async () => import("../../MDXContent/Client"),
|
||||
{
|
||||
@@ -41,6 +54,23 @@ const TagSection = ({ tag }: TagSectionProps) => {
|
||||
const { area } = useArea()
|
||||
const pathname = usePathname()
|
||||
const { scrollableElement } = useScrollController()
|
||||
const { version } = useVersion()
|
||||
const { data } = useSWR<{
|
||||
schema: SchemaObject
|
||||
}>(
|
||||
tag["x-associatedSchema"]
|
||||
? getLinkWithBasePath(
|
||||
`/schema?name=${tag["x-associatedSchema"].$ref}&area=${area}&version=${version}`,
|
||||
process.env.NEXT_PUBLIC_BASE_PATH
|
||||
)
|
||||
: null,
|
||||
swrFetcher,
|
||||
{
|
||||
errorRetryInterval: 2000,
|
||||
}
|
||||
)
|
||||
const associatedSchema = data?.schema
|
||||
|
||||
const root = useMemo(() => {
|
||||
return isElmWindow(scrollableElement) ? document.body : scrollableElement
|
||||
}, [scrollableElement])
|
||||
@@ -118,6 +148,9 @@ const TagSection = ({ tag }: TagSectionProps) => {
|
||||
}
|
||||
codeContent={<></>}
|
||||
/>
|
||||
{associatedSchema && (
|
||||
<TagSectionSchema schema={associatedSchema} tagName={tag.name} />
|
||||
)}
|
||||
{loadPaths && (
|
||||
<LoadingProvider initialLoading={true}>
|
||||
<TagPaths tag={tag} />
|
||||
|
||||
85
www/apps/api-reference/hooks/use-schema-example.ts
Normal file
85
www/apps/api-reference/hooks/use-schema-example.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { ExampleObject, SchemaObject } from "../types/openapi"
|
||||
import type { JSONSchema7 } from "json-schema"
|
||||
import stringify from "json-stringify-pretty-compact"
|
||||
import { sample } from "openapi-sampler"
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
|
||||
type Options = {
|
||||
schema?: SchemaObject
|
||||
schemaExamples?: OpenAPIV3.ExampleObject
|
||||
schemaExample?: any
|
||||
options?: {
|
||||
skipNonRequired?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const useSchemaExample = ({
|
||||
schema,
|
||||
schemaExamples,
|
||||
schemaExample,
|
||||
options = {},
|
||||
}: Options) => {
|
||||
const { skipNonRequired = true } = options
|
||||
const examples = useMemo(() => {
|
||||
const tempExamples: ExampleObject[] = []
|
||||
|
||||
if (!schema) {
|
||||
return tempExamples
|
||||
}
|
||||
|
||||
if (schemaExamples) {
|
||||
Object.entries(schemaExamples).forEach(([value, example]) => {
|
||||
if ("$ref" in example) {
|
||||
return []
|
||||
}
|
||||
|
||||
tempExamples.push({
|
||||
title: example.summary || "",
|
||||
value,
|
||||
content: stringify(example.value, {
|
||||
maxLength: 50,
|
||||
}),
|
||||
})
|
||||
})
|
||||
} else if (schemaExample) {
|
||||
tempExamples.push({
|
||||
title: "",
|
||||
value: "",
|
||||
content: stringify(schemaExample, {
|
||||
maxLength: 50,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
const contentSample = stringify(
|
||||
sample(
|
||||
{
|
||||
...schema,
|
||||
} as JSONSchema7,
|
||||
{
|
||||
skipNonRequired,
|
||||
}
|
||||
),
|
||||
{
|
||||
maxLength: 50,
|
||||
}
|
||||
)
|
||||
|
||||
tempExamples.push({
|
||||
title: "",
|
||||
value: "",
|
||||
content: contentSample,
|
||||
})
|
||||
}
|
||||
|
||||
return tempExamples
|
||||
}, [schema, schemaExample, schemaExamples, skipNonRequired])
|
||||
|
||||
return {
|
||||
examples,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSchemaExample
|
||||
@@ -88,6 +88,7 @@ export type SchemaObject = (ArraySchemaObject | NonArraySchemaObject) & {
|
||||
isRequired?: boolean
|
||||
"x-featureFlag"?: string
|
||||
"x-expandable"?: string
|
||||
"x-schemaName"?: string
|
||||
}
|
||||
|
||||
export type PropertiesObject = {
|
||||
@@ -114,3 +115,7 @@ export type ExpandedDocument = Document & {
|
||||
[k: string]: PathsObject
|
||||
}
|
||||
}
|
||||
|
||||
export type TagObject = OpenAPIV3.TagObject & {
|
||||
"x-associatedSchema"?: OpenAPIV3.ReferenceObject
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function getPathsOfTag(
|
||||
return {
|
||||
...fileContent,
|
||||
operationPath: `/${file
|
||||
.replaceAll("_", "/")
|
||||
.replaceAll(/(?<!\{[^}]*)_(?![^{]*\})/g, "/")
|
||||
.replace(/\.[A-Za-z]+$/, "")}`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,7 +145,14 @@ export const reducer = (
|
||||
if (i.path && parent?.path && i.path === parent?.path) {
|
||||
return {
|
||||
...i,
|
||||
children: [...(i.children || []), ...items],
|
||||
children:
|
||||
indexPosition !== undefined
|
||||
? [
|
||||
...(i.children?.slice(0, indexPosition) || []),
|
||||
...items,
|
||||
...(i.children?.slice(indexPosition) || []),
|
||||
]
|
||||
: [...(i.children || []), ...items],
|
||||
loaded: parent.changeLoaded ? true : i.loaded,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user