Skip to content

Validation ​

Lucid Resourceful provides a comprehensive validation system built on Joi, offering type-safe validation for model properties, request payloads, and OpenAPI schema generation. This system was specifically designed to leverage Joi's mature ecosystem and seamless OpenAPI integration capabilities.

Overview ​

The validation system operates at multiple levels:

  • Data Type Validation: Built-in validation for all Resourceful data types
  • Property-Level Validation: Field-specific validation rules defined in decorators
  • Validation Scoping: Dynamic, context-aware validation rule modification
  • Request Payload Validation: CRUD operation validation with hooks
  • OpenAPI Schema Generation: Automatic API documentation from validation rules

Why Joi Over Vine ​

Lucid Resourceful uses Joi as its validation library instead of AdonisJS's default Vine for several architectural reasons:

OpenAPI Integration ​

Joi schemas can be directly converted to OpenAPI 3.0 specifications without complex transformations:

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

const userSchema = joi.object({
  name: joi.string().min(2).max(100).required(),
  email: joi.string().email().required(),
  age: joi.number().integer().min(0).max(150).optional()
})

// Joi's describe() method provides rich metadata for OpenAPI generation
const description = userSchema.describe()
// Results in complete OpenAPI schema objects

Schema Introspection ​

Joi provides comprehensive schema introspection capabilities that enable runtime analysis:

typescript
// Extract validation rules, defaults, and metadata
const description = schema.describe()
console.log(description.keys) // Field definitions
console.log(description.flags) // Default values, required status
console.log(description.rules) // Validation rules and constraints

Plugin Ecosystem ​

Joi's mature plugin ecosystem integrates seamlessly with metadata-driven CRUD operations:

typescript
// Custom extensions work naturally with the validation system
const extendedJoi = joi.extend(customExtension)
// Automatically supported in OpenAPI generation

Custom Joi Instance ​

Lucid Resourceful provides an extended Joi instance with additional schema types optimized for database and API operations.

Importing the Extended Joi ​

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

// All standard Joi methods are available
const stringSchema = joi.string().email().required()
const numberSchema = joi.number().positive().max(1000)
const objectSchema = joi.object({
  name: joi.string().required(),
  balance: joi.bigint().positive() // Custom BigInt support
})

Custom Schema Types ​

BigInt Validation ​

The extended Joi includes native BigInt support for handling large integers:

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

const bigintSchema = joi.bigint()
  .min(0n)
  .max(9999999999999999999n)
  .multiple(100n)
  .required()

// Automatic type coercion from strings and numbers
const result = bigintSchema.validate("12345")
// Returns: { value: 12345n }

// Works with ResourcefulBigintType
@resourcefulColumn({
  type: ResourcefulBigintType(),
  validate: {
    create: joi.bigint().positive(),
    update: joi.bigint().min(0n)
  }
})
declare balance: bigint

BigInt Schema Methods ​

The BigInt schema provides comprehensive comparison and validation methods:

typescript
// Range validation
joi.bigint().min(0n).max(1000n)

// Exclusive bounds
joi.bigint().greater(0n).less(1000n)

// Multiple validation
joi.bigint().multiple(5n) // Must be divisible by 5

// Sign validation
joi.bigint().positive() // > 0
joi.bigint().negative() // < 0

// Combine with standard Joi methods
joi.bigint().positive().required().default(0n)

Data Type Validation ​

All Resourceful data types include built-in Joi validation schemas that ensure type safety and OpenAPI compliance.

Automatic Validation ​

Data types automatically validate their configuration options:

typescript
// Valid configuration
const stringType = ResourcefulStringType({
  minLength: 1,
  maxLength: 100,
  pattern: '^[a-zA-Z0-9]+$'
})

// Throws validation error - minLength cannot be greater than maxLength
const invalidType = ResourcefulStringType({
  minLength: 100,
  maxLength: 50 // Error!
})

Schema Access ​

Each data type exposes its underlying Joi schema for inspection:

typescript
const stringType = ResourcefulStringType({ minLength: 5 })

// Access the internal validation schema
const schema = stringType.asJoiSchema()
console.log(schema.describe()) // Joi schema description

