diff --git a/next/src/errors/index.ts b/next/src/errors/index.ts index 45c03f56d..c730b12d9 100644 --- a/next/src/errors/index.ts +++ b/next/src/errors/index.ts @@ -7,24 +7,44 @@ export type SchemaValidationErrorType = /** * Core validation keywords */ - | 'type' | 'required' | 'valid' | 'const' | 'enum' + | 'type' + | 'required' + | 'valid' + | 'const' + | 'enum' /** * Schema composition keywords (allOf, anyOf, oneOf, not) * These keywords apply subschemas in a logical manner according to JSON Schema spec */ - | 'anyOf' | 'oneOf' | 'not' + | 'anyOf' + | 'oneOf' + | 'not' /** * String validation keywords */ - | 'format' | 'minLength' | 'maxLength' | 'pattern' + | 'format' + | 'minLength' + | 'maxLength' + | 'pattern' /** * Number validation keywords */ - | 'multipleOf' | 'maximum' | 'exclusiveMaximum' | 'minimum' | 'exclusiveMinimum' + | 'multipleOf' + | 'maximum' + | 'exclusiveMaximum' + | 'minimum' + | 'exclusiveMinimum' /** * Date validation keywords */ - | 'minDate' | 'maxDate' + | 'minDate' + | 'maxDate' + /** + * File validation keywords + */ + | 'fileStructure' + | 'maxFileSize' + | 'accept' /** * Array validation keywords */ diff --git a/next/src/errors/messages.ts b/next/src/errors/messages.ts index e1fde0e72..301eb2212 100644 --- a/next/src/errors/messages.ts +++ b/next/src/errors/messages.ts @@ -1,6 +1,7 @@ import type { SchemaValidationErrorType } from '.' import type { JsfSchemaType, NonBooleanJsfSchema, SchemaValue } from '../types' import { randexp } from 'randexp' +import { convertKBToMB } from '../utils' import { DATE_FORMAT } from '../validation/custom/date' export function getErrorMessage( @@ -9,6 +10,7 @@ export function getErrorMessage( validation: SchemaValidationErrorType, customErrorMessage?: string, ): string { + const presentation = schema['x-jsf-presentation'] switch (validation) { // Core validation case 'type': @@ -60,11 +62,24 @@ export function getErrorMessage( return `Must be greater or equal to ${schema.minimum}` case 'exclusiveMinimum': return `Must be greater than ${schema.exclusiveMinimum}` - // Date validation + // Date validation case 'minDate': - return `The date must be ${schema['x-jsf-presentation']?.minDate} or after.` + return `The date must be ${presentation?.minDate} or after.` case 'maxDate': - return `The date must be ${schema['x-jsf-presentation']?.maxDate} or before.` + return `The date must be ${presentation?.maxDate} or before.` + // File validation + case 'fileStructure': + return 'Not a valid file.' + case 'maxFileSize': { + const limitKB = presentation?.maxFileSize + const limitMB = typeof limitKB === 'number' ? convertKBToMB(limitKB) : undefined + return `File size too large.${limitMB ? ` The limit is ${limitMB} MB.` : ''}` + } + case 'accept': { + const formats = presentation?.accept + return `Unsupported file format.${formats ? ` The acceptable formats are ${formats}.` : ''}` + } + // Arrays case 'minItems': throw new Error('Array support is not implemented yet') case 'maxItems': diff --git a/next/src/utils.ts b/next/src/utils.ts index 9d5075dd9..2d78d6e5e 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -42,4 +42,12 @@ export function getField(fields: Field[], name: string, ...subNames: string[]) { return getField(field.fields, subNames[0], ...subNames.slice(1)) } return field -}; +} + +// Helper function to convert KB to MB +export function convertKBToMB(kb: number): number { + if (kb === 0) + return 0 + const mb = kb / 1024 // KB to MB + return Number.parseFloat(mb.toFixed(2)) // Keep 2 decimal places +} diff --git a/next/src/validation/file.ts b/next/src/validation/file.ts new file mode 100644 index 000000000..cf7cdb303 --- /dev/null +++ b/next/src/validation/file.ts @@ -0,0 +1,99 @@ +import type { ValidationError, ValidationErrorPath } from '../errors' +import type { NonBooleanJsfSchema, SchemaValue } from '../types' +import { isObjectValue } from './util' + +// Represents a file-like object, either a browser native File or a plain object. +// Both must have name (string) and size (number) properties. +export type FileLike = (File & { name: string, size: number }) | { name: string, size: number } + +/** + * Validates file-specific constraints (maxFileSize, accept). + * + * The value is expected to be `null`, `undefined`, or an array of `FileLike` objects. + * Each `FileLike` object must have `name` (string) and `size` (number) properties. + * + * @param value - The value to validate. + * @param schema - The schema object, potentially containing 'x-jsf-presentation' with + * 'maxFileSize' (in KB) and/or 'accept' (comma-separated string). + * @param path - The path to the current field in the validation context. + * @returns An array of validation errors, empty if validation passes. + */ +export function validateFile( + value: SchemaValue, + schema: NonBooleanJsfSchema, + path: ValidationErrorPath = [], +): ValidationError[] { + // Early exit conditions + // 1. Check if schema indicates a potential file input + const presentation = schema['x-jsf-presentation'] + const isExplicitFileInput = presentation?.inputType === 'file' + const hasFileKeywords + = typeof presentation?.maxFileSize === 'number' || typeof presentation?.accept === 'string' + const isPotentialFileInput = isExplicitFileInput || hasFileKeywords + + if (!isPotentialFileInput) { + return [] // Not a file input schema, nothing to validate here. + } + + // 2. Check if the value is an array. Non-arrays (like null, object, string) are handled by validateType. + if (!Array.isArray(value)) { + return [] // File validation only applies to arrays. + } + + // Actual file validation logic + // If value is an empty array, no further file validation needed. + if (value.length === 0) { + return [] + } + + // 2. Check structure of array items: Each item must be a FileLike object. + const isStructureValid = value.every( + file => isObjectValue(file) && typeof file.name === 'string' && typeof file.size === 'number', + ) + + if (!isStructureValid) { + return [{ path, validation: 'fileStructure' }] + } + + // Now we know value is a valid FileLike[] with at least one item. + const files = value as FileLike[] + + // 3. Validate maxFileSize (presentation.maxFileSize is expected in KB) + if (typeof presentation?.maxFileSize === 'number') { + const maxSizeInBytes = presentation.maxFileSize * 1024 // Convert KB from schema to Bytes + // Check if *any* file exceeds the limit. + const isAnyFileTooLarge = files.some(file => file.size > maxSizeInBytes) + + if (isAnyFileTooLarge) { + return [{ path, validation: 'maxFileSize' }] + } + } + + // 4. Validate accepted file formats (presentation.accept is comma-separated string) + if (typeof presentation?.accept === 'string' && presentation.accept.trim() !== '') { + const acceptedFormats = presentation.accept + .toLowerCase() + .split(',') + .map((f: string) => f.trim()) + .filter((f: string) => f) + // Normalize formats (handle leading dots) + .map((f: string) => (f.startsWith('.') ? f : `.${f}`)) + + if (acceptedFormats.length > 0) { + // Check if *at least one* file has an accepted format. + const isAnyFileFormatAccepted = files.some((file) => { + const nameLower = file.name.toLowerCase() + const extension = nameLower.includes('.') ? `.${nameLower.split('.').pop()}` : '' + return extension !== '' && acceptedFormats.includes(extension) + }) + + // Fail only if *none* of the files have an accepted format. + if (!isAnyFileFormatAccepted) { + return [{ path, validation: 'accept' }] + } + } + } + + // If all checks passed + return [] +} diff --git a/next/src/validation/schema.ts b/next/src/validation/schema.ts index 2e86cd284..1cdbc5a1f 100644 --- a/next/src/validation/schema.ts +++ b/next/src/validation/schema.ts @@ -6,6 +6,7 @@ import { validateCondition } from './conditions' import { validateConst } from './const' import { validateDate } from './custom/date' import { validateEnum } from './enum' +import { validateFile } from './file' import { validateJsonLogic } from './json-logic' import { validateNumber } from './number' import { validateObject } from './object' @@ -75,29 +76,37 @@ function validateType( const valueType = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value if (Array.isArray(schemaType)) { + // Handle cases where schema type is an array (e.g., ["string", "null"]) if (value === null && schemaType.includes('null')) { return [] } for (const type of schemaType) { + if (type === 'array' && Array.isArray(value)) { + return [] // Correctly validate array type + } if (valueType === 'number' && type === 'integer' && Number.isInteger(value)) { return [] } - if (valueType === type || (type === 'null' && value === null)) { return [] } } } - - if (valueType === 'number' && schemaType === 'integer' && Number.isInteger(value)) { - return [] - } - - if (valueType === schemaType) { - return [] + else { + // Handle cases where schema type is a single type string + if (schemaType === 'array' && Array.isArray(value)) { + return [] // Correctly validate array type + } + if (valueType === 'number' && schemaType === 'integer' && Number.isInteger(value)) { + return [] + } + if (valueType === schemaType) { + return [] + } } + // If none of the conditions matched, it's a type error return [{ path, validation: 'type' }] } @@ -149,30 +158,36 @@ export function validateSchema( const valueIsUndefined = value === undefined || (value === null && options.treatNullAsUndefined) const errors: ValidationError[] = [] - // If value is undefined but not required, no further validation needed + // Handle undefined value if (valueIsUndefined) { return [] } + // Handle boolean schemas if (typeof schema === 'boolean') { - // It means the property does not exist in the payload - if (!schema && typeof value !== 'undefined') { - return [{ path, validation: 'valid' }] - } - else { - return [] - } + return schema ? [] : [{ path, validation: 'valid' }] } - const typeValidationErrors = validateType(value, schema, path) - if (typeValidationErrors.length > 0) { - return typeValidationErrors + // Check if it is a file input (needed early for null check) + const presentation = schema['x-jsf-presentation'] + const isExplicitFileInput = presentation?.inputType === 'file' + + let typeValidationErrors: ValidationError[] = [] + // Skip standard type validation ONLY if inputType is explicitly 'file' + // (The null check above already handled null for potential file inputs) + if (!isExplicitFileInput) { + typeValidationErrors = validateType(value, schema, path) + if (typeValidationErrors.length > 0) { + return typeValidationErrors + } } // If the schema defines "required", run required checks even when type is undefined. if (schema.required && isObjectValue(value)) { const missingKeys = schema.required.filter((key: string) => { const fieldValue = value[key] + // Field is considered missing if it's undefined OR + // if it's null AND treatNullAsUndefined option is true return fieldValue === undefined || (fieldValue === null && options.treatNullAsUndefined) }) @@ -193,6 +208,9 @@ export function validateSchema( ...validateArray(value, schema, options, jsonLogicBag, path), ...validateString(value, schema, path), ...validateNumber(value, schema, path), + // File validation + ...validateFile(value, schema, path), + // Composition and conditional logic ...validateNot(value, schema, options, jsonLogicBag, path), ...validateAllOf(value, schema, options, jsonLogicBag, path), ...validateAnyOf(value, schema, options, jsonLogicBag, path), diff --git a/next/test/validation/file.test.ts b/next/test/validation/file.test.ts new file mode 100644 index 000000000..4b5d7c605 --- /dev/null +++ b/next/test/validation/file.test.ts @@ -0,0 +1,146 @@ +import type { JsfSchema } from '../../src/types' +import type { FileLike } from '../../src/validation/file' +import { describe, expect, it } from '@jest/globals' +import { validateSchema } from '../../src/validation/schema' + +// Helper to create a dummy file-like object +const createFile = (name: string, sizeInBytes: number): FileLike => ({ name, size: sizeInBytes }) + +// Common schema +const baseFileSchema: JsfSchema = { + type: ['array', 'null'], + items: { type: 'object' }, +} + +const fileSchemaWithSizeLimitKB: JsfSchema = { + ...baseFileSchema, + 'x-jsf-presentation': { + maxFileSize: 1024, // 1024 KB = 1MB + }, +} + +const fileSchemaWithAccept: JsfSchema = { + ...baseFileSchema, + 'x-jsf-presentation': { + accept: '.jpg, .png, .pdf', + }, +} + +const fileSchemaWithLimitAndAcceptKB: JsfSchema = { + ...baseFileSchema, + 'x-jsf-presentation': { + maxFileSize: 500, // 500 KB + accept: '.jpg, .png, .pdf', + }, +} + +describe('validateFile', () => { + // --- Valid Cases --- + it('should pass validation for null value', () => { + const errors = validateSchema(null, baseFileSchema) + expect(errors).toEqual([]) + }) + + it('should pass validation for undefined value', () => { + const errors = validateSchema(undefined, baseFileSchema) + expect(errors).toEqual([]) + }) + + it('should pass validation for empty array', () => { + const errors = validateSchema([], baseFileSchema) + expect(errors).toEqual([]) + }) + + it('should pass validation for valid file array with size limit (KB)', () => { + const value = [createFile('test.jpg', 500 * 1024)] as any[] // 500 KB file, limit 1024 KB + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) + expect(errors).toEqual([]) + }) + + it('should pass validation for valid file array with accept limit', () => { + const value = [createFile('document.pdf', 100)] as any[] + const errors = validateSchema(value, fileSchemaWithAccept) + expect(errors).toEqual([]) + }) + + it('should pass validation if SOME files have accepted format', () => { + const value = [ + createFile('image.jpg', 100), + createFile('document.txt', 200), // .txt is not accepted, but .jpg is + ] as any[] + // The logic requires only *some* file to be valid for the accept rule to pass + const errors = validateSchema(value, fileSchemaWithAccept) + expect(errors).toEqual([]) + }) + + it('should pass validation for valid file array with both limits (KB)', () => { + const value = [createFile('image.png', 500 * 1024)] as any[] // Exactly 500 KB + const errors = validateSchema(value, fileSchemaWithLimitAndAcceptKB) + expect(errors).toEqual([]) + }) + + // --- Invalid Structure --- + it('should fail validation for non-array value', () => { + const value = { name: 'file.txt', size: 100 } // Not an array + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) + // Expect type error first, as the schema expects an array but got an object + expect(errors).toEqual([{ path: [], validation: 'type' }]) + }) + + it('should fail validation for array with invalid file object (missing size)', () => { + const value = [{ name: 'file.txt' }] as any[] // Cast to bypass TS check, simulating bad input + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) + expect(errors).toEqual([{ path: [], validation: 'fileStructure' }]) + }) + + it('should fail validation for array with invalid file object (missing name)', () => { + const value = [{ size: 100 }] as any[] // Cast to bypass TS check + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) + expect(errors).toEqual([{ path: [], validation: 'fileStructure' }]) + }) + + it('should fail validation for array with non-object item', () => { + const value = ['file.txt'] as any[] + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) + expect(errors).toEqual([ + { path: ['items', 0], validation: 'type' }, + { path: [], validation: 'fileStructure' }, + ]) + }) + + // --- Max File Size --- + it('should fail validation if one file exceeds maxFileSize (KB)', () => { + const value = [ + createFile('small.jpg', 500 * 1024), // 500 KB + createFile('large.png', 1.5 * 1024 * 1024), // 1536 KB + ] as any[] + const errors = validateSchema(value, fileSchemaWithSizeLimitKB) // Limit is 1024 KB + expect(errors).toEqual([{ path: [], validation: 'maxFileSize' }]) + }) + + // --- Accept --- + it('should fail validation if ALL files have unsupported format', () => { + const value = [createFile('document.txt', 100), createFile('archive.zip', 200)] as any[] + const errors = validateSchema(value, fileSchemaWithAccept) + expect(errors).toEqual([{ path: [], validation: 'accept' }]) + }) + + it('should fail validation if file has no extension when accept is defined', () => { + const value = [createFile('image', 100)] as any[] + const errors = validateSchema(value, fileSchemaWithAccept) + expect(errors).toEqual([{ path: [], validation: 'accept' }]) + }) + + // --- Combined --- + it('should fail with maxFileSize if size is invalid (KB), even if format is valid', () => { + const value = [createFile('large_valid.pdf', 600 * 1024)] as any[] // 600 KB > 500 KB limit + const errors = validateSchema(value, fileSchemaWithLimitAndAcceptKB) + expect(errors).toEqual([{ path: [], validation: 'maxFileSize' }]) + }) + + it('should fail with accept if format is invalid, even if size is valid (KB)', () => { + const value = [createFile('small_invalid.txt', 400 * 1024)] as any[] // 400 KB < 500 KB limit, but .txt invalid + const errors = validateSchema(value, fileSchemaWithLimitAndAcceptKB) + expect(errors).toEqual([{ path: [], validation: 'accept' }]) + }) +})