From 0bb06fe5af00a32971b92048bef41423bf2fdbfb Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 23 Sep 2024 13:52:08 +0200 Subject: [PATCH 1/9] add validation schemas for sync-model folder --- src/modules/sync/validation/commonSchemas.ts | 3 + src/modules/sync/validation/elementSchemas.ts | 323 ++++++++++++++++++ src/modules/sync/validation/entitySchema.ts | 112 ++++++ src/modules/sync/validation/syncSchemas.ts | 21 ++ src/utils/types.ts | 48 +++ 5 files changed, 507 insertions(+) create mode 100644 src/modules/sync/validation/commonSchemas.ts create mode 100644 src/modules/sync/validation/elementSchemas.ts create mode 100644 src/modules/sync/validation/entitySchema.ts create mode 100644 src/modules/sync/validation/syncSchemas.ts diff --git a/src/modules/sync/validation/commonSchemas.ts b/src/modules/sync/validation/commonSchemas.ts new file mode 100644 index 00000000..c23a0f9a --- /dev/null +++ b/src/modules/sync/validation/commonSchemas.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const CodenameReferenceSchema = z.strictObject({ codename: z.string() }); diff --git a/src/modules/sync/validation/elementSchemas.ts b/src/modules/sync/validation/elementSchemas.ts new file mode 100644 index 00000000..d1091c88 --- /dev/null +++ b/src/modules/sync/validation/elementSchemas.ts @@ -0,0 +1,323 @@ +import { z } from "zod"; + +import { AddPropToObjectTuple, CombineTuples } from "../../../utils/types.js"; +import { + SyncAssetElement, + SyncCustomElement, + SyncDateTimeElement, + SyncGuidelinesElement, + SyncLinkedItemsElement, + SyncMultipleChoiceElement, + SyncNumberElement, + SyncRichTextElement, + SyncSubpagesElement, + SyncTaxonomyElement, + SyncTextElement, + SyncTypeSnippetElement, + SyncUrlSlugElement, +} from "../types/syncModel.js"; +import { CodenameReferenceSchema } from "./commonSchemas.js"; + +export const AssetElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("asset"), + guidelines: z.string().optional(), + asset_count_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + maximum_file_size: z.number().optional(), + allowed_file_types: z.union([z.literal("adjustable"), z.literal("any")]).optional(), + image_width_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + image_height_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })), + }), + }) + .optional(), +}) satisfies z.ZodType; + +export const CustomElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("custom"), + source_url: z.string(), + json_parameters: z.string().optional(), + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + allowed_elements: z.array(CodenameReferenceSchema).optional(), +}) satisfies z.ZodType; + +export const DateTimeElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("date_time"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.string(), + }), + }) + .optional(), +}) satisfies z.ZodType; + +export const GuidelinesElementDataSchema = z.strictObject({ + codename: z.string(), + guidelines: z.string(), + type: z.literal("guidelines"), +}) satisfies z.ZodType; + +export const LinkedItemsElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + item_count_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + guidelines: z.string().optional(), + type: z.literal("modular_content"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + default: z.strictObject({ + global: z.strictObject({ value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })) }), + }).optional(), +}) satisfies z.ZodType; + +const MultipleChoiceOptionSchema = z.strictObject({ + name: z.string(), + codename: z.string(), +}); + +export const MultipleChoiceElementDataSchema = z.strictObject({ + mode: z.union([z.literal("single"), z.literal("multiple")]), + options: z.array(MultipleChoiceOptionSchema), + codename: z.string(), + name: z.string(), + type: z.literal("multiple_choice"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z.strictObject({ + global: z.strictObject({ + value: z.array(CodenameReferenceSchema), + }), + }).optional(), +}) satisfies z.ZodType; + +export const NumberElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("number"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.number(), + }), + }) + .optional(), +}) satisfies z.ZodType; + +const RichTextAllowedBlockSchema = z.enum(["images", "text", "tables", "components-and-items"]); +const RichTextAllowedTableBlockSchema = z.enum(["images", "text"]); +const RichTextImageConditionSchema = z.enum(["at_most", "exactly", "at_least"]); +const RichTextAllowedImageTypeSchema = z.enum(["adjustable", "any"]); +const RichTextMaximumLengthAppliesToSchema = z.enum(["words", "characters"]); +const RichTextAllowedTextBlockSchema = z.enum([ + "paragraph", + "heading-one", + "heading-two", + "heading-three", + "heading-four", + "heading-five", + "heading-six", + "ordered-list", + "unordered-list", +]); +const RichTextAllowedFormattingSchema = z.enum([ + "unstyled", + "bold", + "italic", + "code", + "link", + "subscript", + "superscript", +]); + +export const RichTextElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("rich_text"), + maximum_text_length: z + .strictObject({ value: z.number(), applies_to: RichTextMaximumLengthAppliesToSchema }).optional(), + maximum_image_size: z.number().optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + allowed_item_link_types: z.array(CodenameReferenceSchema).optional(), + image_width_limit: z + .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) + .optional(), + image_height_limit: z + .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) + .optional(), + allowed_image_types: RichTextAllowedImageTypeSchema.optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + allowed_blocks: z.array(RichTextAllowedBlockSchema).optional(), + allowed_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), + allowed_formatting: z.array(RichTextAllowedFormattingSchema).optional(), + allowed_table_blocks: z.array(RichTextAllowedTableBlockSchema).optional(), + allowed_table_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), + allowed_table_formatting: z.array(RichTextAllowedFormattingSchema).optional(), +}) satisfies z.ZodType; + +export const SubpagesElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("subpages"), + item_count_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), +}) satisfies z.ZodType; + +export const SnippetElementDataSchema = z.strictObject({ + codename: z.string(), + type: z.literal("snippet"), + snippet: CodenameReferenceSchema, +}) satisfies z.ZodType; + +export const TaxonomyElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("taxonomy"), + taxonomy_group: CodenameReferenceSchema, + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + term_count_limit: z + .strictObject({ + value: z.number(), + condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), + }) + .optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.array(CodenameReferenceSchema), + }), + }) + .optional(), +}) satisfies z.ZodType; + +export const TextElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("text"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + maximum_text_length: z + .strictObject({ value: z.number(), applies_to: z.union([z.literal("words"), z.literal("characters")]) }) + .optional(), + default: z.strictObject({ global: z.strictObject({ value: z.string() }) }).optional(), + validation_regex: z + .strictObject({ + is_active: z.boolean(), + regex: z.string(), + flags: z.string().nullable().optional(), + validation_message: z.string().optional(), + }).optional(), +}) satisfies z.ZodType; + +export const UrlSlugElementDataSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + type: z.literal("url_slug"), + depends_on: z.strictObject({ + element: CodenameReferenceSchema, + snippet: CodenameReferenceSchema.optional(), + }), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + validation_regex: z + .strictObject({ + is_active: z.boolean(), + regex: z.string(), + flags: z.string().optional(), + validation_message: z.string().optional(), + }) + .optional(), +}) satisfies z.ZodType; + +export const SnippetElementsSchemas = [ + AssetElementDataSchema, + CustomElementDataSchema, + DateTimeElementDataSchema, + GuidelinesElementDataSchema, + LinkedItemsElementDataSchema, + NumberElementDataSchema, + MultipleChoiceElementDataSchema, + RichTextElementDataSchema, + TaxonomyElementDataSchema, + TextElementDataSchema, +] as const; + +const extendTuples = , Tuple2 extends ReadonlyArray>( + t1: Tuple1, + t2: Tuple2, +): CombineTuples => [...t1, ...t2] as unknown as CombineTuples; + +const TypeElementSchemas = extendTuples( + SnippetElementsSchemas, + [SnippetElementDataSchema, SubpagesElementDataSchema, UrlSlugElementDataSchema] as const, +); + +export const TypeElementSchemasWithGroups = TypeElementSchemas + .map(schema => + schema.extend({ content_group: z.strictObject({ codename: z.string() }) }) + ) as unknown as AddPropToObjectTuple< + typeof TypeElementSchemas, + typeof ContentGroupSchema + >; + +const ContentGroupSchema = z.strictObject({ + content_group: z.strictObject({ codename: z.string() }), +}); + +export const SnippetElementsSchemasUnion = z.discriminatedUnion("type", [...SnippetElementsSchemas]); +export const TypeElementsSchemasUnion = z.discriminatedUnion("type", [...TypeElementSchemas]); +export const TypeElementWithGroupSchemasUnion = z.discriminatedUnion("type", [...TypeElementSchemasWithGroups]); diff --git a/src/modules/sync/validation/entitySchema.ts b/src/modules/sync/validation/entitySchema.ts new file mode 100644 index 00000000..8864ac03 --- /dev/null +++ b/src/modules/sync/validation/entitySchema.ts @@ -0,0 +1,112 @@ +import { ContentTypeContracts } from "@kontent-ai/management-sdk"; +import { z } from "zod"; + +import { omit } from "../../../utils/object.js"; +import { Replace } from "../../../utils/types.js"; +import { + AssetFolderSyncModel, + CollectionSyncModel, + ContentTypeSnippetsSyncModel, + ContentTypeSyncModel, + LanguageSyncModel, + SpaceSyncModel, + TaxonomySyncModel, + WebSpotlightSyncModel, +} from "../types/syncModel.js"; +import { CodenameReferenceSchema } from "./commonSchemas.js"; +import { + SnippetElementsSchemasUnion, + TypeElementsSchemasUnion, + TypeElementWithGroupSchemasUnion, +} from "./elementSchemas.js"; + +export const AssetFolderSchema: z.ZodType = z.strictObject({ + name: z.string(), + folders: z.lazy(() => AssetFolderSchema.array()), + codename: z.string(), +}); + +export const TaxonomySchema: z.ZodType = z.strictObject({ + name: z.string(), + codename: z.string(), + terms: z.lazy(() => TaxonomySchema.array()), +}); + +export const SnippetSchema: z.ZodType = z.strictObject({ + name: z.string(), + codename: z.string(), + elements: z.array(SnippetElementsSchemasUnion), +}); + +const ContentGroupSchema = z.strictObject({ codename: z.string(), name: z.string() }); + +export const TypeSchema: z.ZodType< + ContentTypeSyncModel, + z.ZodTypeDef, + { content_groups: ReadonlyArray> } +> = z + .strictObject({ content_groups: z.array(ContentGroupSchema) }) + .passthrough() + .transform(obj => ({ ...obj, groups_number: obj.content_groups.length === 0 ? "zero" : "multiple" })) + .pipe( + z.discriminatedUnion("groups_number", [ + z.strictObject({ + name: z.string(), + codename: z.string(), + groups_number: z.literal("zero"), + content_groups: z.array(ContentGroupSchema).length(0), + elements: z.array(TypeElementsSchemasUnion), + }), + z.strictObject({ + name: z.string(), + codename: z.string(), + groups_number: z.literal("multiple"), + content_groups: z.array(ContentGroupSchema).min(1), + elements: z.array(TypeElementWithGroupSchemasUnion), + }), + ]) + .refine(a => + a.groups_number === "zero" + ? true + : a.elements.every(e => a.content_groups.map(c => c.codename).includes(e.content_group.codename)), { + message: "Content group codename must be one of the type's content group codename", + }), + ) + .transform(obj => omit(obj, ["groups_number"])); + +export const WebSpotlightSchema: z.ZodType = z.strictObject({ + enabled: z.boolean(), + root_type: CodenameReferenceSchema.nullable(), +}); + +export const CollectionSchema: z.ZodType = z.strictObject({ + name: z.string(), + codename: z.string(), +}); + +export const SpaceSchema: z.ZodType = z.strictObject({ + name: z.string(), + codename: z.string(), + web_spotlight_root_item: CodenameReferenceSchema.optional(), + collections: z.array(CodenameReferenceSchema), +}); + +export const LanguageSchema: z.ZodType = z.discriminatedUnion("is_default", [ + z.strictObject( + { + name: z.string(), + codename: z.string(), + is_active: z.boolean(), + is_default: z.literal(true), + }, + ), + z.strictObject( + { + name: z.string(), + codename: z.string(), + is_active: z.boolean(), + is_default: z.literal(false), + fallback_language: CodenameReferenceSchema, + }, + ), +]); diff --git a/src/modules/sync/validation/syncSchemas.ts b/src/modules/sync/validation/syncSchemas.ts new file mode 100644 index 00000000..f6cee942 --- /dev/null +++ b/src/modules/sync/validation/syncSchemas.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { + AssetFolderSchema, + CollectionSchema, + LanguageSchema, + SnippetSchema, + SpaceSchema, + TaxonomySchema, + TypeSchema, + WebSpotlightSchema, +} from "./entitySchema.js"; + +export const SyncTypesSchema = z.array(TypeSchema); +export const SyncSnippetsSchema = z.array(SnippetSchema); +export const SyncTaxonomySchema = z.array(TaxonomySchema); +export const SyncCollectionsSchema = z.array(CollectionSchema); +export const SyncLanguageSchema = z.array(LanguageSchema); +export const SyncAssetFolderSchema = z.array(AssetFolderSchema); +export const SyncSpacesSchema = z.array(SpaceSchema); +export const SyncWebSpotlightSchema = WebSpotlightSchema; diff --git a/src/utils/types.ts b/src/utils/types.ts index cfad1566..e281ff8a 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -67,3 +67,51 @@ export type AnyOnePropertyOf = [keyof Obj, keyof Obj] extend ? Key extends keyof Obj ? { [K in Key]: Obj[K] } & { [K in Exclude]?: undefined } : never : never; + +/** + * Combines two tuple types into a single tuple type, if both are valid tuple types. + * + * @example + * // If both Tuple1 and Tuple2 are valid tuples: + * type Result = CombineTuples<[1, 2], [3, 4]>; + * // Result: [1, 2, 3, 4] + * + * @example + * // If either Tuple1 or Tuple2 is not a valid tuple: + * type Invalid = CombineTuples<[], ReadonlyArray>; + * // Result: "never" + * + * @description + * - If `Tuple1` or `Tuple2` extends `ReadonlyArray`, meaning they are not fixed-length tuples, + * the resulting type is `"never"`. + */ + +export type CombineTuples, Tuple2 extends ReadonlyArray> = + ReadonlyArray extends Tuple1 ? "never" : ReadonlyArray extends Tuple2 ? "never" + : [...Tuple1, ...Tuple2]; + +/** + * Adds a new property type to each object in a tuple of objects. + * + * @template Tuple - A tuple type consisting of objects. + * @template ToAdd - An object type representing the properties to be added to each object in the tuple. + * + * @example + * // If given a tuple of objects and an object to add: + * type OriginalTuple = [{ name: string }, { age: number }]; + * type AdditionalProps = { id: number }; + * type Result = AddPropToObjectTuple; + * // Result: [{ id: number; name: string }, { id: number; age: number }] + * + * @example + * // If the input tuple is not a tuple of objects, it results in `never`: + * type Invalid = AddPropToObjectTuple, { id: number }>; + * // Result: never + * + * @description + * - If `Tuple` extends `ReadonlyArray`, meaning it is not a fixed-length tuple of objects, + * the resulting type is `never`. + */ +export type AddPropToObjectTuple, ToAdd extends object> = + ReadonlyArray extends Tuple ? never + : { [Key in keyof Tuple]: ToAdd & Tuple[Key] }; From 7d650de3ed03d34801d6dc7c036227e4a7a51887 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Tue, 17 Sep 2024 16:13:40 +0200 Subject: [PATCH 2/9] add unit tests for sync-model validaiton schemas --- .../validation/languageSchema.test.ts | 135 +++++++++++++ .../syncModel/validation/typeSchema.test.ts | 186 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 tests/unit/syncModel/validation/languageSchema.test.ts create mode 100644 tests/unit/syncModel/validation/typeSchema.test.ts diff --git a/tests/unit/syncModel/validation/languageSchema.test.ts b/tests/unit/syncModel/validation/languageSchema.test.ts new file mode 100644 index 00000000..10bdb5d1 --- /dev/null +++ b/tests/unit/syncModel/validation/languageSchema.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "vitest"; + +import { LanguageSchema } from "../../../../src/modules/sync/validation/entitySchema.js"; + +describe("LanguageSchema Tests", () => { + test("Valid object with is_default true", () => { + const validData = { + name: "English", + codename: "en", + is_active: true, + is_default: true, + }; + + const result = LanguageSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + test("Valid object with is_default false and valid fallback_language", () => { + const validData = { + name: "French", + codename: "fr", + is_active: true, + is_default: false, + fallback_language: { + codename: "en", + }, + }; + + const result = LanguageSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + test("Invalid object with is_default true and fallback_language provided", () => { + const invalidData = { + name: "English", + codename: "en", + is_active: true, + is_default: true, + fallback_language: { + codename: "en", + }, + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object with is_default false and missing fallback_language", () => { + const invalidData = { + name: "French", + codename: "fr", + is_active: true, + is_default: false, + // Missing fallback_language + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object missing required fields", () => { + const invalidData = { + codename: "en", + is_active: true, + is_default: true, + // Missing 'name' + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object with wrong field types", () => { + const invalidData = { + name: 123, // Should be a string + codename: "en", + is_active: "true", // Should be a boolean + is_default: true, + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object with invalid fallback_language", () => { + const invalidData = { + name: "French", + codename: "fr", + is_active: true, + is_default: false, + fallback_language: { + // 'codename' is missing + code: "en", + }, + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object with extra properties in fallback_language", () => { + const validData = { + name: "French", + codename: "fr", + is_active: true, + is_default: false, + fallback_language: { + codename: "en", + extraProp: "ignored", + }, + }; + + const result = LanguageSchema.safeParse(validData); + expect(result.success).toBe(false); + }); + + test("Invalid object with null properties", () => { + const invalidData = { + name: null, + codename: null, + is_active: null, + is_default: true, + }; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); + + test("Invalid object that is empty", () => { + const invalidData = {}; + + const result = LanguageSchema.safeParse(invalidData); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/unit/syncModel/validation/typeSchema.test.ts b/tests/unit/syncModel/validation/typeSchema.test.ts new file mode 100644 index 00000000..2114ccb4 --- /dev/null +++ b/tests/unit/syncModel/validation/typeSchema.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; + +import { TypeSchema } from "../../../../src/modules/sync/validation/entitySchema.js"; + +const validElementWithoutGroup = { + name: "Text Element", + codename: "text_element", + type: "text", +}; + +const validElementWithGroup = { + ...validElementWithoutGroup, + content_group: { codename: "group1" }, +}; + +describe("TypeSchema", () => { + it("should validate successfully when content_groups is empty and elements have no content_group (groups_number \"zero\")", () => { + const input = { + name: "Type Without Groups", + codename: "type_without_groups", + content_groups: [], + elements: [validElementWithoutGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ + name: "Type Without Groups", + codename: "type_without_groups", + content_groups: [], + elements: [validElementWithoutGroup], + }); + } + }); + + it("should validate successfully when content_groups is non-empty and elements have content_group (groups_number \"multiple\")", () => { + const input = { + name: "Type With Groups", + codename: "type_with_groups", + content_groups: [{ codename: "group1", name: "Group 1" }], + elements: [validElementWithGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ + name: "Type With Groups", + codename: "type_with_groups", + content_groups: [{ codename: "group1", name: "Group 1" }], + elements: [validElementWithGroup], + }); + } + }); + + it("should fail validation when additional property is involved", () => { + const input = { + name: "Invalid Type", + codename: "invalid_type", + content_groups: [], + non_existing_property: "", + elements: [validElementWithoutGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should fail validation when additional property in element is involved", () => { + const input = { + name: "Invalid Type", + codename: "invalid_type", + content_groups: [], + elements: [{ + ...validElementWithoutGroup, + non_existing_property: "", + }], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should fail validation when content_groups is empty but elements have content_group", () => { + const input = { + name: "Invalid Type", + codename: "invalid_type", + content_groups: [], + elements: [validElementWithGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should fail validation when content_groups is non-empty but elements lack content_group", () => { + const input = { + name: "Invalid Type", + codename: "invalid_type", + content_groups: [{ codename: "group1", name: "Group 1" }], + elements: [validElementWithoutGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should fail validation when elements have content_group not defined in content_groups", () => { + const input = { + name: "Invalid Type", + codename: "invalid_type", + content_groups: [{ codename: "group1", name: "Group 1" }], + elements: [ + { + ...validElementWithGroup, + content_group: { codename: "non_existent_group" }, + }, + ], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should fail validation when elements array is missing", () => { + const input = { + name: "Type Without Elements", + codename: "type_without_elements", + content_groups: [], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should validate successfully when elements array is empty", () => { + const input = { + name: "Type With No Elements", + codename: "type_with_no_elements", + content_groups: [], + elements: [], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ + name: "Type With No Elements", + codename: "type_with_no_elements", + content_groups: [], + elements: [], + }); + } + }); + + it("should fail validation when an element has invalid type", () => { + const input = { + name: "Type With Invalid Element", + codename: "type_with_invalid_element", + content_groups: [], + elements: [ + { + name: "Invalid Element", + codename: "invalid_element", + type: "non_existent_type", + }, + ], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should validate and remove groups_number from output", () => { + const input = { + name: "Type With Groups", + codename: "type_with_groups", + content_groups: [{ codename: "group1", name: "Group 1" }], + elements: [validElementWithGroup], + }; + + const result = TypeSchema.safeParse(input); + expect(result.success).toBe(true); + }); +}); From 76516a7e8a589beffd5d8d3c436104491d4f113c Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Mon, 23 Sep 2024 13:52:42 +0200 Subject: [PATCH 3/9] add validation schemas to readContentModelFromFolder --- package-lock.json | 14 ++- package.json | 3 +- src/modules/sync/diffEnvironments.ts | 11 +- src/modules/sync/syncModelRun.ts | 11 +- src/modules/sync/utils/getContentModel.ts | 116 +++++++++++----------- src/utils/object.ts | 6 +- src/utils/types.ts | 20 ++++ 7 files changed, 118 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 546dd600..92d24b13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "open": "^10.1.0", "ts-pattern": "^5.3.1", "yargs": "^17.7.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" }, "bin": { "data-ops": "build/src/index.js" @@ -11358,6 +11359,17 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz", + "integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } } } diff --git a/package.json b/package.json index 33b6d31d..46024845 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "open": "^10.1.0", "ts-pattern": "^5.3.1", "yargs": "^17.7.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@kontent-ai/eslint-config": "^1.0.2", diff --git a/src/modules/sync/diffEnvironments.ts b/src/modules/sync/diffEnvironments.ts index fb2b26bc..9901c348 100644 --- a/src/modules/sync/diffEnvironments.ts +++ b/src/modules/sync/diffEnvironments.ts @@ -1,6 +1,6 @@ import chalk from "chalk"; -import { logInfo, LogOptions } from "../../log.js"; +import { logError, logInfo, LogOptions } from "../../log.js"; import { createClient } from "../../utils/client.js"; import { diff } from "./diff.js"; import { fetchModel, transformSyncModel } from "./generateSyncModel.js"; @@ -44,7 +44,14 @@ export const diffEnvironmentsInternal = async (params: DiffEnvironmentsParams, c ); const sourceModel = "folderName" in params && params.folderName !== undefined - ? await readContentModelFromFolder(params.folderName) + ? await readContentModelFromFolder(params.folderName).catch(e => { + if (e instanceof AggregateError) { + logError(params, `Parsing model validation errors:\n${e.errors.map(e => e.message).join("\n")}`); + process.exit(1); + } + logError(params, JSON.stringify(e, Object.getOwnPropertyNames(e))); + process.exit(1); + }) : transformSyncModel( await fetchModel( createClient({ diff --git a/src/modules/sync/syncModelRun.ts b/src/modules/sync/syncModelRun.ts index ddd49515..04a35317 100644 --- a/src/modules/sync/syncModelRun.ts +++ b/src/modules/sync/syncModelRun.ts @@ -1,6 +1,6 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; -import { LogOptions } from "../../log.js"; +import { logError, LogOptions } from "../../log.js"; import { createClient } from "../../utils/client.js"; import { diff } from "./diff.js"; import { fetchModel, transformSyncModel } from "./generateSyncModel.js"; @@ -57,7 +57,14 @@ export const getDiffModel = async ( } const sourceModel = "folderName" in params - ? await readContentModelFromFolder(params.folderName) + ? await readContentModelFromFolder(params.folderName).catch(e => { + if (e instanceof AggregateError) { + logError(params, `Parsing model validation errors:\n${e.errors.map(e => e.message).join("\n")}`); + process.exit(1); + } + logError(params, JSON.stringify(e, Object.getOwnPropertyNames(e))); + process.exit(1); + }) : transformSyncModel( await fetchModel(createClient({ environmentId: params.sourceEnvironmentId, diff --git a/src/modules/sync/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index d99c2e47..d22ce84f 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -1,8 +1,13 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; import * as fs from "fs/promises"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; import { LogOptions } from "../../../log.js"; +import { throwError } from "../../../utils/error.js"; +import { superiorFromEntries } from "../../../utils/object.js"; import { notNullOrUndefined } from "../../../utils/typeguards.js"; +import { Either } from "../../../utils/types.js"; import { assetFoldersFileName, collectionsFileName, @@ -12,71 +17,70 @@ import { spacesFileName, taxonomiesFileName, webSpotlightFileName, - workflowsFileName, } from "../constants/filename.js"; import { fetchModel, transformSyncModel } from "../generateSyncModel.js"; import { FileContentModel } from "../types/fileContentModel.js"; import { - AssetFolderSyncModel, - ContentTypeSnippetsSyncModel, - ContentTypeSyncModel, - LanguageSyncModel, - SpaceSyncModel, - TaxonomySyncModel, - WebSpotlightSyncModel, - WorkflowSyncModel, -} from "../types/syncModel.js"; + SyncAssetFolderSchema, + SyncCollectionsSchema, + SyncLanguageSchema, + SyncSnippetsSchema, + SyncSpacesSchema, + SyncTaxonomySchema, + SyncTypesSchema, + SyncWebSpotlightSchema, +} from "../validation/syncSchemas.js"; import { getRequiredCodenames } from "./contentTypeHelpers.js"; import { fetchRequiredAssetsByCodename, fetchRequiredContentItemsByCodename } from "./fetchers.js"; +type ParseWithError = Either, ParseError>; +type ParseError = { success: false; error: Error }; +type ParseResult = { success: true; result: Result }; + export const readContentModelFromFolder = async (folderName: string): Promise => { - // in future we should use typeguard to check whether the content is valid - const contentTypes = JSON.parse( - await fs.readFile(`${folderName}/${contentTypesFileName}`, "utf8"), - ) as ReadonlyArray; - - const snippets = JSON.parse( - await fs.readFile(`${folderName}/${contentTypeSnippetsFileName}`, "utf8"), - ) as ReadonlyArray; - const taxonomyGroups = JSON.parse( - await fs.readFile(`${folderName}/${taxonomiesFileName}`, "utf8"), - ) as ReadonlyArray; - - const collections = JSON.parse( - await fs.readFile(`${folderName}/${collectionsFileName}`, "utf8"), - ) as ReadonlyArray; - - const webSpotlight = JSON.parse( - await fs.readFile(`${folderName}/${webSpotlightFileName}`, "utf8"), - ) as WebSpotlightSyncModel; - - const assetFolders = JSON.parse( - await fs.readFile(`${folderName}/${assetFoldersFileName}`, "utf8").catch(() => "[]"), - ) as ReadonlyArray; - - const spaces = JSON.parse( - await fs.readFile(`${folderName}/${spacesFileName}`, "utf8").catch(() => "[]"), - ) as ReadonlyArray; - - const languages = JSON.parse( - await fs.readFile(`${folderName}/${languagesFileName}`, "utf8").catch(() => "[]"), - ) as ReadonlyArray; - - const workflows = JSON.parse( - await fs.readFile(`${folderName}/${workflowsFileName}`, "utf8").catch(() => "[]"), - ) as ReadonlyArray; + const parseReults = [ + ["contentTypes", await parseSchema(SyncTypesSchema, folderName, contentTypesFileName)], + ["contentTypeSnippets", await parseSchema(SyncSnippetsSchema, folderName, contentTypeSnippetsFileName)], + ["taxonomyGroups", await parseSchema(SyncTaxonomySchema, folderName, taxonomiesFileName)], + ["collections", await parseSchema(SyncCollectionsSchema, folderName, collectionsFileName)], + ["webSpotlight", await parseSchema(SyncWebSpotlightSchema, folderName, webSpotlightFileName)], + ["assetFolders", await parseSchema(SyncAssetFolderSchema, folderName, assetFoldersFileName)], + ["spaces", await parseSchema(SyncSpacesSchema, folderName, spacesFileName)], + ["languages", await parseSchema(SyncLanguageSchema, folderName, languagesFileName)], + ] as const; + + const isError = (a: ParseWithError): a is ParseError => !a.success; + + const isErrorEntry = ( + tuple: readonly [EntityName, ParseWithError], + ): tuple is [EntityName, ParseError] => isError(tuple[1]); + + const errors = parseReults.filter(isErrorEntry).map(([, val]) => val.error); + + if (errors.length) { + throw new AggregateError(errors); + } + + return superiorFromEntries( + parseReults.map(([key, value]) => + value.success ? [key, value.result] : throwError("Error with parsing the model from folder.") + ), + ); +}; - return { - contentTypes, - contentTypeSnippets: snippets, - taxonomyGroups: taxonomyGroups, - collections, - webSpotlight, - assetFolders, - spaces, - languages, - workflows, - }; +const parseSchema = async ( + schema: z.ZodType, + folderName: string, + filename: string, +): Promise> => { + const result = schema.safeParse(JSON.parse(await fs.readFile(`${folderName}/${filename}`, "utf8"))); + + return result.success + ? { success: true, result: result.data } + : { + success: false, + error: new Error(fromError(result.error, { unionSeparator: " or\n", prefix: filename }).message), + }; }; type AssetItemsCodenames = Readonly<{ diff --git a/src/utils/object.ts b/src/utils/object.ts index 37486c15..692c202f 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -1,4 +1,4 @@ -import { SuperiorOmit } from "./types.js"; +import { ObjectFromTuple, SuperiorOmit } from "./types.js"; export const omit = (obj: T, props: K[]): SuperiorOmit => Object.fromEntries(Object.entries(obj).filter(([key]) => !props.includes(key as K))) as SuperiorOmit; @@ -22,3 +22,7 @@ type ObjectPerKey = Key extends any ? { [k in Key]: V export const makeObjectWithKey = (key: Key, value: Value): ObjectPerKey => ({ [key]: value }) as ObjectPerKey; + +export const superiorFromEntries = ( + entries: InputType, +): ObjectFromTuple => Object.fromEntries(entries) as ObjectFromTuple; diff --git a/src/utils/types.ts b/src/utils/types.ts index e281ff8a..319ec274 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -115,3 +115,23 @@ export type CombineTuples, Tuple2 extends export type AddPropToObjectTuple, ToAdd extends object> = ReadonlyArray extends Tuple ? never : { [Key in keyof Tuple]: ToAdd & Tuple[Key] }; + +export type Either = Left | Right; + +/** + * Maps a tuple of key-value pairs to an object type. + * + * @example + * // For the following tuple type: + * type Tuple = [['name', string], ['age', number]]; + * + * // The resulting type will be: + * type Result = ObjectFromTuple; + * // { + * // name: string; + * // age: number; + * // } + */ +export type ObjectFromTuple = { + [K in T[number][0]]: Extract[1]; +}; From 5baa8cef7a780638c716a49c121020692743dc55 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Wed, 25 Sep 2024 08:23:04 +0200 Subject: [PATCH 4/9] fixup workflow parse schemas --- src/modules/sync/validation/entitySchema.ts | 52 +++++++++++++++++++++ src/modules/sync/validation/syncSchemas.ts | 2 + 2 files changed, 54 insertions(+) diff --git a/src/modules/sync/validation/entitySchema.ts b/src/modules/sync/validation/entitySchema.ts index 8864ac03..f6a443b1 100644 --- a/src/modules/sync/validation/entitySchema.ts +++ b/src/modules/sync/validation/entitySchema.ts @@ -110,3 +110,55 @@ export const LanguageSchema: z.ZodType = z.discriminatedUnion }, ), ]); + +const WorkflowColorSchema = z.union([ + z.literal("gray"), + z.literal("red"), + z.literal("rose"), + z.literal("light-purple"), + z.literal("dark-purple"), + z.literal("dark-blue"), + z.literal("light-blue"), + z.literal("sky-blue"), + z.literal("mint-green"), + z.literal("persian-green"), + z.literal("dark-green"), + z.literal("light-green"), + z.literal("yellow"), + z.literal("pink"), + z.literal("orange"), + z.literal("brown"), +]); + +const WorkflowPublishedStepSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + create_new_version_role_ids: z.array(z.string()).length(0), + unpublish_role_ids: z.array(z.string()).length(0), +}); + +const WorkflowArchivedStepSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + role_ids: z.array(z.string()).length(0), +}); + +const WorkflowStepSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + color: WorkflowColorSchema, + transitions_to: z.array(z.strictObject({ step: CodenameReferenceSchema })), + role_ids: z.array(z.string()).length(0), +}); + +export const WorkflowSchema = z.strictObject({ + name: z.string(), + codename: z.string(), + scopes: z.array(z.strictObject({ + content_types: z.array(CodenameReferenceSchema), + collections: z.array(CodenameReferenceSchema), + })), + steps: z.array(WorkflowStepSchema), + published_step: WorkflowPublishedStepSchema, + archived_step: WorkflowArchivedStepSchema, +}); diff --git a/src/modules/sync/validation/syncSchemas.ts b/src/modules/sync/validation/syncSchemas.ts index f6cee942..2bf31e4b 100644 --- a/src/modules/sync/validation/syncSchemas.ts +++ b/src/modules/sync/validation/syncSchemas.ts @@ -9,6 +9,7 @@ import { TaxonomySchema, TypeSchema, WebSpotlightSchema, + WorkflowSchema, } from "./entitySchema.js"; export const SyncTypesSchema = z.array(TypeSchema); @@ -19,3 +20,4 @@ export const SyncLanguageSchema = z.array(LanguageSchema); export const SyncAssetFolderSchema = z.array(AssetFolderSchema); export const SyncSpacesSchema = z.array(SpaceSchema); export const SyncWebSpotlightSchema = WebSpotlightSchema; +export const SyncWorkflowSchema = z.array(WorkflowSchema); From 8858cc27490b5006f2a1887d2b2d4b1c1977d37a Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Wed, 25 Sep 2024 08:23:39 +0200 Subject: [PATCH 5/9] fixup add workflows validation --- src/modules/sync/utils/getContentModel.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/sync/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index d22ce84f..8ce1f171 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -17,6 +17,7 @@ import { spacesFileName, taxonomiesFileName, webSpotlightFileName, + workflowsFileName, } from "../constants/filename.js"; import { fetchModel, transformSyncModel } from "../generateSyncModel.js"; import { FileContentModel } from "../types/fileContentModel.js"; @@ -29,6 +30,7 @@ import { SyncTaxonomySchema, SyncTypesSchema, SyncWebSpotlightSchema, + SyncWorkflowSchema, } from "../validation/syncSchemas.js"; import { getRequiredCodenames } from "./contentTypeHelpers.js"; import { fetchRequiredAssetsByCodename, fetchRequiredContentItemsByCodename } from "./fetchers.js"; @@ -47,6 +49,7 @@ export const readContentModelFromFolder = async (folderName: string): Promise): a is ParseError => !a.success; From 69ea857708c12052fa67b70873ed306a89f68275 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 26 Sep 2024 16:26:22 +0200 Subject: [PATCH 6/9] fixup extract "second" function and use it --- .../importExportEntities/entities/workflows.ts | 6 +----- src/modules/sync/utils/getContentModel.ts | 17 ++++++----------- src/utils/function.ts | 5 +++++ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/modules/importExport/importExportEntities/entities/workflows.ts b/src/modules/importExport/importExportEntities/entities/workflows.ts index a8f6b92b..768ecf0f 100644 --- a/src/modules/importExport/importExportEntities/entities/workflows.ts +++ b/src/modules/importExport/importExportEntities/entities/workflows.ts @@ -4,6 +4,7 @@ import chalk from "chalk"; import { defaultCodename, defaultName, emptyId } from "../../../../constants/ids.js"; import { logInfo, LogOptions } from "../../../../log.js"; import { zip } from "../../../../utils/array.js"; +import { second } from "../../../../utils/function.js"; import { serially } from "../../../../utils/requests.js"; import { notNullOrUndefined } from "../../../../utils/typeguards.js"; import { MapValues, ReplaceReferences } from "../../../../utils/types.js"; @@ -252,8 +253,3 @@ const createDefaultWorkflowData = (wf: Workflow): WorkflowModels.IUpdateWorkflow published_step: { ...wf.published_step, codename: "published", name: "Published" }, archived_step: { ...wf.archived_step, codename: "archived", name: "Archived" }, }); - -const second = >( - guard: (value: Original) => value is Guarded, -) => -(tuple: readonly [First, Original, ...Rest]): tuple is readonly [First, Guarded, ...Rest] => guard(tuple[1]); diff --git a/src/modules/sync/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index 8ce1f171..5932131d 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -5,9 +5,9 @@ import { fromError } from "zod-validation-error"; import { LogOptions } from "../../../log.js"; import { throwError } from "../../../utils/error.js"; +import { second } from "../../../utils/function.js"; import { superiorFromEntries } from "../../../utils/object.js"; import { notNullOrUndefined } from "../../../utils/typeguards.js"; -import { Either } from "../../../utils/types.js"; import { assetFoldersFileName, collectionsFileName, @@ -35,12 +35,12 @@ import { import { getRequiredCodenames } from "./contentTypeHelpers.js"; import { fetchRequiredAssetsByCodename, fetchRequiredContentItemsByCodename } from "./fetchers.js"; -type ParseWithError = Either, ParseError>; +type ParseWithError = ParseResult | ParseError; type ParseError = { success: false; error: Error }; type ParseResult = { success: true; result: Result }; export const readContentModelFromFolder = async (folderName: string): Promise => { - const parseReults = [ + const parseResults = [ ["contentTypes", await parseSchema(SyncTypesSchema, folderName, contentTypesFileName)], ["contentTypeSnippets", await parseSchema(SyncSnippetsSchema, folderName, contentTypeSnippetsFileName)], ["taxonomyGroups", await parseSchema(SyncTaxonomySchema, folderName, taxonomiesFileName)], @@ -52,20 +52,15 @@ export const readContentModelFromFolder = async (folderName: string): Promise): a is ParseError => !a.success; - - const isErrorEntry = ( - tuple: readonly [EntityName, ParseWithError], - ): tuple is [EntityName, ParseError] => isError(tuple[1]); - - const errors = parseReults.filter(isErrorEntry).map(([, val]) => val.error); + const errors = parseResults.filter(r => second, ParseError, string, []>(x => !x.success)(r)) + .map(([, value]) => value.error); if (errors.length) { throw new AggregateError(errors); } return superiorFromEntries( - parseReults.map(([key, value]) => + parseResults.map(([key, value]) => value.success ? [key, value.result] : throwError("Error with parsing the model from folder.") ), ); diff --git a/src/utils/function.ts b/src/utils/function.ts index 28282940..d085d074 100644 --- a/src/utils/function.ts +++ b/src/utils/function.ts @@ -6,3 +6,8 @@ export const apply = ( ): Output | null | undefined => notNullOrUndefined(value) ? fnc(value) : value; export const not = (fnc: (a: T) => boolean) => (value: T): boolean => !fnc(value); + +export const second = >( + guard: (value: Original) => value is Guarded, +) => +(tuple: readonly [First, Original, ...Rest]): tuple is readonly [First, Guarded, ...Rest] => guard(tuple[1]); From 191e976c818e186e672084b09ae0cf939c3f7cfc Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 26 Sep 2024 16:27:06 +0200 Subject: [PATCH 7/9] fixup update schemas --- src/modules/sync/types/syncModel.ts | 20 +- src/modules/sync/validation/elementSchemas.ts | 552 ++++++++++-------- src/modules/sync/validation/entitySchema.ts | 199 ++++--- src/utils/types.ts | 34 +- 4 files changed, 434 insertions(+), 371 deletions(-) diff --git a/src/modules/sync/types/syncModel.ts b/src/modules/sync/types/syncModel.ts index 4ac171e1..1847b3e1 100644 --- a/src/modules/sync/types/syncModel.ts +++ b/src/modules/sync/types/syncModel.ts @@ -63,16 +63,16 @@ export type SyncSubpagesElement = & ReplaceReferences & Pick; // The property is missing in the SDK type -type SyncSnippetCustomElement = SnippetElement; -type SyncSnippetMultipleChoiceElement = SnippetElement; -type SyncSnippetAssetElement = SnippetElement; -type SyncSnippetRichTextElement = SnippetElement; -type SyncSnippetTaxonomyElement = SnippetElement; -type SyncSnippetLinkedItemsElement = SnippetElement; -type SyncSnippetGuidelinesElement = SnippetElement; -type SyncSnippetTextElement = SnippetElement; -type SyncSnippetDateTimeElement = SnippetElement; -type SyncSnippetNumberElement = SnippetElement; +export type SyncSnippetCustomElement = SnippetElement; +export type SyncSnippetMultipleChoiceElement = SnippetElement; +export type SyncSnippetAssetElement = SnippetElement; +export type SyncSnippetRichTextElement = SnippetElement; +export type SyncSnippetTaxonomyElement = SnippetElement; +export type SyncSnippetLinkedItemsElement = SnippetElement; +export type SyncSnippetGuidelinesElement = SnippetElement; +export type SyncSnippetTextElement = SnippetElement; +export type SyncSnippetDateTimeElement = SnippetElement; +export type SyncSnippetNumberElement = SnippetElement; export type SyncSnippetElement = | SyncSnippetCustomElement diff --git a/src/modules/sync/validation/elementSchemas.ts b/src/modules/sync/validation/elementSchemas.ts index d1091c88..03949efe 100644 --- a/src/modules/sync/validation/elementSchemas.ts +++ b/src/modules/sync/validation/elementSchemas.ts @@ -1,155 +1,168 @@ +import { ContentTypeElements } from "@kontent-ai/management-sdk"; import { z } from "zod"; -import { AddPropToObjectTuple, CombineTuples } from "../../../utils/types.js"; +import { AddPropToObjectTuple, IsFullEnum, IsSubset } from "../../../utils/types.js"; import { - SyncAssetElement, - SyncCustomElement, - SyncDateTimeElement, - SyncGuidelinesElement, - SyncLinkedItemsElement, - SyncMultipleChoiceElement, - SyncNumberElement, - SyncRichTextElement, + SyncSnippetAssetElement, + SyncSnippetCustomElement, + SyncSnippetDateTimeElement, + SyncSnippetGuidelinesElement, + SyncSnippetLinkedItemsElement, + SyncSnippetMultipleChoiceElement, + SyncSnippetNumberElement, + SyncSnippetRichTextElement, + SyncSnippetTaxonomyElement, + SyncSnippetTextElement, SyncSubpagesElement, - SyncTaxonomyElement, - SyncTextElement, SyncTypeSnippetElement, SyncUrlSlugElement, } from "../types/syncModel.js"; import { CodenameReferenceSchema } from "./commonSchemas.js"; -export const AssetElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("asset"), - guidelines: z.string().optional(), - asset_count_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - maximum_file_size: z.number().optional(), - allowed_file_types: z.union([z.literal("adjustable"), z.literal("any")]).optional(), - image_width_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - image_height_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - default: z - .strictObject({ - global: z.strictObject({ - value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })), - }), - }) - .optional(), -}) satisfies z.ZodType; +type RequiredZodObject = { [K in keyof T]-?: z.ZodType }; -export const CustomElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("custom"), - source_url: z.string(), - json_parameters: z.string().optional(), - guidelines: z.string().optional(), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - allowed_elements: z.array(CodenameReferenceSchema).optional(), -}) satisfies z.ZodType; +export const AssetElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("asset"), + guidelines: z.string().optional(), + asset_count_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + maximum_file_size: z.number().optional(), + allowed_file_types: z.enum(["adjustable", "any"]).optional(), + image_width_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + image_height_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })), + }), + }) + .optional(), + } satisfies RequiredZodObject, +); -export const DateTimeElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("date_time"), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - default: z - .strictObject({ - global: z.strictObject({ - value: z.string(), - }), - }) - .optional(), -}) satisfies z.ZodType; +export const CustomElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("custom"), + source_url: z.string(), + json_parameters: z.string().optional(), + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + allowed_elements: z.array(CodenameReferenceSchema).optional(), + } satisfies RequiredZodObject, +); -export const GuidelinesElementDataSchema = z.strictObject({ - codename: z.string(), - guidelines: z.string(), - type: z.literal("guidelines"), -}) satisfies z.ZodType; +export const DateTimeElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("date_time"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.string(), + }), + }) + .optional(), + } satisfies RequiredZodObject, +); -export const LinkedItemsElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - item_count_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - allowed_content_types: z.array(CodenameReferenceSchema).optional(), - guidelines: z.string().optional(), - type: z.literal("modular_content"), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - default: z.strictObject({ - global: z.strictObject({ value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })) }), - }).optional(), -}) satisfies z.ZodType; +export const GuidelinesElementDataSchema = z.strictObject( + { + codename: z.string(), + guidelines: z.string(), + type: z.literal("guidelines"), + } satisfies RequiredZodObject, +); + +export const LinkedItemsElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + item_count_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + guidelines: z.string().optional(), + type: z.literal("modular_content"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + default: z.strictObject({ + global: z.strictObject({ value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })) }), + }).optional(), + } satisfies RequiredZodObject, +); const MultipleChoiceOptionSchema = z.strictObject({ name: z.string(), codename: z.string(), }); -export const MultipleChoiceElementDataSchema = z.strictObject({ - mode: z.union([z.literal("single"), z.literal("multiple")]), - options: z.array(MultipleChoiceOptionSchema), - codename: z.string(), - name: z.string(), - type: z.literal("multiple_choice"), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - default: z.strictObject({ - global: z.strictObject({ - value: z.array(CodenameReferenceSchema), - }), - }).optional(), -}) satisfies z.ZodType; - -export const NumberElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("number"), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - default: z - .strictObject({ +export const MultipleChoiceElementDataSchema = z.strictObject( + { + mode: z.enum(["single", "multiple"]), + options: z.array(MultipleChoiceOptionSchema), + codename: z.string(), + name: z.string(), + type: z.literal("multiple_choice"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z.strictObject({ global: z.strictObject({ - value: z.number(), + value: z.array(CodenameReferenceSchema), }), - }) - .optional(), -}) satisfies z.ZodType; + }).optional(), + } satisfies RequiredZodObject, +); + +export const NumberElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("number"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.number(), + }), + }) + .optional(), + } satisfies RequiredZodObject, +); -const RichTextAllowedBlockSchema = z.enum(["images", "text", "tables", "components-and-items"]); -const RichTextAllowedTableBlockSchema = z.enum(["images", "text"]); -const RichTextImageConditionSchema = z.enum(["at_most", "exactly", "at_least"]); -const RichTextAllowedImageTypeSchema = z.enum(["adjustable", "any"]); -const RichTextMaximumLengthAppliesToSchema = z.enum(["words", "characters"]); -const RichTextAllowedTextBlockSchema = z.enum([ +const RichTextImageConditionTuple = ["at_most", "exactly", "at_least"] as const; +const RichTextAllowedTextBlock = [ "paragraph", "heading-one", "heading-two", @@ -159,8 +172,9 @@ const RichTextAllowedTextBlockSchema = z.enum([ "heading-six", "ordered-list", "unordered-list", -]); -const RichTextAllowedFormattingSchema = z.enum([ +] as const; + +const RichTextAllowedFormatting = [ "unstyled", "bold", "italic", @@ -168,120 +182,159 @@ const RichTextAllowedFormattingSchema = z.enum([ "link", "subscript", "superscript", -]); +] as const; -export const RichTextElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("rich_text"), - maximum_text_length: z - .strictObject({ value: z.number(), applies_to: RichTextMaximumLengthAppliesToSchema }).optional(), - maximum_image_size: z.number().optional(), - allowed_content_types: z.array(CodenameReferenceSchema).optional(), - allowed_item_link_types: z.array(CodenameReferenceSchema).optional(), - image_width_limit: z - .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) - .optional(), - image_height_limit: z - .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) - .optional(), - allowed_image_types: RichTextAllowedImageTypeSchema.optional(), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - allowed_blocks: z.array(RichTextAllowedBlockSchema).optional(), - allowed_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), - allowed_formatting: z.array(RichTextAllowedFormattingSchema).optional(), - allowed_table_blocks: z.array(RichTextAllowedTableBlockSchema).optional(), - allowed_table_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), - allowed_table_formatting: z.array(RichTextAllowedFormattingSchema).optional(), -}) satisfies z.ZodType; +const RichTextImageConditionSchema = z.enum( + RichTextImageConditionTuple satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); -export const SubpagesElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("subpages"), - item_count_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - allowed_content_types: z.array(CodenameReferenceSchema).optional(), - guidelines: z.string().optional(), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), -}) satisfies z.ZodType; +const RichTextAllowedTextBlockSchema = z.enum( + RichTextAllowedTextBlock satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); -export const SnippetElementDataSchema = z.strictObject({ - codename: z.string(), - type: z.literal("snippet"), - snippet: CodenameReferenceSchema, -}) satisfies z.ZodType; +const RichTextAllowedFormattingSchema = z.enum( + RichTextAllowedFormatting satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); -export const TaxonomyElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("taxonomy"), - taxonomy_group: CodenameReferenceSchema, - guidelines: z.string().optional(), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - term_count_limit: z - .strictObject({ - value: z.number(), - condition: z.union([z.literal("at_most"), z.literal("exactly"), z.literal("at_least")]), - }) - .optional(), - default: z - .strictObject({ - global: z.strictObject({ - value: z.array(CodenameReferenceSchema), - }), - }) - .optional(), -}) satisfies z.ZodType; +export const RichTextElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("rich_text"), + maximum_text_length: z + .strictObject({ value: z.number(), applies_to: z.enum(["words", "characters"]) }).optional(), + maximum_image_size: z.number().optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + allowed_item_link_types: z.array(CodenameReferenceSchema).optional(), + image_width_limit: z + .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) + .optional(), + image_height_limit: z + .strictObject({ value: z.number(), condition: RichTextImageConditionSchema }) + .optional(), + allowed_image_types: z.enum(["adjustable", "any"]).optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + allowed_blocks: z.array(z.enum(["images", "text", "tables", "components-and-items"])).optional(), + allowed_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), + allowed_formatting: z.array(RichTextAllowedFormattingSchema).optional(), + allowed_table_blocks: z.array(z.enum(["images", "text"])).optional(), + allowed_table_text_blocks: z.array(RichTextAllowedTextBlockSchema).optional(), + allowed_table_formatting: z.array(RichTextAllowedFormattingSchema).optional(), + } satisfies RequiredZodObject, +); -export const TextElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("text"), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - maximum_text_length: z - .strictObject({ value: z.number(), applies_to: z.union([z.literal("words"), z.literal("characters")]) }) - .optional(), - default: z.strictObject({ global: z.strictObject({ value: z.string() }) }).optional(), - validation_regex: z - .strictObject({ - is_active: z.boolean(), - regex: z.string(), - flags: z.string().nullable().optional(), - validation_message: z.string().optional(), +export const SubpagesElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("subpages"), + item_count_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + allowed_content_types: z.array(CodenameReferenceSchema).optional(), + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + default: z.strictObject({ + global: z.strictObject({ value: z.array(CodenameReferenceSchema.extend({ external_id: z.string() })) }), }).optional(), -}) satisfies z.ZodType; + } satisfies RequiredZodObject>, +); -export const UrlSlugElementDataSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - type: z.literal("url_slug"), - depends_on: z.strictObject({ - element: CodenameReferenceSchema, - snippet: CodenameReferenceSchema.optional(), - }), - is_required: z.boolean().optional(), - is_non_localizable: z.boolean().optional(), - guidelines: z.string().optional(), - validation_regex: z - .strictObject({ - is_active: z.boolean(), - regex: z.string(), - flags: z.string().optional(), - validation_message: z.string().optional(), - }) - .optional(), -}) satisfies z.ZodType; +export const SnippetElementDataSchema = z.strictObject( + { + codename: z.string(), + type: z.literal("snippet"), + snippet: CodenameReferenceSchema, + } satisfies RequiredZodObject>, +); + +export const TaxonomyElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("taxonomy"), + taxonomy_group: CodenameReferenceSchema, + guidelines: z.string().optional(), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + term_count_limit: z + .strictObject({ + value: z.number(), + condition: z.enum(["at_most", "exactly", "at_least"]), + }) + .optional(), + default: z + .strictObject({ + global: z.strictObject({ + value: z.array(CodenameReferenceSchema), + }), + }) + .optional(), + } satisfies RequiredZodObject, +); + +export const TextElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("text"), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + maximum_text_length: z + .strictObject({ value: z.number(), applies_to: z.enum(["words", "characters"]) }) + .optional(), + default: z.strictObject({ global: z.strictObject({ value: z.string() }) }).optional(), + validation_regex: z + .strictObject({ + is_active: z.boolean(), + regex: z.string(), + flags: z.string().nullable().optional(), + validation_message: z.string().optional(), + }).optional(), + } satisfies RequiredZodObject, +); + +export const UrlSlugElementDataSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + type: z.literal("url_slug"), + depends_on: z.strictObject({ + element: CodenameReferenceSchema, + snippet: CodenameReferenceSchema.optional(), + }), + is_required: z.boolean().optional(), + is_non_localizable: z.boolean().optional(), + guidelines: z.string().optional(), + validation_regex: z + .strictObject({ + is_active: z.boolean(), + regex: z.string(), + flags: z.string().optional(), + validation_message: z.string().optional(), + }) + .optional(), + } satisfies RequiredZodObject>, +); export const SnippetElementsSchemas = [ AssetElementDataSchema, @@ -296,26 +349,21 @@ export const SnippetElementsSchemas = [ TextElementDataSchema, ] as const; -const extendTuples = , Tuple2 extends ReadonlyArray>( - t1: Tuple1, - t2: Tuple2, -): CombineTuples => [...t1, ...t2] as unknown as CombineTuples; - -const TypeElementSchemas = extendTuples( - SnippetElementsSchemas, - [SnippetElementDataSchema, SubpagesElementDataSchema, UrlSlugElementDataSchema] as const, -); +const TypeElementSchemas = [ + ...SnippetElementsSchemas, + SnippetElementDataSchema, + SubpagesElementDataSchema, + UrlSlugElementDataSchema, +] as const; export const TypeElementSchemasWithGroups = TypeElementSchemas - .map(schema => - schema.extend({ content_group: z.strictObject({ codename: z.string() }) }) - ) as unknown as AddPropToObjectTuple< + .map(schema => schema.extend({ content_group: CodenameReferenceSchema })) as unknown as AddPropToObjectTuple< typeof TypeElementSchemas, typeof ContentGroupSchema >; const ContentGroupSchema = z.strictObject({ - content_group: z.strictObject({ codename: z.string() }), + content_group: CodenameReferenceSchema, }); export const SnippetElementsSchemasUnion = z.discriminatedUnion("type", [...SnippetElementsSchemas]); diff --git a/src/modules/sync/validation/entitySchema.ts b/src/modules/sync/validation/entitySchema.ts index f6a443b1..14c984ad 100644 --- a/src/modules/sync/validation/entitySchema.ts +++ b/src/modules/sync/validation/entitySchema.ts @@ -1,8 +1,8 @@ -import { ContentTypeContracts } from "@kontent-ai/management-sdk"; +import { WorkflowContracts } from "@kontent-ai/management-sdk"; import { z } from "zod"; import { omit } from "../../../utils/object.js"; -import { Replace } from "../../../utils/types.js"; +import { IsFullEnum, IsSubset, RequiredZodObject } from "../../../utils/types.js"; import { AssetFolderSyncModel, CollectionSyncModel, @@ -12,6 +12,7 @@ import { SpaceSyncModel, TaxonomySyncModel, WebSpotlightSyncModel, + WorkflowSyncModel, } from "../types/syncModel.js"; import { CodenameReferenceSchema } from "./commonSchemas.js"; import { @@ -20,30 +21,36 @@ import { TypeElementWithGroupSchemasUnion, } from "./elementSchemas.js"; -export const AssetFolderSchema: z.ZodType = z.strictObject({ - name: z.string(), - folders: z.lazy(() => AssetFolderSchema.array()), - codename: z.string(), -}); +export const AssetFolderSchema: z.ZodType = z.strictObject( + { + name: z.string(), + folders: z.lazy(() => AssetFolderSchema.array()), + codename: z.string(), + } satisfies RequiredZodObject, +); -export const TaxonomySchema: z.ZodType = z.strictObject({ - name: z.string(), - codename: z.string(), - terms: z.lazy(() => TaxonomySchema.array()), -}); +export const TaxonomySchema: z.ZodType = z.strictObject( + { + name: z.string(), + codename: z.string(), + terms: z.lazy(() => TaxonomySchema.array()), + } satisfies RequiredZodObject, +); -export const SnippetSchema: z.ZodType = z.strictObject({ - name: z.string(), - codename: z.string(), - elements: z.array(SnippetElementsSchemasUnion), -}); +export const SnippetSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + elements: z.array(SnippetElementsSchemasUnion), + } satisfies RequiredZodObject, +); const ContentGroupSchema = z.strictObject({ codename: z.string(), name: z.string() }); export const TypeSchema: z.ZodType< ContentTypeSyncModel, z.ZodTypeDef, - { content_groups: ReadonlyArray> } + Pick > = z .strictObject({ content_groups: z.array(ContentGroupSchema) }) .passthrough() @@ -74,31 +81,37 @@ export const TypeSchema: z.ZodType< ) .transform(obj => omit(obj, ["groups_number"])); -export const WebSpotlightSchema: z.ZodType = z.strictObject({ - enabled: z.boolean(), - root_type: CodenameReferenceSchema.nullable(), -}); +export const WebSpotlightSchema = z.strictObject( + { + enabled: z.boolean(), + root_type: CodenameReferenceSchema.nullable(), + } satisfies RequiredZodObject, +); -export const CollectionSchema: z.ZodType = z.strictObject({ - name: z.string(), - codename: z.string(), -}); +export const CollectionSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + } satisfies RequiredZodObject, +); -export const SpaceSchema: z.ZodType = z.strictObject({ - name: z.string(), - codename: z.string(), - web_spotlight_root_item: CodenameReferenceSchema.optional(), - collections: z.array(CodenameReferenceSchema), -}); +export const SpaceSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + web_spotlight_root_item: CodenameReferenceSchema.optional(), + collections: z.array(CodenameReferenceSchema), + } satisfies RequiredZodObject, +); -export const LanguageSchema: z.ZodType = z.discriminatedUnion("is_default", [ +export const LanguageSchema = z.discriminatedUnion("is_default", [ z.strictObject( { name: z.string(), codename: z.string(), is_active: z.boolean(), is_default: z.literal(true), - }, + } satisfies RequiredZodObject>, ), z.strictObject( { @@ -107,58 +120,74 @@ export const LanguageSchema: z.ZodType = z.discriminatedUnion is_active: z.boolean(), is_default: z.literal(false), fallback_language: CodenameReferenceSchema, - }, + } satisfies RequiredZodObject, ), ]); -const WorkflowColorSchema = z.union([ - z.literal("gray"), - z.literal("red"), - z.literal("rose"), - z.literal("light-purple"), - z.literal("dark-purple"), - z.literal("dark-blue"), - z.literal("light-blue"), - z.literal("sky-blue"), - z.literal("mint-green"), - z.literal("persian-green"), - z.literal("dark-green"), - z.literal("light-green"), - z.literal("yellow"), - z.literal("pink"), - z.literal("orange"), - z.literal("brown"), -]); +const colors = [ + "gray", + "red", + "rose", + "light-purple", + "dark-purple", + "dark-blue", + "light-blue", + "sky-blue", + "mint-green", + "persian-green", + "dark-green", + "light-green", + "yellow", + "orange", + "brown", + "pink", +] as const; -const WorkflowPublishedStepSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - create_new_version_role_ids: z.array(z.string()).length(0), - unpublish_role_ids: z.array(z.string()).length(0), -}); - -const WorkflowArchivedStepSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - role_ids: z.array(z.string()).length(0), -}); - -const WorkflowStepSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - color: WorkflowColorSchema, - transitions_to: z.array(z.strictObject({ step: CodenameReferenceSchema })), - role_ids: z.array(z.string()).length(0), -}); - -export const WorkflowSchema = z.strictObject({ - name: z.string(), - codename: z.string(), - scopes: z.array(z.strictObject({ - content_types: z.array(CodenameReferenceSchema), - collections: z.array(CodenameReferenceSchema), - })), - steps: z.array(WorkflowStepSchema), - published_step: WorkflowPublishedStepSchema, - archived_step: WorkflowArchivedStepSchema, -}); +const WorkflowColorSchema = z.enum( + colors satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); + +const WorkflowPublishedStepSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + create_new_version_role_ids: z.array(z.string()).length(0), + unpublish_role_ids: z.array(z.string()).length(0), + } satisfies RequiredZodObject>, +); + +const WorkflowArchivedStepSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + role_ids: z.array(z.string()).length(0), + } satisfies RequiredZodObject>, +); + +const WorkflowStepSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + color: WorkflowColorSchema, + transitions_to: z.array(z.strictObject({ step: CodenameReferenceSchema })), + role_ids: z.array(z.string()).length(0), + } satisfies RequiredZodObject>, +); + +export const WorkflowSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + scopes: z.array(z.strictObject({ + content_types: z.array(CodenameReferenceSchema), + collections: z.array(CodenameReferenceSchema), + })), + steps: z.array(WorkflowStepSchema), + published_step: WorkflowPublishedStepSchema, + archived_step: WorkflowArchivedStepSchema, + } satisfies RequiredZodObject, +); diff --git a/src/utils/types.ts b/src/utils/types.ts index 319ec274..bf5e9e91 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,4 +1,5 @@ import { SharedContracts } from "@kontent-ai/management-sdk"; +import { z } from "zod"; export type MapValues> = Map extends ReadonlyMap ? Res : never; @@ -68,28 +69,6 @@ export type AnyOnePropertyOf = [keyof Obj, keyof Obj] extend : never : never; -/** - * Combines two tuple types into a single tuple type, if both are valid tuple types. - * - * @example - * // If both Tuple1 and Tuple2 are valid tuples: - * type Result = CombineTuples<[1, 2], [3, 4]>; - * // Result: [1, 2, 3, 4] - * - * @example - * // If either Tuple1 or Tuple2 is not a valid tuple: - * type Invalid = CombineTuples<[], ReadonlyArray>; - * // Result: "never" - * - * @description - * - If `Tuple1` or `Tuple2` extends `ReadonlyArray`, meaning they are not fixed-length tuples, - * the resulting type is `"never"`. - */ - -export type CombineTuples, Tuple2 extends ReadonlyArray> = - ReadonlyArray extends Tuple1 ? "never" : ReadonlyArray extends Tuple2 ? "never" - : [...Tuple1, ...Tuple2]; - /** * Adds a new property type to each object in a tuple of objects. * @@ -116,8 +95,6 @@ export type AddPropToObjectTuple, ToAdd exte ReadonlyArray extends Tuple ? never : { [Key in keyof Tuple]: ToAdd & Tuple[Key] }; -export type Either = Left | Right; - /** * Maps a tuple of key-value pairs to an object type. * @@ -135,3 +112,12 @@ export type Either = Left | Right; export type ObjectFromTuple = { [K in T[number][0]]: Extract[1]; }; + +export type IsSubset = B; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type IsFullEnum = + ReadonlyArray< + unknown + >; + +export type RequiredZodObject = { [K in keyof T]-?: z.ZodType }; From dd9357895473e7c583a0a69ddb60c5bfc998d59d Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Thu, 26 Sep 2024 16:28:37 +0200 Subject: [PATCH 8/9] fixup tests --- .../validation/languageSchema.test.ts | 96 +------------------ .../syncModel/validation/typeSchema.test.ts | 76 --------------- 2 files changed, 4 insertions(+), 168 deletions(-) diff --git a/tests/unit/syncModel/validation/languageSchema.test.ts b/tests/unit/syncModel/validation/languageSchema.test.ts index 10bdb5d1..0fc398ff 100644 --- a/tests/unit/syncModel/validation/languageSchema.test.ts +++ b/tests/unit/syncModel/validation/languageSchema.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "vitest"; import { LanguageSchema } from "../../../../src/modules/sync/validation/entitySchema.js"; describe("LanguageSchema Tests", () => { - test("Valid object with is_default true", () => { + it("Valid object with is_default true", () => { const validData = { name: "English", codename: "en", @@ -15,7 +15,7 @@ describe("LanguageSchema Tests", () => { expect(result.success).toBe(true); }); - test("Valid object with is_default false and valid fallback_language", () => { + it("Valid object with is_default false and valid fallback_language", () => { const validData = { name: "French", codename: "fr", @@ -30,7 +30,7 @@ describe("LanguageSchema Tests", () => { expect(result.success).toBe(true); }); - test("Invalid object with is_default true and fallback_language provided", () => { + it("Invalid object with is_default true and fallback_language provided", () => { const invalidData = { name: "English", codename: "en", @@ -44,92 +44,4 @@ describe("LanguageSchema Tests", () => { const result = LanguageSchema.safeParse(invalidData); expect(result.success).toBe(false); }); - - test("Invalid object with is_default false and missing fallback_language", () => { - const invalidData = { - name: "French", - codename: "fr", - is_active: true, - is_default: false, - // Missing fallback_language - }; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - test("Invalid object missing required fields", () => { - const invalidData = { - codename: "en", - is_active: true, - is_default: true, - // Missing 'name' - }; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - test("Invalid object with wrong field types", () => { - const invalidData = { - name: 123, // Should be a string - codename: "en", - is_active: "true", // Should be a boolean - is_default: true, - }; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - test("Invalid object with invalid fallback_language", () => { - const invalidData = { - name: "French", - codename: "fr", - is_active: true, - is_default: false, - fallback_language: { - // 'codename' is missing - code: "en", - }, - }; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - test("Invalid object with extra properties in fallback_language", () => { - const validData = { - name: "French", - codename: "fr", - is_active: true, - is_default: false, - fallback_language: { - codename: "en", - extraProp: "ignored", - }, - }; - - const result = LanguageSchema.safeParse(validData); - expect(result.success).toBe(false); - }); - - test("Invalid object with null properties", () => { - const invalidData = { - name: null, - codename: null, - is_active: null, - is_default: true, - }; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); - - test("Invalid object that is empty", () => { - const invalidData = {}; - - const result = LanguageSchema.safeParse(invalidData); - expect(result.success).toBe(false); - }); }); diff --git a/tests/unit/syncModel/validation/typeSchema.test.ts b/tests/unit/syncModel/validation/typeSchema.test.ts index 2114ccb4..f7ce7a36 100644 --- a/tests/unit/syncModel/validation/typeSchema.test.ts +++ b/tests/unit/syncModel/validation/typeSchema.test.ts @@ -67,21 +67,6 @@ describe("TypeSchema", () => { expect(result.success).toBe(false); }); - it("should fail validation when additional property in element is involved", () => { - const input = { - name: "Invalid Type", - codename: "invalid_type", - content_groups: [], - elements: [{ - ...validElementWithoutGroup, - non_existing_property: "", - }], - }; - - const result = TypeSchema.safeParse(input); - expect(result.success).toBe(false); - }); - it("should fail validation when content_groups is empty but elements have content_group", () => { const input = { name: "Invalid Type", @@ -122,65 +107,4 @@ describe("TypeSchema", () => { const result = TypeSchema.safeParse(input); expect(result.success).toBe(false); }); - - it("should fail validation when elements array is missing", () => { - const input = { - name: "Type Without Elements", - codename: "type_without_elements", - content_groups: [], - }; - - const result = TypeSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("should validate successfully when elements array is empty", () => { - const input = { - name: "Type With No Elements", - codename: "type_with_no_elements", - content_groups: [], - elements: [], - }; - - const result = TypeSchema.safeParse(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toMatchObject({ - name: "Type With No Elements", - codename: "type_with_no_elements", - content_groups: [], - elements: [], - }); - } - }); - - it("should fail validation when an element has invalid type", () => { - const input = { - name: "Type With Invalid Element", - codename: "type_with_invalid_element", - content_groups: [], - elements: [ - { - name: "Invalid Element", - codename: "invalid_element", - type: "non_existent_type", - }, - ], - }; - - const result = TypeSchema.safeParse(input); - expect(result.success).toBe(false); - }); - - it("should validate and remove groups_number from output", () => { - const input = { - name: "Type With Groups", - codename: "type_with_groups", - content_groups: [{ codename: "group1", name: "Group 1" }], - elements: [validElementWithGroup], - }; - - const result = TypeSchema.safeParse(input); - expect(result.success).toBe(true); - }); }); From ca3c740e256909dae16f09917af1fa2a089a93a8 Mon Sep 17 00:00:00 2001 From: Ivan Kiral Date: Tue, 1 Oct 2024 10:45:16 +0200 Subject: [PATCH 9/9] fixup remove duplicate requiredZodObject and use merge instead of extend --- src/modules/sync/validation/elementSchemas.ts | 14 ++++++-------- src/utils/types.ts | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/modules/sync/validation/elementSchemas.ts b/src/modules/sync/validation/elementSchemas.ts index 03949efe..ffbf724a 100644 --- a/src/modules/sync/validation/elementSchemas.ts +++ b/src/modules/sync/validation/elementSchemas.ts @@ -1,7 +1,7 @@ import { ContentTypeElements } from "@kontent-ai/management-sdk"; import { z } from "zod"; -import { AddPropToObjectTuple, IsFullEnum, IsSubset } from "../../../utils/types.js"; +import { AddPropToObjectTuple, IsFullEnum, IsSubset, RequiredZodObject } from "../../../utils/types.js"; import { SyncSnippetAssetElement, SyncSnippetCustomElement, @@ -19,8 +19,6 @@ import { } from "../types/syncModel.js"; import { CodenameReferenceSchema } from "./commonSchemas.js"; -type RequiredZodObject = { [K in keyof T]-?: z.ZodType }; - export const AssetElementDataSchema = z.strictObject( { name: z.string(), @@ -356,16 +354,16 @@ const TypeElementSchemas = [ UrlSlugElementDataSchema, ] as const; +const ContentGroupSchema = z.strictObject({ + content_group: CodenameReferenceSchema, +}); + export const TypeElementSchemasWithGroups = TypeElementSchemas - .map(schema => schema.extend({ content_group: CodenameReferenceSchema })) as unknown as AddPropToObjectTuple< + .map(schema => schema.merge(ContentGroupSchema)) as unknown as AddPropToObjectTuple< typeof TypeElementSchemas, typeof ContentGroupSchema >; -const ContentGroupSchema = z.strictObject({ - content_group: CodenameReferenceSchema, -}); - export const SnippetElementsSchemasUnion = z.discriminatedUnion("type", [...SnippetElementsSchemas]); export const TypeElementsSchemasUnion = z.discriminatedUnion("type", [...TypeElementSchemas]); export const TypeElementWithGroupSchemasUnion = z.discriminatedUnion("type", [...TypeElementSchemasWithGroups]); diff --git a/src/utils/types.ts b/src/utils/types.ts index bf5e9e91..91316fab 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -120,4 +120,4 @@ export type IsFullEnum; -export type RequiredZodObject = { [K in keyof T]-?: z.ZodType }; +export type RequiredZodObject = Readonly<{ [K in keyof T]-?: z.ZodType }>;