From 2ffdab0c5509ee660079fd684b1d13d0f0dd9a4a Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Mon, 5 Feb 2024 23:22:15 -0500 Subject: [PATCH] Hack around limitation re: $refs with siblings For any schema that defines a $ref property alongside any other properties, docusaurus-json-schema-plugin currently loses all other properties and treats the schema as just the $ref'd other schema This is because docusaurus-json-schema-plugin uses the now-unmaintained @stoplight/json-ref-resolver, which is stuck on the old JSON Schema drafts 4-7 semantics. (In those older versions, this behavior was correct.) To work around this issue: - Expand the existing schema preprocessing logic to detect all $ref's with siblings - For each such case, replace the `{ $ref: "...", ...props }` with that $ref moved into an available schema composition keyword. (e.g., it might become `{ ...props, allOf: [{ $ref: "..." }] }`). - Throw an error if the schema uses all three composition keywords and there's no place for this weird unnecessary composition. (This seems quite unlikely, though) - Override docusaurus-json-schema-plugin's rendering to detect this preprocessed "unnecessary composition" and treat it as a special-case apart from the normal way it presents lists of composed schemas. - When a schema composition is used with only one item, treat it as representing the extension of a base schema, and present it as such. - Present purely annotative extensions with less visual clutter (like, for when a schema is just `{ $ref: "...", description: "..." }` --- packages/web/src/components/SchemaViewer.tsx | 74 ++++++++- .../components/CreateNodes.tsx | 18 ++- .../components/UnnecessaryComposition.tsx | 144 ++++++++++++++++++ 3 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx diff --git a/packages/web/src/components/SchemaViewer.tsx b/packages/web/src/components/SchemaViewer.tsx index 431d8bb4..d0e9ce34 100644 --- a/packages/web/src/components/SchemaViewer.tsx +++ b/packages/web/src/components/SchemaViewer.tsx @@ -1,5 +1,6 @@ import React from "react"; import type { URL } from "url"; +import type { JSONSchema } from "json-schema-typed/draft-2020-12"; import JSONSchemaViewer from "@theme/JSONSchemaViewer"; import CodeBlock from "@theme/CodeBlock"; import Tabs from "@theme/Tabs"; @@ -31,7 +32,7 @@ export default function SchemaViewer(props: SchemaViewerProps): JSX.Element { pointer } = rootSchemaInfo; - const transformedSchema = insertIds(rootSchema, `${id}#`); + const transformedSchema = transformSchema(rootSchema, id || ""); return ( @@ -51,7 +52,7 @@ export default function SchemaViewer(props: SchemaViewerProps): JSX.Element { const { schema } = describeSchema({ schema: { id } }); - return insertIds(schema, `${id}#`); + return transformSchema(schema, id); } } } @@ -88,6 +89,10 @@ export default function SchemaViewer(props: SchemaViewerProps): JSX.Element { ); } +function transformSchema(schema: JSONSchema, id: string): JSONSchema { + return insertIds(ensureRefsLackSiblings(schema), `${id}#`) +} + function insertIds(obj: T, rootId: string): T { if (Array.isArray(obj)) { return obj.map((item, index) => insertIds(item, `${rootId}/${index}`)) as T; @@ -104,3 +109,68 @@ function insertIds(obj: T, rootId: string): T { } return obj; } + +// recursively iterates over a schema and finds all instances where `$ref` is +// defined alonside other fields. +// +// this function is a HACK to get around docusaurus-json-schema-plugin's use of +// @stoplight/json-ref-resolver, whose behavior is to override all objects +// containing `$ref` with the resolved reference itself (thus clobbering those +// other fields). +// +// this integration preprocesses all such occurrences by moving any sibling +// $ref field into an available `allOf`/`oneOf`/`anyOf` (or throwing an error +// if all three are already used) +// +// NOTE that it would be fine to re-use an existing `allOf`, but the approach +// that this integration takes is to handle those composition keywords +// as a special-case when rendering a composition of size one +// (i.e., when rendering, detect single-child compositions as the signal that +// this processing step was used). +function ensureRefsLackSiblings(obj: T): T { + // base case + if (!obj || typeof obj !== "object") { + return obj; + } + + // array case + if (Array.isArray(obj)) { + return obj.map(ensureRefsLackSiblings) as T; + } + + // check for just { $ref: ... } + if (Object.keys(obj).length === 1 && "$ref" in obj) { + return obj; + } + + const { + $ref, + ...rest + } = obj as T & object & { $ref?: string }; + + const result = Object.entries(rest) + .reduce((newObj, [key, value]) => { + // @ts-ignore + newObj[key] = ensureRefsLackSiblings(value); + return newObj; + }, {} as T); + + if (!$ref) { + return result; + } + + // find an unused schema composition keyword and move the $ref there + const propertyName = ["allOf", "oneOf", "anyOf"] + .find((candidate) => !(candidate in obj)); + + if (!propertyName) { + throw new Error( + `Could not find available composition keyword in ${JSON.stringify(obj)}` + ); + } + + // @ts-ignore + result[propertyName] = [{ $ref: $ref }]; + + return result; +} diff --git a/packages/web/src/theme/JSONSchemaViewer/components/CreateNodes.tsx b/packages/web/src/theme/JSONSchemaViewer/components/CreateNodes.tsx index 9bb9cc16..abd27aa8 100644 --- a/packages/web/src/theme/JSONSchemaViewer/components/CreateNodes.tsx +++ b/packages/web/src/theme/JSONSchemaViewer/components/CreateNodes.tsx @@ -6,6 +6,10 @@ import { useSchemaHierarchyContext } from "@theme-original/JSONSchemaViewer/cont import { useSchemaContext, internalIdKey } from "@site/src/contexts/SchemaContext"; import Link from "@docusaurus/Link"; +import UnnecessaryCompositionSchema, { + detectUnnecessaryComposition +} from "./UnnecessaryComposition"; + export default function CreateNodesWrapper(props: { schema: Exclude & { [internalIdKey]: string @@ -15,13 +19,12 @@ export default function CreateNodesWrapper(props: { const { schemaIndex } = useSchemaContext(); const { - schema: { - [internalIdKey]: id, - ...schema - }, + schema, ...otherProps } = props; + const { [internalIdKey]: id } = schema; + if (id && id in schemaIndex && level > 0) { const { href, @@ -39,9 +42,14 @@ export default function CreateNodesWrapper(props: { ); } + const unnecessaryComposition = detectUnnecessaryComposition(schema); + if (unnecessaryComposition) { + return ; + } + return ( <> - + ); } diff --git a/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx new file mode 100644 index 00000000..dd20f6fd --- /dev/null +++ b/packages/web/src/theme/JSONSchemaViewer/components/UnnecessaryComposition.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import type { JSONSchema } from "json-schema-typed/draft-2020-12"; +import CreateNodes from "@theme/JSONSchemaViewer/components/CreateNodes"; +import CreateEdge from "@theme-original/JSONSchemaViewer/components/CreateEdge"; +import { SchemaHierarchyComponent } from "@theme-original/JSONSchemaViewer/contexts" +import { Collapsible } from "@theme/JSONSchemaViewer/components"; +import { GenerateFriendlyName, QualifierMessages } from "@theme/JSONSchemaViewer/utils"; +import { internalIdKey } from "@site/src/contexts/SchemaContext"; +import { CreateDescription } from "@theme/JSONSchemaViewer/JSONSchemaElements"; +import { useJSVOptionsContext } from "@theme/JSONSchemaViewer/contexts" + +export interface UnnecessaryComposition { + schemaWithoutUnnecessaryComposition: Exclude; + unnecessaryCompositionKeyword: "allOf" | "oneOf" | "anyOf"; + unnecessarilyComposedSchema: JSONSchema; +} + +export function detectUnnecessaryComposition( + schema: JSONSchema +): UnnecessaryComposition | undefined { + if (typeof schema === "boolean") { + return; + } + + const unnecessaryCompositionKeywords = (["allOf", "oneOf", "anyOf"] as const) + .filter(keyword => keyword in schema && (schema[keyword] || []).length === 1); + + if (unnecessaryCompositionKeywords.length !== 1) { + return; + } + + const [unnecessaryCompositionKeyword] = unnecessaryCompositionKeywords; + + const { + [unnecessaryCompositionKeyword]: composition, + ...schemaWithoutUnnecessaryComposition + } = schema; + + const [unnecessarilyComposedSchema] = + composition as [JSONSchema] /* (we know this from filter above) */; + + return { + unnecessarilyComposedSchema, + unnecessaryCompositionKeyword, + schemaWithoutUnnecessaryComposition + }; +} + +export interface UnnecessaryCompositionSchemaProps extends UnnecessaryComposition { +} + +export default function UnnecessaryCompositionSchema({ + schemaWithoutUnnecessaryComposition, + unnecessaryCompositionKeyword, + unnecessarilyComposedSchema +}: UnnecessaryCompositionSchemaProps): JSX.Element { + const jsvOptions = useJSVOptionsContext(); + + // treat the unnecessary composition to represent the extension of a base + // schema, where the unnecessarily composed schema is the base + const baseSchema = unnecessarilyComposedSchema; + const extensionSchema = schemaWithoutUnnecessaryComposition; + const { + documentation, + semantics + } = separateDocumentationFromSemantics(extensionSchema); + + if (Object.keys(semantics).length === 0) { + const { description } = documentation; + + return <> + + {description && } +
+ + + + + ; + } + + return ( + <> + extensions  + These extensions apply to the base schema below: +

+ +

+ + +   + base schema + + } + detailsProps={{ + open: true + }} + > + + + + + + + ); +} + +function separateDocumentationFromSemantics(schema: JSONSchema): { + documentation: Exclude, + semantics: JSONSchema +} { + if (typeof schema === "boolean") { + return { + documentation: {}, + semantics: schema + }; + } + + const { + title, + description, + examples, + default: default_, + // @ts-ignore + [internalIdKey]: _id, + ...semantics + } = schema; + + return { + documentation: { + title, + description, + examples, + default: default_ + }, + semantics + }; +}