Skip to content

Commit

Permalink
Fix circular structure detection (#623)
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-lee authored Jan 30, 2025
1 parent bf1c996 commit 57a890f
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 13 deletions.
148 changes: 148 additions & 0 deletions examples/cosmo-cargo/schema/shipments.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@
"type": "string",
"format": "date-time",
"readOnly": true
},
"comments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Comment"
}
}
}
},
Expand Down Expand Up @@ -142,6 +148,100 @@
"type": "string"
}
}
},
"ShipmentHistory": {
"type": "object",
"properties": {
"currentShipment": {
"$ref": "#/components/schemas/Shipment"
},
"previousShipment": {
"$ref": "#/components/schemas/ShipmentHistory"
},
"transferredAt": {
"type": "string",
"format": "date-time"
}
}
},
"Comment": {
"type": "object",
"required": ["id", "author", "text", "timestamp"],
"description": "A comment on a shipment that can have nested replies",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the comment"
},
"author": {
"type": "string",
"description": "Name of the person who wrote the comment"
},
"text": {
"type": "string",
"description": "Content of the comment"
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "When the comment was posted"
},
"replies": {
"type": "array",
"description": "Nested replies to this comment",
"items": {
"$ref": "#/components/schemas/Comment"
}
}
}
},
"Organization": {
"type": "object",
"description": "A shipping organization that can have sub-organizations (e.g. regional offices)",
"required": ["id", "name"],
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the organization"
},
"name": {
"type": "string",
"description": "Name of the organization"
},
"parent": {
"$ref": "#/components/schemas/Organization",
"description": "The parent organization (direct object circular reference)"
},
"sub": {
"type": "array",
"description": "Child organizations (array circular reference)",
"items": {
"$ref": "#/components/schemas/Organization"
}
}
}
},
"LinkedShipment": {
"type": "object",
"description": "A shipment that's part of a chain (e.g. multi-leg delivery)",
"required": ["id", "status"],
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Unique identifier for the shipment"
},
"status": {
"type": "string",
"enum": ["PENDING", "IN_TRANSIT", "DELIVERED"]
},
"next": {
"$ref": "#/components/schemas/LinkedShipment"
},
"previous": {
"$ref": "#/components/schemas/LinkedShipment"
}
}
}
}
},
Expand Down Expand Up @@ -1688,6 +1788,54 @@
}
}
},
"/organizations": {
"get": {
"tags": ["Organization Management"],
"summary": "List all organizations",
"description": "Returns a list of all organizations in the hierarchy",
"operationId": "listOrganizations",
"responses": {
"200": {
"description": "List of organizations",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Organization"
}
}
}
}
}
},
"post": {
"tags": ["Organization Management"],
"summary": "Create a new organization",
"description": "Creates a new organization, optionally as part of an existing hierarchy",
"operationId": "createOrganization",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Organization"
}
}
}
},
"responses": {
"201": {
"description": "Organization created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Organization"
}
}
}
}
}
}
},
"/routes/{originFacilityId}/{destinationFacilityId}/{serviceLevel}/estimate": {
"get": {
"tags": ["Route Planning"],
Expand Down
33 changes: 27 additions & 6 deletions packages/zudoku/src/lib/oas/graphql/circular.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
import { GraphQLJSON } from "graphql-type-json";
import { GraphQLScalarType } from "graphql/index.js";
import { RecordAny } from "../../util/traverse.js";

export const CIRCULAR_REF = "$[Circular Reference]";
const handleCircularRefs = (obj: any, visited = new WeakSet()): any => {

const handleCircularRefs = (
obj: any,
visited = new Map<string, string[]>(),
path: string[] = [],
): any => {
if (obj === CIRCULAR_REF) return CIRCULAR_REF;
if (obj === null || typeof obj !== "object") return obj;

if (visited.has(obj)) return CIRCULAR_REF;
const currentPath = path.join(".");

if (obj.type === "object" && obj.properties) {
const schemaKey = Object.keys(obj.properties).sort().join("-");

visited.add(obj);
if (visited.has(schemaKey)) {
const prevPaths = visited.get(schemaKey)!;
if (prevPaths.some((prev) => currentPath.startsWith(prev))) {
return CIRCULAR_REF;
}
visited.set(schemaKey, [...prevPaths, currentPath]);
} else {
visited.set(schemaKey, [currentPath]);
}
}

if (Array.isArray(obj)) {
return obj.map((item) => handleCircularRefs(item, visited));
return obj.map((item, index) =>
handleCircularRefs(item, visited, [...path, `${index}`]),
);
}

const result: Record<string, any> = {};
const result: RecordAny = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = handleCircularRefs(value, visited);
result[key] = handleCircularRefs(value, visited, [...path, key]);
}
return result;
};

export const GraphQLJSONSchema = new GraphQLScalarType({
...GraphQLJSON,
name: "JSONSchema",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ListPlusIcon, RefreshCcwDotIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "zudoku/ui/Badge.js";
import { Markdown, ProseClasses } from "../../../components/Markdown.js";
import { CIRCULAR_REF } from "../../../oas/graphql/circular.js";
import type { SchemaObject } from "../../../oas/parser/index.js";
import { Button } from "../../../ui/Button.js";
import { cn } from "../../../util/cn.js";
Expand All @@ -12,6 +11,7 @@ import { LogicalGroup } from "./LogicalGroup/LogicalGroup.js";
import { SchemaView } from "./SchemaView.js";
import {
hasLogicalGroupings,
isCircularRef,
isComplexType,
LogicalSchemaTypeMap,
} from "./utils.js";
Expand Down Expand Up @@ -41,13 +41,10 @@ export const SchemaLogicalGroup = ({
}
};

const isCircularRef = (schema: unknown): schema is string =>
schema === CIRCULAR_REF;

const RecursiveIndicator = () => (
<div className="flex items-center gap-2 italic text-sm text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded-md">
<RefreshCcwDotIcon size={16} />
<span>recursive</span>
<span>circular</span>
</div>
);

Expand All @@ -74,6 +71,8 @@ export const SchemaPropertyItem = ({
<div className="flex flex-col gap-1 justify-between text-sm">
<div className="flex gap-2 items-center">
<code>{name}</code>
<Badge variant="muted">object</Badge>
{group === "optional" && <Badge variant="outline">optional</Badge>}
<RecursiveIndicator />
</div>
</div>
Expand All @@ -96,6 +95,9 @@ export const SchemaPropertyItem = ({
)}
</Badge>
{group === "optional" && <Badge variant="outline">optional</Badge>}
{schema.type === "array" &&
"items" in schema &&
isCircularRef(schema.items) && <RecursiveIndicator />}
</div>

{schema.description && (
Expand Down Expand Up @@ -133,7 +135,9 @@ export const SchemaPropertyItem = ({
<SchemaView schema={schema} level={level + 1} />
) : (
schema.type === "array" &&
typeof schema.items === "object" && (
"items" in schema &&
typeof schema.items === "object" &&
!isCircularRef(schema.items) && (
<SchemaView schema={schema.items} level={level + 1} />
)
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import type { SchemaObject } from "../../../oas/parser/index.js";
import { Card, CardContent, CardHeader, CardTitle } from "../../../ui/Card.js";
import { cn } from "../../../util/cn.js";
import { groupBy } from "../../../util/groupBy.js";
import { SchemaLogicalGroup, SchemaPropertyItem } from "./SchemaComponents.js";
import {
SchemaLogicalGroup,
SchemaPropertyItem,
} from "./SchemaPropertyItem.js";
import { hasLogicalGroupings } from "./utils.js";

export const SchemaView = ({
Expand Down
4 changes: 4 additions & 0 deletions packages/zudoku/src/lib/plugins/openapi/schema/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CIRCULAR_REF } from "../../../oas/graphql/circular.js";
import type { SchemaObject } from "../../../oas/parser/index.js";

export const isComplexType = (value: SchemaObject) =>
Expand All @@ -16,3 +17,6 @@ export const LogicalSchemaTypeMap = {
} as const;

export type LogicalGroupType = "AND" | "OR" | "ONE";

export const isCircularRef = (schema: unknown): schema is string =>
schema === CIRCULAR_REF;

0 comments on commit 57a890f

Please sign in to comment.