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:
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 objectsSchema Introspection
Joi provides comprehensive schema introspection capabilities that enable runtime analysis:
// 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 constraintsPlugin Ecosystem
Joi's mature plugin ecosystem integrates seamlessly with metadata-driven CRUD operations:
// Custom extensions work naturally with the validation system
const extendedJoi = joi.extend(customExtension)
// Automatically supported in OpenAPI generationCustom Joi Instance
Lucid Resourceful provides an extended Joi instance with additional schema types optimized for database and API operations.
Importing the Extended Joi
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:
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: bigintBigInt Schema Methods
The BigInt schema provides comprehensive comparison and validation methods:
// 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:
// 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:
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:
// 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
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:
@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: stringComplex Validation Rules
Joi's full feature set is available for complex validation scenarios:
@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: stringValidation Scoping
Validation scoping allows dynamic modification of validation rules based on request context, enabling sophisticated context-aware validation.
Basic Validation Scoping
@resourcefulColumn({
type: ResourcefulStringType(),
validationScopes: [
(schema) => {
// Modify the base schema
return schema.max(500) // Increase max length
}
]
})
declare description: stringContext-Aware Scoping
Access HTTP context, authentication, and application services in validation scopes:
@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: stringOperation-Specific Scoping
Modify validation based on the CRUD operation being performed:
@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: stringMultiple Validation Scopes
Chain multiple validation scopes for complex logic:
@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: stringRequest 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:
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:
// 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):
// 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:
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:
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
// 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:
@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: stringValidation 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
- Property Validation: Individual field validation using property schemas
- Validation Scoping: Context-aware modification of validation rules
- Mixin Validation: Model-level payload validation
- Hook Validation: Operation-specific additional validation
- Combined Validation: All validations merged and applied
Validation Execution Order
// 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:
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: stringValidation 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
// 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 degradationType Safety
Leverage TypeScript for type-safe validation:
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
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
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
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: