Skip to content

Commit

Permalink
Hack around limitation re: $refs with siblings
Browse files Browse the repository at this point in the history
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: "..." }`
  • Loading branch information
gnidan committed Feb 6, 2024
1 parent d767428 commit 2ffdab0
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 7 deletions.
74 changes: 72 additions & 2 deletions packages/web/src/components/SchemaViewer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<Tabs>
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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<T>(obj: T, rootId: string): T {
if (Array.isArray(obj)) {
return obj.map((item, index) => insertIds(item, `${rootId}/${index}`)) as T;
Expand All @@ -104,3 +109,68 @@ function insertIds<T>(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<T>(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;
}
18 changes: 13 additions & 5 deletions packages/web/src/theme/JSONSchemaViewer/components/CreateNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSONSchema, boolean> & {
[internalIdKey]: string
Expand All @@ -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,
Expand All @@ -39,9 +42,14 @@ export default function CreateNodesWrapper(props: {
);
}

const unnecessaryComposition = detectUnnecessaryComposition(schema);
if (unnecessaryComposition) {
return <UnnecessaryCompositionSchema {...unnecessaryComposition} />;
}

return (
<>
<CreateNodes schema={props.schema} {...otherProps} />
<CreateNodes schema={schema} {...otherProps} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -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<JSONSchema, boolean>;
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 <>
<QualifierMessages schema={documentation} options={jsvOptions} />
{description && <CreateDescription description={description} />}
<hr />

<SchemaHierarchyComponent
innerJsonPointer={`/${unnecessaryCompositionKeyword}/0`}
>
<CreateNodes schema={unnecessarilyComposedSchema} />
</SchemaHierarchyComponent>
</>;
}

return (
<>
<span className="badge badge--info">extensions</span>&nbsp;
These extensions apply to the base schema below:
<p>
<CreateNodes schema={extensionSchema} />
</p>

<Collapsible
summary={
<>
<strong><GenerateFriendlyName schema={baseSchema} /></strong>&nbsp;
<span className="badge badge--info">base schema</span>
</>
}
detailsProps={{
open: true
}}
>
<SchemaHierarchyComponent
innerJsonPointer={`/${unnecessaryCompositionKeyword}/0`}
>
<CreateNodes schema={unnecessarilyComposedSchema} />
</SchemaHierarchyComponent>
</Collapsible>

</>
);
}

function separateDocumentationFromSemantics(schema: JSONSchema): {
documentation: Exclude<JSONSchema, boolean>,
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
};
}

0 comments on commit 2ffdab0

Please sign in to comment.