diff --git a/package.json b/package.json index 145824857d..49b493e98e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/www/apps/api-reference/app/api/schema/route.ts b/www/apps/api-reference/app/api/schema/route.ts new file mode 100644 index 0000000000..719eb3f336 --- /dev/null +++ b/www/apps/api-reference/app/api/schema/route.ts @@ -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, + } + ) +} diff --git a/www/apps/api-reference/components/Section/Container/index.tsx b/www/apps/api-reference/components/Section/Container/index.tsx index acf1bf4d72..0394e51062 100644 --- a/www/apps/api-reference/components/Section/Container/index.tsx +++ b/www/apps/api-reference/components/Section/Container/index.tsx @@ -5,14 +5,18 @@ import { forwardRef } from "react" type SectionContainerProps = { children: React.ReactNode noTopPadding?: boolean + noDivider?: boolean } const SectionContainer = forwardRef( - function SectionContainer({ children, noTopPadding = false }, ref) { + function SectionContainer( + { children, noTopPadding = false, noDivider = false }, + ref + ) { return (
{children} - + {!noDivider && }
) } diff --git a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx index 37615bef4c..a1d8a3c623 100644 --- a/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/CodeSection/Responses/Sample/index.tsx @@ -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([]) + 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 ( <> diff --git a/www/apps/api-reference/components/Tags/Paths/index.tsx b/www/apps/api-reference/components/Tags/Paths/index.tsx index 845043c3ee..c71b6a55c1 100644 --- a/www/apps/api-reference/components/Tags/Paths/index.tsx +++ b/www/apps/api-reference/components/Tags/Paths/index.tsx @@ -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, diff --git a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx new file mode 100644 index 0000000000..e23b048e29 --- /dev/null +++ b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx @@ -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: Schema, + 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 ( + + +

{formattedName} Object

+

+ Fields +

+ + + } + codeContent={ + + {examples.length && ( + + )} + + } + /> +
+ ) +} + +export default TagSectionSchema diff --git a/www/apps/api-reference/components/Tags/Section/index.tsx b/www/apps/api-reference/components/Tags/Section/index.tsx index 060d0fb07c..d0d82461d4 100644 --- a/www/apps/api-reference/components/Tags/Section/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/index.tsx @@ -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 const Section = dynamic( async () => import("../../Section") ) as React.FC +const TagSectionSchema = dynamic( + async () => import("./Schema") +) as React.FC + const MDXContentClient = dynamic( 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 && ( + + )} {loadPaths && ( diff --git a/www/apps/api-reference/hooks/use-schema-example.ts b/www/apps/api-reference/hooks/use-schema-example.ts new file mode 100644 index 0000000000..774b157a02 --- /dev/null +++ b/www/apps/api-reference/hooks/use-schema-example.ts @@ -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 diff --git a/www/apps/api-reference/types/openapi.ts b/www/apps/api-reference/types/openapi.ts index f54ee31398..a6e5d3d31c 100644 --- a/www/apps/api-reference/types/openapi.ts +++ b/www/apps/api-reference/types/openapi.ts @@ -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 +} diff --git a/www/apps/api-reference/utils/get-paths-of-tag.ts b/www/apps/api-reference/utils/get-paths-of-tag.ts index f90fb30ae8..009c86bdd3 100644 --- a/www/apps/api-reference/utils/get-paths-of-tag.ts +++ b/www/apps/api-reference/utils/get-paths-of-tag.ts @@ -34,7 +34,7 @@ export default async function getPathsOfTag( return { ...fileContent, operationPath: `/${file - .replaceAll("_", "/") + .replaceAll(/(?