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: