diff --git a/src/application/Common/Enum.ts b/src/application/Common/Enum.ts index 74f42218..81b9384a 100644 --- a/src/application/Common/Enum.ts +++ b/src/application/Common/Enum.ts @@ -5,13 +5,13 @@ export type EnumType = number | string; export type EnumVariable = { [key in T]: TEnumValue }; -export interface IEnumParser { +export interface EnumParser { parseEnum(value: string, propertyName: string): TEnum; } export function createEnumParser( enumVariable: EnumVariable, -): IEnumParser { +): EnumParser { return { parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable), }; diff --git a/src/application/Parser/ApplicationParser.ts b/src/application/Parser/ApplicationParser.ts index 5a9d0e60..612c5b9d 100644 --- a/src/application/Parser/ApplicationParser.ts +++ b/src/application/Parser/ApplicationParser.ts @@ -1,40 +1,48 @@ import type { CollectionData } from '@/application/collections/'; import type { IApplication } from '@/domain/IApplication'; -import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; -import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import WindowsData from '@/application/collections/windows.yaml'; import MacOsData from '@/application/collections/macos.yaml'; import LinuxData from '@/application/collections/linux.yaml'; -import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser'; +import { parseProjectDetails, type ProjectDetailsParser } from '@/application/Parser/ProjectDetailsParser'; import { Application } from '@/domain/Application'; -import type { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata'; -import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; -import { parseCategoryCollection } from './CategoryCollectionParser'; +import { parseCategoryCollection, type CategoryCollectionParser } from './CategoryCollectionParser'; +import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; export function parseApplication( - categoryParser = parseCategoryCollection, - projectDetailsParser = parseProjectDetails, - metadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance, - collectionsData = PreParsedCollections, + collectionsData: readonly CollectionData[] = PreParsedCollections, + utilities: ApplicationParserUtilities = DefaultUtilities, ): IApplication { - validateCollectionsData(collectionsData); - const projectDetails = projectDetailsParser(metadata); + validateCollectionsData(collectionsData, utilities.validator); + const projectDetails = utilities.parseProjectDetails(); const collections = collectionsData.map( - (collection) => categoryParser(collection, projectDetails), + (collection) => utilities.parseCategoryCollection(collection, projectDetails), ); const app = new Application(projectDetails, collections); return app; } -export type CategoryCollectionParserType - = (file: CollectionData, projectDetails: ProjectDetails) => ICategoryCollection; - -const PreParsedCollections: readonly CollectionData [] = [ +const PreParsedCollections: readonly CollectionData[] = [ WindowsData, MacOsData, LinuxData, ]; -function validateCollectionsData(collections: readonly CollectionData[]) { - if (!collections.length) { - throw new Error('missing collections'); - } +function validateCollectionsData( + collections: readonly CollectionData[], + validator: TypeValidator, +) { + validator.assertNonEmptyCollection({ + value: collections, + valueName: 'collections', + }); } + +interface ApplicationParserUtilities { + readonly parseCategoryCollection: CategoryCollectionParser; + readonly validator: TypeValidator; + readonly parseProjectDetails: ProjectDetailsParser; +} + +const DefaultUtilities: ApplicationParserUtilities = { + parseCategoryCollection, + parseProjectDetails, + validator: createTypeValidator(), +}; diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index 8aef0a80..7c92c7d3 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -3,33 +3,73 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import type { ICategoryCollection } from '@/domain/ICategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; -import { createEnumParser } from '../Common/Enum'; -import { parseCategory } from './Executable/CategoryParser'; -import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; +import { createEnumParser, type EnumParser } from '../Common/Enum'; +import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; +import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; +import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities'; -export function parseCategoryCollection( - content: CollectionData, - projectDetails: ProjectDetails, - osParser = createEnumParser(OperatingSystem), - createUtilities: CategoryCollectionSpecificUtilitiesFactory = createCollectionUtilities, -): ICategoryCollection { - validate(content); - const scripting = new ScriptingDefinitionParser() - .parse(content.scripting, projectDetails); - const utilities = createUtilities(content.functions, scripting); - const categories = content.actions.map((action) => parseCategory(action, utilities)); - const os = osParser.parseEnum(content.os, 'os'); - const collection = new CategoryCollection( - os, - categories, - scripting, +export const parseCategoryCollection: CategoryCollectionParser = ( + content, + projectDetails, + utilities: CategoryCollectionParserUtilities = DefaultUtilities, +) => { + validateCollection(content, utilities.validator); + const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); + const collectionUtilities = utilities.createUtilities(content.functions, scripting); + const categories = content.actions.map( + (action) => utilities.parseCategory(action, collectionUtilities), ); + const os = utilities.osParser.parseEnum(content.os, 'os'); + const collection = utilities.createCategoryCollection({ + os, actions: categories, scripting, + }); return collection; +}; + +export type CategoryCollectionFactory = ( + ...parameters: ConstructorParameters +) => ICategoryCollection; + +export interface CategoryCollectionParser { + ( + content: CollectionData, + projectDetails: ProjectDetails, + utilities?: CategoryCollectionParserUtilities, + ): ICategoryCollection; } -function validate(content: CollectionData): void { - if (!content.actions.length) { - throw new Error('content does not define any action'); - } +function validateCollection( + content: CollectionData, + validator: TypeValidator, +): void { + validator.assertObject({ + value: content, + valueName: 'collection', + allowedProperties: [ + 'os', 'scripting', 'actions', 'functions', + ], + }); + validator.assertNonEmptyCollection({ + value: content.actions, + valueName: '"actions" in collection', + }); } + +interface CategoryCollectionParserUtilities { + readonly osParser: EnumParser; + readonly validator: TypeValidator; + readonly parseScriptingDefinition: ScriptingDefinitionParser; + readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory; + readonly parseCategory: CategoryParser; + readonly createCategoryCollection: CategoryCollectionFactory; +} + +const DefaultUtilities: CategoryCollectionParserUtilities = { + osParser: createEnumParser(OperatingSystem), + validator: createTypeValidator(), + parseScriptingDefinition, + createUtilities: createCollectionUtilities, + parseCategory, + createCategoryCollection: (...args) => new CategoryCollection(...args), +}; diff --git a/src/application/Parser/ContextualError.ts b/src/application/Parser/Common/ContextualError.ts similarity index 100% rename from src/application/Parser/ContextualError.ts rename to src/application/Parser/Common/ContextualError.ts diff --git a/src/application/Parser/Common/TypeValidator.ts b/src/application/Parser/Common/TypeValidator.ts new file mode 100644 index 00000000..05d13efa --- /dev/null +++ b/src/application/Parser/Common/TypeValidator.ts @@ -0,0 +1,96 @@ +import type { PropertyKeys } from '@/TypeHelpers'; +import { isNullOrUndefined, isArray, isPlainObject } from '@/TypeHelpers'; + +export interface TypeValidator { + assertObject(assertion: ObjectAssertion): void; + assertNonEmptyCollection(assertion: NonEmptyCollectionAssertion): void; +} + +export interface NonEmptyCollectionAssertion { + readonly value: unknown; + readonly valueName: string; +} + +export interface ObjectAssertion { + readonly value: T | unknown; + readonly valueName: string; + readonly allowedProperties?: readonly PropertyKeys[]; +} + +export function createTypeValidator(): TypeValidator { + return { + assertObject: (assertion) => { + assertDefined(assertion.value, assertion.valueName); + assertPlainObject(assertion.value, assertion.valueName); + assertNoEmptyProperties(assertion.value, assertion.valueName); + if (assertion.allowedProperties !== undefined) { + const allowedProperties = assertion.allowedProperties.map((p) => p as string); + assertAllowedProperties(assertion.value, assertion.valueName, allowedProperties); + } + }, + assertNonEmptyCollection: (assertion) => { + assertDefined(assertion.value, assertion.valueName); + assertArray(assertion.value, assertion.valueName); + assertNonEmpty(assertion.value, assertion.valueName); + }, + }; +} + +function assertDefined( + value: T, + valueName: string, +): asserts value is NonNullable { + if (isNullOrUndefined(value)) { + throw new Error(`'${valueName}' is missing.`); + } +} + +function assertPlainObject( + value: unknown, + valueName: string, +): asserts value is object { + if (!isPlainObject(value)) { + throw new Error(`'${valueName}' is not an object.`); + } +} + +function assertNoEmptyProperties( + value: object, + valueName: string, +): void { + if (Object.keys(value).length === 0) { + throw new Error(`'${valueName}' is an empty object without properties.`); + } +} + +function assertAllowedProperties( + value: object, + valueName: string, + allowedProperties: readonly string[], +): void { + const properties = Object.keys(value).map((p) => p as string); + const disallowedProperties = properties.filter( + (prop) => !allowedProperties.map((p) => p as string).includes(prop), + ); + if (disallowedProperties.length > 0) { + throw new Error(`'${valueName}' has disallowed properties: ${disallowedProperties.join(', ')}.`); + } +} + +function assertArray( + value: unknown, + valueName: string, +): asserts value is Array { + if (!isArray(value)) { + throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`); + } +} + +function assertNonEmpty( + value: Array, + valueName: string, +): void { + if (value.length === 0) { + throw new Error(`'${valueName}' cannot be an empty array.`); + } +} diff --git a/src/application/Parser/Executable/CategoryParser.ts b/src/application/Parser/Executable/CategoryParser.ts index 30dd5eb0..39d77e51 100644 --- a/src/application/Parser/Executable/CategoryParser.ts +++ b/src/application/Parser/Executable/CategoryParser.ts @@ -1,7 +1,7 @@ import type { CategoryData, ScriptData, ExecutableData, } from '@/application/collections/'; -import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError'; +import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import type { Category } from '@/domain/Executables/Category/Category'; import { CollectionCategory } from '@/domain/Executables/Category/CollectionCategory'; import type { Script } from '@/domain/Executables/Script/Script'; @@ -13,16 +13,24 @@ import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSp let categoryIdCounter = 0; -export function parseCategory( +export const parseCategory: CategoryParser = ( category: CategoryData, collectionUtilities: CategoryCollectionSpecificUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, -): Category { +) => { return parseCategoryRecursively({ categoryData: category, collectionUtilities, categoryUtilities, }); +}; + +export interface CategoryParser { + ( + category: CategoryData, + collectionUtilities: CategoryCollectionSpecificUtilities, + categoryUtilities?: CategoryParserUtilities, + ): Category; } interface CategoryParseContext { @@ -41,7 +49,7 @@ function parseCategoryRecursively( subscripts: new Array