Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hack around limitation that prevents $ref: ...s from having siblings #81

Merged
merged 1 commit into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
};
}
Loading