1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432
|
# lua_shape
A comprehensive schema validation and transformation library for Rspamd, designed to replace tableshape with improved error reporting, documentation generation, and export capabilities.
## Features
1. **Better Error Reporting**: Structured error trees with intersection analysis for `one_of` types
2. **Documentation Generation**: Extract structured documentation from schemas
3. **Type Constraints**: Numeric ranges, string lengths, patterns, and more
4. **First-class Mixins**: Field composition with origin tracking
5. **JSON Schema Export**: Export schemas for UCL validation
6. **Transform Support**: Immutable transformations with validation
7. **Pure Lua**: No dependencies on external modules (except optional lpeg for patterns)
## Quick Start
```lua
local T = require "lua_shape.core"
-- Define a schema
local config_schema = T.table({
host = T.string({ min_len = 1 }),
port = T.integer({ min = 1, max = 65535 }):with_default(8080),
timeout = T.number({ min = 0 }):optional(),
ssl = T.boolean():with_default(false)
})
-- Validate configuration
local ok, result = config_schema:check({
host = "localhost",
port = 3000
})
if not ok then
print("Validation error:")
print(T.format_error(result))
end
-- Transform with defaults applied
local ok, config = config_schema:transform({
host = "example.com"
})
-- config.port == 8080 (default applied)
-- config.ssl == false (default applied)
```
## Core Types
### Scalars
- `T.string(opts)` - String with optional constraints
- `min_len`, `max_len` - Length constraints
- `pattern` - Lua pattern for validation (e.g., `"^%d+$"` for digits only)
- `lpeg` - Optional lpeg pattern for complex parsing
- `T.number(opts)` - Number with optional range constraints (min, max)
- Accepts both numbers and values convertible via `tonumber`
- `T.integer(opts)` - Integer (number with integer constraint)
- `T.boolean()` - Boolean value
- `T.callable()` - Function/callable value
- `T.enum(values)` - One of a fixed set of values
- `T.literal(value)` - Exact value match
### Structured Types
- `T.array(item_schema, opts)` - Array with item validation
- Underlying table must be a dense, 1-indexed array (no sparse or string keys)
- `min_items`, `max_items` - Size constraints
- `T.table(fields, opts)` - Table/object with field schemas
- `open = true` - Allow additional fields not defined in schema
- `open = false` (default) - Reject unknown fields
- `extra = schema` - Schema for validating extra fields
- `mixins` - Array of mixin schemas for composition (applied when the schema is resolved via the registry)
- `T.one_of(variants)` - Sum type (match exactly one alternative)
### Composition
- `schema:optional()` - Make schema optional
- `schema:with_default(value)` - Add default value (can be a function for dynamic defaults)
- `schema:doc(doc_table)` - Add documentation
- `schema:transform_with(fn)` - Apply transformation
- `T.transform(schema, fn)` - Transform wrapper
- `T.ref(id)` - Reference to registered schema
- `T.mixin(schema, opts)` - Mixin for table composition
## Examples
### Basic Types with Constraints
```lua
-- String with length constraint
local name_schema = T.string({ min_len = 3, max_len = 50 })
-- String with Lua pattern (validates format)
local email_schema = T.string({ pattern = "^[%w%.]+@[%w%.]+$" })
local ipv4_schema = T.string({ pattern = "^%d+%.%d+%.%d+%.%d+$" })
-- Integer with range
local age_schema = T.integer({ min = 0, max = 150 })
-- Enum
local level_schema = T.enum({"debug", "info", "warning", "error"})
```
### Arrays and Tables
```lua
-- Array of strings
local tags_schema = T.array(T.string())
-- Table with required and optional fields
local user_schema = T.table({
name = T.string(),
email = T.string(),
age = T.integer():optional(),
role = T.enum({"admin", "user"}):with_default("user")
})
-- Closed table (default): rejects unknown fields
local strict_config = T.table({
host = T.string(),
port = T.integer()
}, { open = false })
-- Open table: allows additional fields not in schema
local flexible_config = T.table({
host = T.string(),
port = T.integer()
}, { open = true })
-- Accepts: { host = "localhost", port = 8080, custom_field = "value" }
```
### one_of with Intersection
```lua
-- Multiple config variants
local config_schema = T.one_of({
{
name = "file_config",
schema = T.table({
type = T.literal("file"),
path = T.string()
})
},
{
name = "redis_config",
schema = T.table({
type = T.literal("redis"),
host = T.string(),
port = T.integer():with_default(6379)
})
}
})
-- Error messages show intersection:
-- "all alternatives require: type (string)"
```
### Transforms
`T.transform(accepted_type, transformer)` validates input against `accepted_type`, then applies `transformer` function.
```lua
-- Accept string, convert to number
local num_from_string = T.transform(T.string(), tonumber)
-- Accept number or string, convert both to number
local flexible_number = T.one_of({
T.number(),
T.transform(T.string(), tonumber)
})
-- Accept string, parse time interval to number
local timeout_schema = T.one_of({
T.number({ min = 0 }),
T.transform(T.string(), parse_time_interval) -- "5s" -> 5.0
})
```
**Semantics:**
1. Input is validated against accepted type (first argument)
2. If valid, transformer function is called with pcall
3. If transformer returns `nil` or errors, validation fails
4. Otherwise, result is accepted without type checking
> **Note:** Transform functions run only in `schema:transform(...)` mode. In `schema:check(...)` mode, only the input type is validated.
### Callable Defaults
Defaults can be functions that are called each time a default is needed:
```lua
local function get_current_timestamp()
return os.time()
end
local event_schema = T.table({
name = T.string(),
timestamp = T.number():with_default(get_current_timestamp), -- Function called each time
priority = T.integer():with_default(0) -- Static default
})
-- Each transform gets a fresh timestamp
local ok, event1 = event_schema:transform({ name = "login" })
-- event1.timestamp will be the current time when transform was called
```
### Schema Registry
```lua
local Registry = require "lua_shape.registry"
local reg = Registry.global()
-- Define reusable schemas
local redis_schema = reg:define("redis.options", T.table({
servers = T.array(T.string()),
db = T.integer({ min = 0, max = 15 }):with_default(0)
}))
-- Reference in other schemas
local app_schema = T.table({
cache = T.ref("redis.options")
})
-- Resolve references
local resolved = reg:resolve_schema(app_schema)
-- Validate/transforms should use the resolved schema so mixins/references are applied
local ok, cfg_or_err = resolved:transform({
cache = {
servers = {"redis:6379"}
}
})
```
### Mixins with Origin Tracking
```lua
-- Base mixin
local redis_mixin = T.table({
redis_host = T.string(),
redis_port = T.integer():with_default(6379)
})
-- Use mixin in another schema
local plugin_schema = T.table({
enabled = T.boolean(),
plugin_option = T.string()
}, {
mixins = {
T.mixin(redis_mixin, { as = "redis" })
}
})
-- Documentation will show:
-- Direct fields: enabled, plugin_option
-- Mixin "redis": redis_host, redis_port
```
Mixins are merged into the resulting table schema by `Registry:resolve_schema` (or `Registry:define`). Always validate against the resolved schema so that mixin fields participate in `:check` / `:transform` and emit proper documentation/JSON Schema output.
### JSON Schema Export
```lua
local jsonschema = require "lua_shape.jsonschema"
-- Export single schema
local json = jsonschema.from_schema(config_schema, {
id = "https://rspamd.com/schema/config",
title = "Application Config"
})
-- Export all schemas from registry
local all_schemas = jsonschema.export_registry(Registry.global())
```
### Documentation Generation
```lua
local docs = require "lua_shape.docs"
-- Generate documentation IR
local doc_tree = docs.for_schema(config_schema)
-- Render as markdown
local markdown_lines = docs.render_markdown(doc_tree.schema_doc)
for _, line in ipairs(markdown_lines) do
print(line)
end
```
## Error Reporting
### Structured Errors
Errors are represented as trees:
```lua
{
kind = "table_invalid",
path = "config",
details = {
errors = {
port = {
kind = "constraint_violation",
path = "config.port",
details = { constraint = "max", expected = 65535, got = 99999 }
}
}
}
}
```
### Human-Readable Formatting
```lua
local T = require "lua_shape.core"
print(T.format_error(error_tree))
```
Output:
```
table validation failed at config:
constraint violation at config.port: max (expected: 65535, got: 99999)
```
### one_of Intersection Errors
When all variants of a one_of fail, the error shows common requirements:
```
value does not match any alternative at :
all alternatives require:
- name: string
- type: string
some alternatives also expect:
- path: string (in file_config variant)
- host: string (in redis_config variant)
tried alternatives:
- file_config: ...
- redis_config: ...
```
## API Reference
### Core Module (`lua_shape.core`)
#### Type Constructors
- `T.string(opts?)` - String type
- opts: `min_len`, `max_len`, `pattern`, `lpeg`, `doc`
- `T.number(opts?)` - Number type
- opts: `min`, `max`, `doc`
- `T.integer(opts?)` - Integer type (number with integer=true)
- opts: `min`, `max`, `doc`
- `T.boolean(opts?)` - Boolean type
- `T.enum(values, opts?)` - Enum type
- `T.literal(value, opts?)` - Literal value type
- `T.array(item_schema, opts?)` - Array type
- opts: `min_items`, `max_items`, `doc`
- `T.table(fields, opts?)` - Table type
- opts: `open`, `extra`, `mixins`, `doc`
- `T.one_of(variants, opts?)` - Sum type
- `T.optional(schema, opts?)` - Optional wrapper
- `T.default(schema, value)` - Default value wrapper
- `T.transform(accepted_type, transformer, opts?)` - Transform wrapper (validates input against accepted_type, then applies transformer)
- `T.ref(id, opts?)` - Schema reference placeholder (must be resolved via the registry before validation)
- `T.mixin(schema, opts?)` - Mixin definition
#### Schema Methods
- `schema:check(value, ctx?)` - Validate value
- `schema:transform(value, ctx?)` - Transform and validate (tableshape-compatible `(result)` / `(nil, err)` contract)
- `schema:optional(opts?)` - Make optional
- `schema:with_default(value)` - Add default
- `schema:doc(doc_table)` - Add documentation
- `schema:transform_with(fn, opts?)` - Add transformation
### Registry Module (`lua_shape.registry`)
- `Registry.global()` - Get/create global registry
- `registry:define(id, schema)` - Register schema with ID (returns the resolved version with mixins/reference chains applied)
- `registry:get(id)` - Get schema by ID
- `registry:resolve_schema(schema)` - Resolve references and mixins (recurses into nested arrays/one_of/options and caches the result)
- `registry:list()` - List all schema IDs
- `registry:export_all()` - Export all schemas
### Core Utilities
The core module also includes utility functions:
- `T.format_error(err)` - Format error tree as human-readable string
- `T.deep_clone(value)` - Deep clone value for immutable transformations
### JSON Schema Module (`lua_shape.jsonschema`)
- `jsonschema.from_schema(schema, opts?)` - Convert to JSON Schema
- `jsonschema.export_registry(registry, opts?)` - Export all schemas
### Docs Module (`lua_shape.docs`)
- `docs.for_schema(schema, opts?)` - Generate documentation IR
- `docs.for_registry(registry, opts?)` - Generate docs for all schemas
- `docs.render_markdown(doc_tree, indent?)` - Render as markdown
## Migration from tableshape
See [MIGRATION.md](MIGRATION.md) for detailed migration guide.
Quick reference:
| tableshape | lua_shape |
|------------|---------------|
| `ts.string` | `T.string()` |
| `ts.number` | `T.number()` |
| `ts.array_of(ts.string)` | `T.array(T.string())` |
| `ts.shape({...})` | `T.table({...})` |
| `field:is_optional()` | `field:optional()` or `{ schema = ..., optional = true }` |
| `ts.string + ts.number` | `T.one_of({ T.string(), T.number() })` |
| `ts.string / fn` | `T.string():transform_with(fn)` or `T.transform(T.string(), fn)` |
| `field:describe("...")` | `field:doc({ summary = "..." })` |
## Files
- `core.lua` - Core type system, validation, and utilities
- `registry.lua` - Schema registration and reference resolution
- `jsonschema.lua` - JSON Schema export
- `docs.lua` - Documentation generation
- `MIGRATION.md` - Migration guide from tableshape
- `README.md` - This file
## License
Apache License 2.0 - Same as Rspamd
|