Skip to content
30 changes: 25 additions & 5 deletions next/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
21 changes: 18 additions & 3 deletions next/src/errors/messages.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -9,6 +10,7 @@ export function getErrorMessage(
validation: SchemaValidationErrorType,
customErrorMessage?: string,
): string {
const presentation = schema['x-jsf-presentation']
switch (validation) {
// Core validation
case 'type':
Expand Down Expand Up @@ -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':
Expand Down
10 changes: 9 additions & 1 deletion next/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
99 changes: 99 additions & 0 deletions next/src/validation/file.ts
Original file line number Diff line number Diff line change
@@ -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 []
}
56 changes: 37 additions & 19 deletions next/src/validation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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' }]
}

Expand Down Expand Up @@ -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)
})

Expand All @@ -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),
Expand Down
Loading