// Validate values directly
const result = schema.validate("hello")
// Returns: { value: "hello" }

Type-Specific Validation ​

Different data types provide specialized validation capabilities:

typescript
// String validation with format constraints
const emailType = ResourcefulStringType({
  format: 'email',
  maxLength: 255
})

// Number validation with range and precision
const priceType = ResourcefulNumberType({
  minimum: 0,
  maximum: 99999.99,
  multipleOf: 0.01,
  format: 'double'
})

// Integer validation with exclusive bounds
const ageType = ResourcefulIntegerType({
  minimum: 0,
  maximum: 150,
  exclusiveMaximum: true
})

Property-Level Validation ​

Model properties can define validation rules through the validate option in resourceful decorators.

Basic Property Validation ​

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

class User extends withResourceful()(BaseModel) {
  @resourcefulColumn({
    type: ResourcefulStringType(),
    validate: {
      create: joi.string().min(2).max(100).required(),
      update: joi.string().min(2).max(100).optional()
    }
  })
  declare name: string

  @resourcefulColumn({
    type: ResourcefulStringType(),
    validate: {
      create: joi.string().email().required(),
      update: joi.string().email().optional()
    }
  })
  declare email: string
}

Different Validation for Operations ​

Validation rules can differ between create and update operations:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validate: {
    // Password required on creation
    create: joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).required(),
    // Password optional on update (only when changing password)
    update: joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).optional()
  }
})
declare password: string

Complex Validation Rules ​

Joi's full feature set is available for complex validation scenarios:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validate: {
    create: joi.string()
      .min(3)
      .max(50)
      .alphanum()
      .lowercase()
      .required()
      .messages({
        'string.alphanum': 'Username can only contain letters and numbers',
        'string.lowercase': 'Username must be lowercase'
      }),
    update: joi.string()
      .min(3)
      .max(50)
      .alphanum()
      .lowercase()
      .optional()
  }
})
declare username: string

Validation Scoping ​

Validation scoping allows dynamic modification of validation rules based on request context, enabling sophisticated context-aware validation.

Basic Validation Scoping ​

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validationScopes: [
    (schema) => {
      // Modify the base schema
      return schema.max(500) // Increase max length
    }
  ]
})
declare description: string

Context-Aware Scoping ​

Access HTTP context, authentication, and application services in validation scopes:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validationScopes: [
    (schema, ctx, app) => {
      // Admin users can create longer descriptions
      if (ctx.auth.user?.role === 'admin') {
        return schema.max(1000)
      }
      
      // Regular users have stricter limits
      return schema.max(200)
    }
  ]
})
declare content: string

Operation-Specific Scoping ​

Modify validation based on the CRUD operation being performed:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validationScopes: [
    (schema, ctx, app, operation) => {
      if (operation === 'create') {
        // Require title on creation
        return schema.required()
      }
      
      if (operation === 'update') {
        // Optional on updates
        return schema.optional()
      }
      
      return schema
    }
  ]
})
declare title: string

Multiple Validation Scopes ​

Chain multiple validation scopes for complex logic:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validationScopes: [
    // First scope: Base length constraints
    (schema) => schema.min(10).max(500),
    
    // Second scope: Role-based modifications
    (schema, ctx) => {
      if (ctx.auth.user?.role === 'moderator') {
        return schema.max(1000)
      }
      return schema
    },
    
    // Third scope: Time-based restrictions
    (schema, ctx) => {
      const isWeekend = [0, 6].includes(new Date().getDay())
      if (isWeekend && ctx.auth.user?.role !== 'admin') {
        return schema.max(100) // Shorter posts on weekends
      }
      return schema
    }
  ]
})
declare post: string

Request Payload Validation ​

Lucid Resourceful provides hooks for adding request-specific validation beyond model-level validation.

Mixin-Level Payload Validation ​

Configure validation at the model level through mixin options:

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

