feat(api-ref): show schema of a tag (#7297)

This commit is contained in:
Shahed Nasser
2024-05-10 21:07:35 +03:00
committed by GitHub
parent e960f10aba
commit 1a68f4602c
11 changed files with 368 additions and 77 deletions

View File

@@ -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",

View 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,
}
)
}

View File

@@ -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>
)
}

View File

@@ -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 (
<>

View File

@@ -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,

View 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

View File

@@ -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} />

View 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

View File

@@ -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
}

View File

@@ -34,7 +34,7 @@ export default async function getPathsOfTag(
return {
...fileContent,
operationPath: `/${file
.replaceAll("_", "/")
.replaceAll(/(?<!\{[^}]*)_(?![^{]*\})/g, "/")
.replace(/\.[A-Za-z]+$/, "")}`,
}
})

View File

@@ -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,
}
}