diff --git a/client/config/jest.config.ts b/client/config/jest.config.ts index c46728b6b..c649e4658 100644 --- a/client/config/jest.config.ts +++ b/client/config/jest.config.ts @@ -45,7 +45,7 @@ const config: JestConfigWithTsJest = { "^.+\\.[cm]?[jt]sx?$": "ts-jest", }, transformIgnorePatterns: [ - "node_modules/(?!(keycloak-js|react-error-boundary)/)", // process esm only modules + "node_modules/(?!(keycloak-js|react-error-boundary|lodash-es)/)", // process esm only modules ], // Code to set up the testing framework before each test file in the suite is executed diff --git a/client/package.json b/client/package.json index 949a01878..9bf55b90d 100644 --- a/client/package.json +++ b/client/package.json @@ -44,6 +44,7 @@ "immer": "^10.2.0", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", + "lodash-es": "^4.17.21", "monaco-editor": "^0.52.2", "radash": "^12.1.0", "react": "^18.3.1", @@ -68,6 +69,7 @@ "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.4", "@types/js-yaml": "^4.0.9", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-measure": "^2.0.12", @@ -80,10 +82,10 @@ "css-minimizer-webpack-plugin": "^7.0.2", "fork-ts-checker-webpack-plugin": "^9.1.0", "html-webpack-plugin": "^5.6.3", + "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-resolve": "^29.7.0", "jest-watch-typeahead": "^2.2.2", - "jest": "^29.7.0", "mini-css-extract-plugin": "^2.8.1", "monaco-editor-webpack-plugin": "^7.1.1", "msw": "^1.2.3", diff --git a/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx index 690089454..5ecc71d51 100644 --- a/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input-cloudfoundry.tsx @@ -61,38 +61,10 @@ const RemoveButton = ({ ); }; -/* -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "names": { - "description": "Application names. Each may be a glob expression.", - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 0, - "type": "array" - }, - "spaces": { - "description": "Space names.", - "items": { - "minLength": 1, - "type": "string" - }, - "minItems": 1, - "type": "array" - } - }, - "required": ["spaces"], - "title": "CloudFoundry Discover Filter", - "type": "object" -} -*/ - interface FormValues { - names: { value: string }[]; + organizations: { value: string }[]; spaces: { value: string }[]; + names: { value: string }[]; } const stringsToFormValue = (values?: string[]) => @@ -108,40 +80,40 @@ export interface FilterInputCloudFoundryProps { } /** - * Inputs for CloudFoundry discover applications filter. This is based on the json schema. + * Inputs for CloudFoundry discover applications filter. + * + * This is based on the JSON schema for the Cloud Foundry discovery filter as defined in + * {@link ./validate-cloudfoundry-schema.tsx}. */ export const FilterInputCloudFoundry: React.FC< FilterInputCloudFoundryProps > = ({ id, values, onDocumentChanged }) => { const validationSchema = yup.object().shape({ - names: yup - .array() - .of( - yup.object().shape({ - value: yup - .string() - .min(1, "Name must be at least 1 character") - .trim(), - }) - ) - .min(0), - spaces: yup - .array() - .of( - yup.object().shape({ - value: yup - .string() - .min(1, "Space must be at least 1 character") - .trim(), - }) - ) - .min(1), + organizations: yup.array().of( + yup.object().shape({ + value: yup + .string() + .min(1, "Organization name must be at least 1 character") + .trim(), + }) + ), + spaces: yup.array().of( + yup.object().shape({ + value: yup.string().min(1, "Space must be at least 1 character").trim(), + }) + ), + names: yup.array().of( + yup.object().shape({ + value: yup.string().min(1, "Name must be at least 1 character").trim(), + }) + ), }); const form = useForm({ defaultValues: { - names: stringsToFormValue(values?.names as string[]), + organizations: stringsToFormValue(values?.organizations as string[]), spaces: stringsToFormValue(values?.spaces as string[]), + names: stringsToFormValue(values?.names as string[]), }, resolver: yupResolver(validationSchema), mode: "all", @@ -154,8 +126,9 @@ export const FilterInputCloudFoundry: React.FC< formState: { values: true }, callback: ({ values }) => { const asDocument = { - names: formValueToStrings(values.names), + organizations: formValueToStrings(values.organizations), spaces: formValueToStrings(values.spaces), + names: formValueToStrings(values.names), }; onDocumentChanged(asDocument); }, @@ -168,12 +141,13 @@ export const FilterInputCloudFoundry: React.FC< @@ -185,8 +159,21 @@ export const FilterInputCloudFoundry: React.FC< fieldName="spaces" addLabel="Add a space" removeLabel="Remove this space" - emptyMessage="No spaces specified (at least one space is required)" - isRequired={true} + emptyMessage="No spaces specified" + isRequired={false} + /> + + + + diff --git a/client/src/app/components/discover-import-wizard/filter-input.tsx b/client/src/app/components/discover-import-wizard/filter-input.tsx index 0d0df1733..e325a37c5 100644 --- a/client/src/app/components/discover-import-wizard/filter-input.tsx +++ b/client/src/app/components/discover-import-wizard/filter-input.tsx @@ -14,6 +14,7 @@ import { useFetchPlatformDiscoveryFilterSchema } from "@app/queries/schemas"; import { wrapAsEvent } from "@app/utils/utils"; import { FilterInputCloudFoundry } from "./filter-input-cloudfoundry"; +import { useCloudFoundryCheck } from "./validate-cloudfoundry-schema"; interface FiltersFormValues { filterRequired: boolean; @@ -106,6 +107,11 @@ export const FilterInput: React.FC<{ useFilterStateChangeHandler(form, onFiltersChanged); + const shouldUseCloudFoundryInput = useCloudFoundryCheck( + platform, + filtersSchema + ); + return (
@@ -140,7 +146,7 @@ export const FilterInput: React.FC<{ })} fieldId="document" renderInput={({ field: { value, name, onChange } }) => - platform.kind === "cloudfoundry" ? ( + shouldUseCloudFoundryInput ? ( ; } - const names = (values.names as string[]) ?? []; + const organizations = (values.organizations as string[]) ?? []; const spaces = (values.spaces as string[]) ?? []; + const names = (values.names as string[]) ?? []; return ( - Names + Organizations - {names.length === 0 ? ( - + {organizations.length === 0 ? ( + ) : ( - {names.map((name) => ( - {name} + {organizations.map((organization) => ( + {organization} ))} )} @@ -60,6 +61,21 @@ export const ReviewInputCloudFoundry: React.FC< )} + + + Names + + {names.length === 0 ? ( + + ) : ( + + {names.map((name) => ( + {name} + ))} + + )} + + ); }; diff --git a/client/src/app/components/discover-import-wizard/review.tsx b/client/src/app/components/discover-import-wizard/review.tsx index 652496897..9ef5d9a75 100644 --- a/client/src/app/components/discover-import-wizard/review.tsx +++ b/client/src/app/components/discover-import-wizard/review.tsx @@ -16,6 +16,7 @@ import { usePlatformKindList } from "@app/hooks/usePlatformKindList"; import { FilterState } from "./filter-input"; import { ReviewInputCloudFoundry } from "./review-input-cloudfoundry"; +import { useCloudFoundryCheck } from "./validate-cloudfoundry-schema"; export const Review: React.FC<{ platform: SourcePlatform; @@ -27,6 +28,11 @@ export const Review: React.FC<{ const showFilters = filters.filterRequired && filters.schema && filters.document; + const shouldUseCloudFoundryReview = useCloudFoundryCheck( + platform, + filters.schema + ); + return (
@@ -82,7 +88,7 @@ export const Review: React.FC<{ padding: "16px", }} > - {platform.kind === "cloudfoundry" ? ( + {shouldUseCloudFoundryReview ? ( { + return React.useMemo(() => { + return ( + platform.kind === "cloudfoundry" && + schema && + validateCloudFoundrySchema(schema.definition) + ); + }, [platform.kind, schema]); +}; diff --git a/client/src/app/components/schema-defined-fields/utils.test.tsx b/client/src/app/components/schema-defined-fields/utils.test.tsx index bffefbf72..eb3475f50 100644 --- a/client/src/app/components/schema-defined-fields/utils.test.tsx +++ b/client/src/app/components/schema-defined-fields/utils.test.tsx @@ -1,10 +1,6 @@ import { JsonSchemaObject } from "@app/api/models"; -import { - combineSchemas, - isComplexSchema, - jsonSchemaToYupSchema, -} from "./utils"; +import { isComplexSchema, jsonSchemaToYupSchema } from "./utils"; describe("jsonSchemaToYupSchema", () => { it("should return a yup schema", () => { @@ -172,317 +168,3 @@ describe("isComplexSchema", () => { expect(isComplexSchema(schema)).toBe(true); }); }); - -describe("combineSchemas", () => { - it("returns undefined when input is undefined", () => { - expect(combineSchemas(undefined)).toBeUndefined(); - }); - - it("returns undefined when input is null", () => { - expect(combineSchemas(null as any)).toBeUndefined(); - }); - - it("returns a base schema when given an empty array", () => { - const result = combineSchemas([]); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }); - }); - - it("combines properties from a single object schema", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }); - }); - - it("combines properties from multiple object schemas", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - { - type: "object", - properties: { - email: { type: "string" }, - active: { type: "boolean" }, - }, - required: ["email"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - email: { type: "string" }, - active: { type: "boolean" }, - }, - required: ["name", "email"], - }); - }); - - it("overwrites properties when there are duplicates (later ones win)", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string", minLength: 1 }, - age: { type: "number" }, - }, - }, - { - type: "object", - properties: { - name: { type: "string", minLength: 5, maxLength: 50 }, - email: { type: "string" }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.name).toEqual({ - type: "string", - minLength: 5, - maxLength: 50, - }); - expect(result?.properties?.age).toEqual({ type: "number" }); - expect(result?.properties?.email).toEqual({ type: "string" }); - }); - - it("combines required fields uniquely (no duplicates)", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { name: { type: "string" } }, - required: ["name", "age"], - }, - { - type: "object", - properties: { email: { type: "string" } }, - required: ["email", "name"], // "name" is duplicate - }, - { - type: "object", - properties: { active: { type: "boolean" } }, - required: ["active"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.required).toEqual(["name", "age", "email", "active"]); - }); - - it("ignores non-object schemas", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "string", // This should be ignored - }, - { - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }, - { - type: "array", // This should be ignored - items: { type: "string" }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - }, - required: ["name"], - }); - }); - - it("handles schemas without properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - required: ["name"], - }, - { - type: "object", - properties: { - email: { type: "string" }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - email: { type: "string" }, - }, - required: ["name"], - }); - }); - - it("handles schemas without required fields", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - { - type: "object", - properties: { - email: { type: "string" }, - }, - required: ["email"], - }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - }, - required: ["email"], - }); - }); - - it("handles complex nested object properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }, - }, - required: ["user"], - }, - { - type: "object", - properties: { - settings: { - type: "object", - properties: { - theme: { type: "string" }, - notifications: { type: "boolean" }, - }, - }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.user).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }); - expect(result?.properties?.settings).toEqual({ - type: "object", - properties: { - theme: { type: "string" }, - notifications: { type: "boolean" }, - }, - }); - expect(result?.required).toEqual(["user"]); - }); - - it("handles schemas with array properties", () => { - const schemas: JsonSchemaObject[] = [ - { - type: "object", - properties: { - tags: { - type: "array", - items: { type: "string" }, - }, - name: { type: "string" }, - }, - required: ["name"], - }, - { - type: "object", - properties: { - scores: { - type: "array", - items: { type: "number" }, - }, - }, - }, - ]; - - const result = combineSchemas(schemas); - expect(result?.properties?.tags).toEqual({ - type: "array", - items: { type: "string" }, - }); - expect(result?.properties?.scores).toEqual({ - type: "array", - items: { type: "number" }, - }); - expect(result?.properties?.name).toEqual({ type: "string" }); - expect(result?.required).toEqual(["name"]); - }); - - it("returns base schema when all schemas are non-object types", () => { - const schemas: JsonSchemaObject[] = [ - { type: "string" }, - { type: "number" }, - { type: "boolean" }, - ]; - - const result = combineSchemas(schemas); - expect(result).toEqual({ - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }); - }); -}); diff --git a/client/src/app/components/schema-defined-fields/utils.tsx b/client/src/app/components/schema-defined-fields/utils.tsx index b06161295..f7630601b 100644 --- a/client/src/app/components/schema-defined-fields/utils.tsx +++ b/client/src/app/components/schema-defined-fields/utils.tsx @@ -1,6 +1,5 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { TFunction } from "i18next"; -import { unique } from "radash"; import * as yup from "yup"; import { JsonSchemaObject } from "@app/api/models"; @@ -167,51 +166,3 @@ export const isComplexSchema = (schema: JsonSchemaObject): boolean => { return false; }; - -export const isSchemaEmpty = (schema?: JsonSchemaObject): boolean => { - if (!schema) return true; - - if (schema.type === "object") { - return Object.keys(schema.properties ?? {}).length === 0; - } - - if (schema.type === "array") { - return isSchemaEmpty(schema.items); - } - - return ["string", "number", "boolean", "integer"].includes(schema.type); -}; - -/** - * Combines multiple schemas into a single schema. Only supports schemas with a root type of "object". - */ -export const combineSchemas = ( - schemas?: JsonSchemaObject[] -): JsonSchemaObject | undefined => { - if (!schemas) return undefined; - - const baseSchema: JsonSchemaObject = { - $schema: "https://json-schema.org/draft/2020-12/schema", - type: "object", - properties: {}, - required: [], - }; - - const combinedSchema = schemas.reduce((acc, schema) => { - if (schema.type === "object") { - // add all properties to the base schema, overwriting any existing properties - if (schema.properties) { - Object.entries(schema.properties).forEach(([key, value]) => { - acc.properties![key] = value; - }); - } - // uniquely add all required properties to the base schema - if (schema.required) { - acc.required = unique([...(acc.required ?? []), ...schema.required]); - } - } - return acc; - }, baseSchema); - - return combinedSchema; -}; diff --git a/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx b/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx index d3c047c40..49340db6d 100644 --- a/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx +++ b/client/src/app/pages/applications/generate-assets-wizard/step-capture-parameters.tsx @@ -17,12 +17,9 @@ import { import { JsonDocument, JsonSchemaObject, TargetProfile } from "@app/api/models"; import { HookFormPFGroupController } from "@app/components/HookFormPFFields"; import { SchemaDefinedField } from "@app/components/schema-defined-fields"; -import { - combineSchemas, - isSchemaEmpty, - jsonSchemaToYupSchema, -} from "@app/components/schema-defined-fields/utils"; +import { jsonSchemaToYupSchema } from "@app/components/schema-defined-fields/utils"; import { useFetchGenerators } from "@app/queries/generators"; +import { combineSchemas, isSchemaEmpty } from "@app/utils/json-schema"; import { wrapAsEvent } from "@app/utils/utils"; export interface ParameterState { diff --git a/client/src/app/utils/json-schema.test.ts b/client/src/app/utils/json-schema.test.ts new file mode 100644 index 000000000..59c015a43 --- /dev/null +++ b/client/src/app/utils/json-schema.test.ts @@ -0,0 +1,1314 @@ +import { JsonSchemaObject } from "@app/api/models"; + +import { + combineSchemas, + isEquivalentSchema, + stripAnnotations, + validatorGenerator, +} from "./json-schema"; + +describe("stripAnnotations", () => { + it("returns primitives as-is", () => { + expect(stripAnnotations("test")).toBe("test"); + expect(stripAnnotations(42)).toBe(42); + expect(stripAnnotations(true)).toBe(true); + expect(stripAnnotations(false)).toBe(false); + }); + + it("returns null as-is", () => { + expect(stripAnnotations(null)).toBe(null); + }); + + it("returns undefined as-is", () => { + expect(stripAnnotations(undefined)).toBe(undefined); + }); + + it("strips annotation keywords from a simple object", () => { + const schema = { + type: "string", + title: "User Name", + description: "The name of the user", + minLength: 1, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "string", + minLength: 1, + }); + }); + + it("strips all annotation keywords", () => { + const schema = { + type: "object", + title: "Test", + description: "A test object", + default: { foo: "bar" }, + examples: [{ foo: "baz" }], + $comment: "This is a comment", + deprecated: true, + readOnly: true, + writeOnly: false, + properties: { + name: { type: "string" }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + }); + }); + + it("preserves $schema keyword", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "Should be removed", + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + }); + }); + + it("recursively strips annotations from nested objects", () => { + const schema = { + type: "object", + title: "User", + properties: { + name: { + type: "string", + title: "Name", + description: "User's name", + minLength: 1, + }, + profile: { + type: "object", + description: "User profile", + properties: { + age: { + type: "number", + title: "Age", + minimum: 0, + }, + }, + }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + profile: { + type: "object", + properties: { + age: { + type: "number", + minimum: 0, + }, + }, + }, + }, + }); + }); + + it("strips annotations from array items", () => { + const schema = { + type: "array", + title: "Tags", + description: "List of tags", + items: { + type: "string", + title: "Tag", + description: "A single tag", + minLength: 1, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "array", + items: { + type: "string", + minLength: 1, + }, + }); + }); + + it("handles arrays of schemas", () => { + const schemas = [ + { + type: "string", + title: "First", + minLength: 1, + }, + { + type: "number", + description: "Second", + minimum: 0, + }, + ]; + + const result = stripAnnotations(schemas); + expect(result).toEqual([ + { + type: "string", + minLength: 1, + }, + { + type: "number", + minimum: 0, + }, + ]); + }); + + it("handles empty objects", () => { + const schema = {}; + const result = stripAnnotations(schema); + expect(result).toEqual({}); + }); + + it("handles empty arrays", () => { + const schema: JsonSchemaObject[] = []; + const result = stripAnnotations(schema); + expect(result).toEqual([]); + }); + + it("preserves required array", () => { + const schema = { + type: "object", + title: "User", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }); + }); + + it("preserves validation keywords like minLength, maxLength, pattern", () => { + const schema = { + type: "string", + title: "Email", + description: "User's email address", + minLength: 5, + maxLength: 100, + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "string", + minLength: 5, + maxLength: 100, + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + }); + }); + + it("preserves numeric validation keywords", () => { + const schema = { + type: "number", + title: "Age", + minimum: 0, + maximum: 120, + multipleOf: 1, + exclusiveMinimum: false, + exclusiveMaximum: true, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "number", + minimum: 0, + maximum: 120, + multipleOf: 1, + exclusiveMinimum: false, + exclusiveMaximum: true, + }); + }); + + it("handles complex nested structures with mixed arrays and objects", () => { + const schema = { + type: "object", + title: "Complex Schema", + properties: { + users: { + type: "array", + description: "List of users", + items: { + type: "object", + title: "User", + properties: { + name: { + type: "string", + description: "Name", + minLength: 1, + }, + tags: { + type: "array", + description: "Tags", + items: { + type: "string", + title: "Tag", + }, + }, + }, + }, + }, + }, + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({ + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + tags: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }, + }, + }); + }); + + it("does not mutate the original schema", () => { + const original = { + type: "string", + title: "Test", + description: "A test", + minLength: 1, + }; + const originalCopy = { ...original }; + + stripAnnotations(original); + + expect(original).toEqual(originalCopy); + }); + + it("handles schemas with only annotation keywords", () => { + const schema = { + title: "Only Annotations", + description: "This has only annotations", + examples: ["example1", "example2"], + }; + + const result = stripAnnotations(schema); + expect(result).toEqual({}); + }); +}); + +describe("combineSchemas", () => { + it("returns undefined when input is undefined", () => { + expect(combineSchemas(undefined)).toBeUndefined(); + }); + + it("returns undefined when input is null", () => { + expect(combineSchemas(null as any)).toBeUndefined(); + }); + + it("returns a base schema when given an empty array", () => { + const result = combineSchemas([]); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }); + }); + + it("combines properties from a single object schema", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }); + }); + + it("combines properties from multiple object schemas", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + { + type: "object", + properties: { + email: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["email"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["name", "email"], + }); + }); + + it("overwrites properties when there are duplicates (later ones win)", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number" }, + }, + }, + { + type: "object", + properties: { + name: { type: "string", minLength: 5, maxLength: 50 }, + email: { type: "string" }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.name).toEqual({ + type: "string", + minLength: 5, + maxLength: 50, + }); + expect(result?.properties?.age).toEqual({ type: "number" }); + expect(result?.properties?.email).toEqual({ type: "string" }); + }); + + it("combines required fields uniquely (no duplicates)", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { name: { type: "string" } }, + required: ["name", "age"], + }, + { + type: "object", + properties: { email: { type: "string" } }, + required: ["email", "name"], // "name" is duplicate + }, + { + type: "object", + properties: { active: { type: "boolean" } }, + required: ["active"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.required).toEqual(["name", "age", "email", "active"]); + }); + + it("ignores non-object schemas", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "string", // This should be ignored + }, + { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + { + type: "array", // This should be ignored + items: { type: "string" }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("handles schemas without properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + required: ["name"], + }, + { + type: "object", + properties: { + email: { type: "string" }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + email: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("handles schemas without required fields", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + { + type: "object", + properties: { + email: { type: "string" }, + }, + required: ["email"], + }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["email"], + }); + }); + + it("handles complex nested object properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }, + }, + required: ["user"], + }, + { + type: "object", + properties: { + settings: { + type: "object", + properties: { + theme: { type: "string" }, + notifications: { type: "boolean" }, + }, + }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.user).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }); + expect(result?.properties?.settings).toEqual({ + type: "object", + properties: { + theme: { type: "string" }, + notifications: { type: "boolean" }, + }, + }); + expect(result?.required).toEqual(["user"]); + }); + + it("handles schemas with array properties", () => { + const schemas: JsonSchemaObject[] = [ + { + type: "object", + properties: { + tags: { + type: "array", + items: { type: "string" }, + }, + name: { type: "string" }, + }, + required: ["name"], + }, + { + type: "object", + properties: { + scores: { + type: "array", + items: { type: "number" }, + }, + }, + }, + ]; + + const result = combineSchemas(schemas); + expect(result?.properties?.tags).toEqual({ + type: "array", + items: { type: "string" }, + }); + expect(result?.properties?.scores).toEqual({ + type: "array", + items: { type: "number" }, + }); + expect(result?.properties?.name).toEqual({ type: "string" }); + expect(result?.required).toEqual(["name"]); + }); + + it("returns base schema when all schemas are non-object types", () => { + const schemas: JsonSchemaObject[] = [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + ]; + + const result = combineSchemas(schemas); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }); + }); +}); + +describe("isEquivalentSchema", () => { + it("returns true for identical schemas", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number", minimum: 0 }, + }, + required: ["name"], + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + age: { type: "number", minimum: 0 }, + }, + required: ["name"], + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns true for schemas that differ only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "object", + title: "User Schema", + description: "A schema for users", + properties: { + name: { + type: "string", + title: "Name", + description: "User's name", + minLength: 1, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + title: "Person Schema", + description: "A schema for persons", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Person's full name", + minLength: 1, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for schemas with different types", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { name: { type: "string" } }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different properties", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different validation rules", () => { + const schemaA: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 10, + }; + const schemaB: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 20, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns false for schemas with different required fields", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns true when property order differs", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string" }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns true for nested schemas differing only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + title: "User Info", + properties: { + name: { type: "string", description: "Name field" }, + }, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + title: "User Data", + properties: { + name: { type: "string", description: "Full name field" }, + }, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for nested schemas with different structure", () => { + const schemaA: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }, + }, + }; + const schemaB: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("returns true for array schemas differing only in annotations", () => { + const schemaA: JsonSchemaObject = { + type: "array", + title: "Tags", + items: { + type: "string", + description: "A tag", + minLength: 1, + }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + title: "Labels", + items: { + type: "string", + description: "A label", + minLength: 1, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); + + it("returns false for array schemas with different item types", () => { + const schemaA: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + const schemaB: JsonSchemaObject = { + type: "array", + items: { type: "number" }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(false); + }); + + it("handles complex Cloud Foundry schema with annotations", () => { + const schemaA: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "Cloud Foundry Filter", + properties: { + organizations: { + title: "Organizations", + description: "Organization names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + title: "Spaces", + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + title: "Application Names", + description: "Application names. Each may be a glob expression.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const schemaB: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + description: "Org names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + description: "App names. Glob expressions allowed.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + expect(isEquivalentSchema(schemaA, schemaB)).toBe(true); + }); +}); + +describe("validatorGenerator", () => { + it("generates a validator that returns true for equivalent schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that ignores annotations in base schema", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + title: "User", + description: "A user schema", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that ignores annotations in test schema", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + title: "Person", + description: "A person schema", + properties: { + name: { + type: "string", + title: "Name", + description: "Person's name", + minLength: 1, + }, + }, + }; + + expect(validator(testSchema)).toBe(true); + }); + + it("generates a validator that returns false for non-equivalent schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string", minLength: 5 }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for different types", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { name: { type: "string" } }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "array", + items: { type: "string" }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for missing properties", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for extra properties", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a validator that returns false for different required fields", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name"], + }; + + const validator = validatorGenerator(baseSchema); + + const testSchema: JsonSchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + }, + required: ["name", "email"], + }; + + expect(validator(testSchema)).toBe(false); + }); + + it("generates a reusable validator for multiple comparisons", () => { + const baseSchema: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 100, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema1: JsonSchemaObject = { + type: "string", + title: "Name", + minLength: 1, + maxLength: 100, + }; + + const validSchema2: JsonSchemaObject = { + type: "string", + description: "A string value", + minLength: 1, + maxLength: 100, + }; + + const invalidSchema: JsonSchemaObject = { + type: "string", + minLength: 1, + maxLength: 50, + }; + + expect(validator(validSchema1)).toBe(true); + expect(validator(validSchema2)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); + + it("generates a validator for Cloud Foundry schema", () => { + const baseSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + description: "Organization names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + description: "Space names.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + description: "Application names. Each may be a glob expression.", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + title: "CF Filter", + properties: { + organizations: { + title: "Orgs", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + title: "Spaces", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + names: { + title: "Apps", + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + }, + }; + + const invalidSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + organizations: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + spaces: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + // Missing 'names' property + }, + }; + + expect(validator(validSchema)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); + + it("generates a validator that handles nested schemas", () => { + const baseSchema: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + }, + }, + }; + + const validator = validatorGenerator(baseSchema); + + const validSchema: JsonSchemaObject = { + type: "object", + title: "Container", + properties: { + user: { + type: "object", + title: "User Info", + properties: { + name: { type: "string", description: "Name" }, + age: { type: "number", description: "Age" }, + }, + }, + }, + }; + + const invalidSchema: JsonSchemaObject = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, // Different property + }, + }, + }, + }; + + expect(validator(validSchema)).toBe(true); + expect(validator(invalidSchema)).toBe(false); + }); +}); diff --git a/client/src/app/utils/json-schema.ts b/client/src/app/utils/json-schema.ts new file mode 100644 index 000000000..ddb210302 --- /dev/null +++ b/client/src/app/utils/json-schema.ts @@ -0,0 +1,132 @@ +import { isEqual } from "lodash-es"; +import { unique } from "radash"; + +import { JsonSchemaObject } from "@app/api/models"; + +/** + * Check if the given schema is functionally empty. + */ +export const isSchemaEmpty = (schema?: JsonSchemaObject): boolean => { + if (!schema) return true; + + if (schema.type === "object") { + return Object.keys(schema.properties ?? {}).length === 0; + } + + if (schema.type === "array") { + return isSchemaEmpty(schema.items); + } + + return ["string", "number", "boolean", "integer"].includes(schema.type); +}; + +/** + * Combines multiple schemas into a single schema. Only supports schemas with a root type of "object". + */ +export const combineSchemas = ( + schemas?: JsonSchemaObject[] +): JsonSchemaObject | undefined => { + if (!schemas) return undefined; + + const baseSchema: JsonSchemaObject = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }; + + const combinedSchema = schemas.reduce((acc, schema) => { + if (schema.type === "object") { + // add all properties to the base schema, overwriting any existing properties + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, value]) => { + acc.properties![key] = value; + }); + } + // uniquely add all required properties to the base schema + if (schema.required) { + acc.required = unique([...(acc.required ?? []), ...schema.required]); + } + } + return acc; + }, baseSchema); + + return combinedSchema; +}; + +/** + * A Set of all JSON Schema annotation keywords. + * These keywords describe the schema but do not enforce validation rules. + * We will strip these before comparing for functional equivalence. + */ +const ANNOTATION_KEYWORDS = new Set([ + "title", + "description", + "default", + "examples", + "$comment", + "deprecated", + "readOnly", + "writeOnly", + // '$schema' is debatable, but for "functional equivalence" of a given + // schema instance, it's often best to compare it too. +]); + +/** + * Recursively strips annotation keywords from a JSON schema object. + * @param schema The schema (or sub-schema) to strip. + * @returns A new object containing only the functional keywords. + */ +export function stripAnnotations(schema: T): T { + // Base case: not an object or null, return as-is + if (typeof schema !== "object" || schema === null) { + return schema; + } + + // Handle arrays by recursively stripping each element + if (Array.isArray(schema)) { + return schema.map(stripAnnotations) as T; + } + + // Handle objects by building a new object without annotation keys + const strippedSchema: Record = {}; + for (const key in schema) { + if (Object.prototype.hasOwnProperty.call(schema, key)) { + if (!ANNOTATION_KEYWORDS.has(key)) { + // This is a functional key. Recurse on its value. + strippedSchema[key] = stripAnnotations(schema[key]); + } + } + } + + return strippedSchema as T; +} + +/** + * Checks if an incoming JSON schema is functionally equivalent to + * another JSON schema. + */ +export const isEquivalentSchema = ( + schemaA: JsonSchemaObject, + schemaB: JsonSchemaObject +): boolean => { + // 1. Strip annotations from the incoming schema + const functionalSchemaA = stripAnnotations(schemaA); + const functionalSchemaB = stripAnnotations(schemaB); + + // 2. Deep-compare the two functional schemas. + // `isEqual` handles deep comparison and is not sensitive to key order. + return isEqual(functionalSchemaA, functionalSchemaB); +}; + +/** + * Generates a validator function that checks if an incoming JSON schema is functionally equivalent to + * a static base schema. + */ +export const validatorGenerator = (baseSchema: JsonSchemaObject) => { + const functionalBaseSchema = stripAnnotations(baseSchema); + return (schema: JsonSchemaObject) => { + const functionalSchema = stripAnnotations(schema); + return isEqual(functionalBaseSchema, functionalSchema); + }; +}; diff --git a/cspell.json b/cspell.json index c0f5a570c..3476f3ddd 100644 --- a/cspell.json +++ b/cspell.json @@ -2,6 +2,7 @@ "language": "en", "words": [ "cancelation", + "cloudfoundry", "colour", "coreutils", "iconed", @@ -10,7 +11,6 @@ "radash", "ruleset", "rulesets", - "skopeo", "taskgroup", "taskgroups", "toastr", diff --git a/package-lock.json b/package-lock.json index 6e4668215..e1cf01e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "immer": "^10.2.0", "js-yaml": "^4.1.0", "keycloak-js": "^26.1.0", + "lodash-es": "^4.17.21", "monaco-editor": "^0.52.2", "radash": "^12.1.0", "react": "^18.3.1", @@ -111,6 +112,7 @@ "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.4", "@types/js-yaml": "^4.0.9", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/react-measure": "^2.0.12", @@ -5518,6 +5520,16 @@ "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", "license": "MIT" }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",