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/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/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/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/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index d99c2e47..5932131d 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -1,7 +1,12 @@ 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 { second } from "../../../utils/function.js"; +import { superiorFromEntries } from "../../../utils/object.js"; import { notNullOrUndefined } from "../../../utils/typeguards.js"; import { assetFoldersFileName, @@ -17,66 +22,63 @@ import { 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, + SyncWorkflowSchema, +} from "../validation/syncSchemas.js"; import { getRequiredCodenames } from "./contentTypeHelpers.js"; import { fetchRequiredAssetsByCodename, fetchRequiredContentItemsByCodename } from "./fetchers.js"; -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; +type ParseWithError = ParseResult | ParseError; +type ParseError = { success: false; error: Error }; +type ParseResult = { success: true; result: Result }; - 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; +export const readContentModelFromFolder = async (folderName: string): Promise => { + const parseResults = [ + ["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)], + ["workflows", await parseSchema(SyncWorkflowSchema, folderName, workflowsFileName)], + ] as const; + + 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( + parseResults.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/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..ffbf724a --- /dev/null +++ b/src/modules/sync/validation/elementSchemas.ts @@ -0,0 +1,369 @@ +import { ContentTypeElements } from "@kontent-ai/management-sdk"; +import { z } from "zod"; + +import { AddPropToObjectTuple, IsFullEnum, IsSubset, RequiredZodObject } from "../../../utils/types.js"; +import { + SyncSnippetAssetElement, + SyncSnippetCustomElement, + SyncSnippetDateTimeElement, + SyncSnippetGuidelinesElement, + SyncSnippetLinkedItemsElement, + SyncSnippetMultipleChoiceElement, + SyncSnippetNumberElement, + SyncSnippetRichTextElement, + SyncSnippetTaxonomyElement, + SyncSnippetTextElement, + SyncSubpagesElement, + 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.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 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 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 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.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.array(CodenameReferenceSchema), + }), + }).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 RichTextImageConditionTuple = ["at_most", "exactly", "at_least"] as const; +const RichTextAllowedTextBlock = [ + "paragraph", + "heading-one", + "heading-two", + "heading-three", + "heading-four", + "heading-five", + "heading-six", + "ordered-list", + "unordered-list", +] as const; + +const RichTextAllowedFormatting = [ + "unstyled", + "bold", + "italic", + "code", + "link", + "subscript", + "superscript", +] as const; + +const RichTextImageConditionSchema = z.enum( + RichTextImageConditionTuple satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); + +const RichTextAllowedTextBlockSchema = z.enum( + RichTextAllowedTextBlock satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); + +const RichTextAllowedFormattingSchema = z.enum( + RichTextAllowedFormatting satisfies IsFullEnum< + IsSubset, + IsSubset, + IsSubset + >, +); + +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 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 RequiredZodObject>, +); + +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, + CustomElementDataSchema, + DateTimeElementDataSchema, + GuidelinesElementDataSchema, + LinkedItemsElementDataSchema, + NumberElementDataSchema, + MultipleChoiceElementDataSchema, + RichTextElementDataSchema, + TaxonomyElementDataSchema, + TextElementDataSchema, +] as const; + +const TypeElementSchemas = [ + ...SnippetElementsSchemas, + SnippetElementDataSchema, + SubpagesElementDataSchema, + UrlSlugElementDataSchema, +] as const; + +const ContentGroupSchema = z.strictObject({ + content_group: CodenameReferenceSchema, +}); + +export const TypeElementSchemasWithGroups = TypeElementSchemas + .map(schema => schema.merge(ContentGroupSchema)) as unknown as AddPropToObjectTuple< + typeof TypeElementSchemas, + typeof ContentGroupSchema + >; + +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..14c984ad --- /dev/null +++ b/src/modules/sync/validation/entitySchema.ts @@ -0,0 +1,193 @@ +import { WorkflowContracts } from "@kontent-ai/management-sdk"; +import { z } from "zod"; + +import { omit } from "../../../utils/object.js"; +import { IsFullEnum, IsSubset, RequiredZodObject } from "../../../utils/types.js"; +import { + AssetFolderSyncModel, + CollectionSyncModel, + ContentTypeSnippetsSyncModel, + ContentTypeSyncModel, + LanguageSyncModel, + SpaceSyncModel, + TaxonomySyncModel, + WebSpotlightSyncModel, + WorkflowSyncModel, +} 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(), + } satisfies RequiredZodObject, +); + +export const TaxonomySchema: z.ZodType = z.strictObject( + { + name: z.string(), + codename: z.string(), + terms: z.lazy(() => TaxonomySchema.array()), + } satisfies RequiredZodObject, +); + +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, + Pick +> = 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.strictObject( + { + enabled: z.boolean(), + root_type: CodenameReferenceSchema.nullable(), + } satisfies RequiredZodObject, +); + +export const CollectionSchema = z.strictObject( + { + name: z.string(), + codename: z.string(), + } satisfies RequiredZodObject, +); + +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.discriminatedUnion("is_default", [ + z.strictObject( + { + name: z.string(), + codename: z.string(), + is_active: z.boolean(), + is_default: z.literal(true), + } satisfies RequiredZodObject>, + ), + z.strictObject( + { + name: z.string(), + codename: z.string(), + is_active: z.boolean(), + is_default: z.literal(false), + fallback_language: CodenameReferenceSchema, + } satisfies RequiredZodObject, + ), +]); + +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 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/modules/sync/validation/syncSchemas.ts b/src/modules/sync/validation/syncSchemas.ts new file mode 100644 index 00000000..2bf31e4b --- /dev/null +++ b/src/modules/sync/validation/syncSchemas.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { + AssetFolderSchema, + CollectionSchema, + LanguageSchema, + SnippetSchema, + SpaceSchema, + TaxonomySchema, + TypeSchema, + WebSpotlightSchema, + WorkflowSchema, +} 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; +export const SyncWorkflowSchema = z.array(WorkflowSchema); 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]); 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 cfad1566..91316fab 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; @@ -67,3 +68,56 @@ export type AnyOnePropertyOf = [keyof Obj, keyof Obj] extend ? Key extends keyof Obj ? { [K in Key]: Obj[K] } & { [K in Exclude]?: undefined } : never : never; + +/** + * 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] }; + +/** + * 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]; +}; + +export type IsSubset = B; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type IsFullEnum = + ReadonlyArray< + unknown + >; + +export type RequiredZodObject = Readonly<{ [K in keyof T]-?: z.ZodType }>; diff --git a/tests/unit/syncModel/validation/languageSchema.test.ts b/tests/unit/syncModel/validation/languageSchema.test.ts new file mode 100644 index 00000000..0fc398ff --- /dev/null +++ b/tests/unit/syncModel/validation/languageSchema.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { LanguageSchema } from "../../../../src/modules/sync/validation/entitySchema.js"; + +describe("LanguageSchema Tests", () => { + it("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); + }); + + it("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); + }); + + it("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); + }); +}); diff --git a/tests/unit/syncModel/validation/typeSchema.test.ts b/tests/unit/syncModel/validation/typeSchema.test.ts new file mode 100644 index 00000000..f7ce7a36 --- /dev/null +++ b/tests/unit/syncModel/validation/typeSchema.test.ts @@ -0,0 +1,110 @@ +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 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); + }); +});