class User extends withResourceful({
  payloadValidationSchemaBuilders: {
    create: [
      // Base validation for user creation
      () => joi.object({
        role: joi.string().valid('user', 'moderator').default('user'),
        agreedToTerms: joi.boolean().valid(true).required()
      }),
      
      // Context-specific validation
      (ctx, app) => {
        if (ctx.auth.user?.role !== 'admin') {
          return joi.object({
            role: joi.string().valid('user').default('user') // Non-admins can only create 'user' accounts
          })
        }
        return joi.object({})
      }
    ],
    
    update: [
      // Prevent non-admins from changing roles
      (ctx, app) => {
        if (ctx.auth.user?.role !== 'admin') {
          return joi.object({
            role: joi.forbidden()
          })
        }
        return joi.object({})
      }
    ]
  }
})(BaseModel) {
  // ...model definition
}

Operation-Specific Hooks ​

Provide validation hooks when calling CRUD operations:

typescript
// Create with additional validation
const user = await User.$onResourcefulCreate(payload, ctx, app, [
  // Additional validation for this specific create operation
  (ctx, app) => joi.object({
    inviteCode: joi.string().required() // Require invite code for this operation
  })
])

// Update with conditional validation
const user = await User.$onResourcefulUpdate(123, payload, ctx, app, {
  payloadValidationSchemas: [
    (ctx, app) => {
      // Validate profile completeness before certain updates
      return joi.object({
        profileComplete: joi.boolean().valid(true).required()
      })
    }
  ]
})

Combining Model and Hook Validation ​

Model-level and hook-level validations are combined (not replaced):

typescript
// Model validation runs first
@resourcefulColumn({
  type: ResourcefulStringType(),
  validate: {
    create: joi.string().min(3).required()
  }
})
declare username: string

// Then mixin validation
payloadValidationSchemaBuilders: {
  create: [
    () => joi.object({
      username: joi.string().alphanum() // Additional constraint
    })
  ]
}

// Finally, operation-specific validation
User.$onResourcefulCreate(payload, ctx, app, [
  () => joi.object({
    username: joi.string().not('admin', 'root') // Forbidden values
  })
])

// Result: username must be min 3 chars, alphanumeric, and not 'admin' or 'root'

Validation Error Handling ​

Error Handler Configuration ​

Control how validation errors are handled through mixin options:

typescript
class User extends withResourceful({
  onValidationScopeError: 'pass' // 'bubble' | 'pass' | 'fail'
})(BaseModel) {
  // ...
}

Error Handler Behaviors ​

  • 'bubble' (default): Re-throw validation scope errors
  • 'pass': Skip validation when scope evaluation fails
  • 'fail': Fail validation when scope evaluation fails

Error Event Monitoring ​

Listen for validation errors using the event system:

typescript
User.$onResourcefulEvent('validation:scope:error', (error, ctx, app, fieldKey, dataType) => {
  console.error(`Validation scope error for field ${fieldKey}:`, error)
  // Log to monitoring system, send alerts, etc.
})

OpenAPI Schema Integration ​

Joi schemas automatically generate OpenAPI 3.0 compatible documentation.

Automatic Schema Generation ​

typescript
// Model with validation
class User extends withResourceful()(BaseModel) {
  @resourcefulColumn({
    type: ResourcefulStringType(),
    validate: {
      create: joi.string().email().required(),
      update: joi.string().email().optional()
    }
  })
  declare email: string
}

// Generate OpenAPI schema
const schema = await User.$asOpenApiSchemaObject(ctx, app)
/*
Result:
{
  type: 'object',
  properties: {
    email: {
      type: 'string',
      format: 'email'
    }
  },
  required: ['email']
}
*/

Custom Validation Messages in OpenAPI ​

Joi's validation messages enhance OpenAPI documentation:

typescript
@resourcefulColumn({
  type: ResourcefulStringType(),
  validate: {
    create: joi.string()
      .min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
      .required()
      .messages({
        'string.pattern.base': 'Password must contain uppercase, lowercase, and numeric characters'
      })
      .description('User password with complexity requirements')
  }
})
declare password: string

Validation in Different Contexts ​

Property Validation Context ​

Property-level validation occurs during:

  • CRUD Operations: Create, update payload validation
  • Data Type Instantiation: Configuration validation
  • Schema Generation: OpenAPI documentation creation

Request Validation Flow ​

  1. Property Validation: Individual field validation using property schemas
  2. Validation Scoping: Context-aware modification of validation rules
  3. Mixin Validation: Model-level payload validation
  4. Hook Validation: Operation-specific additional validation
  5. Combined Validation: All validations merged and applied

Validation Execution Order ​

typescript
// 1. Property-level base validation
@resourcefulColumn({
  validate: { create: joi.string().min(3) }
})

// 2. Validation scoping applied
validationScopes: [(schema, ctx) => schema.max(100)]

// 3. Mixin-level validation
payloadValidationSchemaBuilders: {
  create: [() => joi.object({ name: joi.string().alphanum() })]
}

// 4. Operation-specific hooks
User.$onResourcefulCreate(payload, ctx, app, [
  () => joi.object({ name: joi.string().not('reserved') })
])

Best Practices ​

Schema Reusability ​

Create reusable validation schemas for common patterns:

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'

// Reusable schemas
const emailSchema = joi.string().email().lowercase().trim()
const passwordSchema = joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
const usernameSchema = joi.string().min(3).max(30).alphanum().lowercase()

// Use in multiple places
@resourcefulColumn({
  type: ResourcefulStringType(),
  validate: {
    create: emailSchema.required(),
    update: emailSchema.optional()
  }
})
declare email: string

Validation Performance ​

  • Minimize Validation Scopes: Only use when dynamic validation is necessary
  • Cache Complex Schemas: Store expensive validation schemas in constants
  • Limit Hook Usage: Use operation hooks sparingly for performance

Error Handling Strategy ​

typescript
// Provide clear error messages
const schema = joi.string()
  .min(3)
  .max(30)
  .alphanum()
  .messages({
    'string.min': 'Username must be at least 3 characters long',
    'string.max': 'Username cannot exceed 30 characters',
    'string.alphanum': 'Username can only contain letters and numbers'
  })

// Use appropriate error handling for validation scopes
onValidationScopeError: 'pass' // Graceful degradation

Type Safety ​

Leverage TypeScript for type-safe validation:

typescript
import { joi } from '@nhtio/lucid-resourceful/joi'
import type { ObjectSchema } from 'joi'

// Type-safe validation schema builders
const createUserValidation = (): ObjectSchema => {
  return joi.object({
    name: joi.string().required(),
    email: joi.string().email().required()
  })
}

// Use in mixin options
payloadValidationSchemaBuilders: {
  create: [createUserValidation]
}

Common Patterns ​

Role-Based Validation ​

typescript
const roleBasedValidation = (ctx: HttpContext): ObjectSchema => {
  const baseSchema = joi.object({
    name: joi.string().required(),
    email: joi.string().email().required()
  })
  
  if (ctx.auth.user?.role === 'admin') {
    return baseSchema.keys({
      role: joi.string().valid('user', 'admin', 'moderator'),
      permissions: joi.array().items(joi.string())
    })
  }
  
  return baseSchema.keys({
    role: joi.string().valid('user').default('user')
  })
}

Conditional Validation ​

typescript
const conditionalValidation = (ctx: HttpContext): ObjectSchema => {
  let schema = joi.object({
    type: joi.string().valid('individual', 'business').required()
  })
  
  return schema.when('type', {
    is: 'business',
    then: schema.keys({
      businessName: joi.string().required(),
      taxId: joi.string().required()
    }),
    otherwise: schema.keys({
      firstName: joi.string().required(),
      lastName: joi.string().required()
    })
  })
}

Multi-Step Validation ​

typescript
const multiStepValidation = [
  // Step 1: Basic validation
  () => joi.object({
    email: joi.string().email().required(),
    username: joi.string().alphanum().required()
  }),
  
  // Step 2: Uniqueness validation
  async (ctx, app) => {
    const User = app.container.use('App/Models/User')
    
    return joi.object({
      email: joi.string().external(async (value) => {
        const exists = await User.findBy('email', value)
        if (exists) throw new Error('Email already exists')
        return value
      }),
      username: joi.string().external(async (value) => {
        const exists = await User.findBy('username', value)
        if (exists) throw new Error('Username already taken')
        return value
      })
    })
  }
]

API Reference ​

For detailed API documentation, see: