Validator DSL
Validators are a cornerstone of Snitch's design, ensuring that HTTP inputs are properly validated and transformed into domain types. This guide explores the internal workings of the validator DSL, explaining each component and how they fit together.
The Validator Interface
At the heart of the validation system is the Validator
interface:
interface Validator<T, R> {
val regex: Regex
val description: String
val parse: Parser.(Collection<String>) -> R
fun optional(): Validator<T?, R?> = this as Validator<T?, R?>
}
Let's break down each component:
-
Type Parameters:
T
: The input type that the validator accepts (typicallyString
)R
: The output type that the validator produces (your domain type)
-
Properties:
regex
: A regular expression used for initial string validationdescription
: A human-readable description used for documentationparse
: A function that takes a collection of strings and transforms them into the output type
-
Methods:
optional()
: Converts a required validator to an optional one
The interface is intentionally minimal, focusing on the essential components of validation: pattern matching, transformation, and documentation.
Creating Validators
Snitch provides several factory functions for creating validators with different behaviors:
The validator
Function
The most general factory function:
inline fun <From, To> validator(
descriptions: String,
regex: Regex = """^.+$""".toRegex(RegexOption.DOT_MATCHES_ALL),
crossinline mapper: Parser.(String) -> To
) = object : Validator<From, To> {
override val description = descriptions
override val regex = regex
override val parse: Parser.(Collection<String>) -> To = { mapper(it.single()) }
}
This function creates a validator that:
- Has a custom description
- Uses a specified regex (or a default that matches any non-empty string)
- Applies a mapping function to transform the input
The crossinline
modifier ensures that the mapper function can be used inside a lambda that will be inlined.
Typical Usage:
val ofUUID = validator<String, UUID>(
"valid UUID",
"""^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$""".toRegex(RegexOption.IGNORE_CASE)
) {
try {
UUID.fromString(it)
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("Invalid UUID format")
}
}
The stringValidator
Function
A specialized version for string inputs:
inline fun <To> stringValidator(
description: String = "",
regex: Regex = """^.+$""".toRegex(RegexOption.DOT_MATCHES_ALL),
crossinline mapper: Parser.(String) -> To,
) = validator<String, To>(description, regex, mapper)
This is a convenience function that defaults the input type to String
, which is the most common case.
Typical Usage:
val ofEmail = stringValidator(
"email address",
"""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""".toRegex()
) { it }
The validatorMulti
Function
For handling collections of values:
fun <From, To> validatorMulti(
descriptions: String,
regex: Regex = """^.+$""".toRegex(RegexOption.DOT_MATCHES_ALL),
mapper: Parser.(Collection<String>) -> To
) = object : Validator<From, To> {
override val description = descriptions
override val regex = regex
override val parse: Parser.(Collection<String>) -> To = mapper
}
This function allows working with multiple input values, such as repeated query parameters.
Typical Usage:
val ofStringSet = validatorMulti<String, Set<String>>(
"set of strings"
) { strings ->
strings.flatMap { it.split(",") }.toSet()
}
The stringValidatorMulti
Function
A specialized version for string inputs that return collections:
fun <To> stringValidatorMulti(
description: String,
regex: Regex = """^.+$""".toRegex(RegexOption.DOT_MATCHES_ALL),
mapper: Parser.(Collection<String>) -> To,
) = validatorMulti<String, To>(description, regex, mapper)
This combines the convenience of stringValidator
with the collection handling of validatorMulti
.
Typical Usage:
val ofTags = stringValidatorMulti<List<String>>(
"comma-separated tags"
) { params ->
params.flatMap { it.split(",") }
.map { it.trim() }
.filter { it.isNotEmpty() }
}
How Validators Work
Now that we understand the interface and creation functions, let's explore how validators operate at runtime.
Regex Validation
The first step in validation is pattern matching using the regex
property:
// Inside parameter handler code
if (!validator.regex.matches(value)) {
throw ValidationException("Value doesn't match pattern for ${validator.description}")
}
This provides a fast first-pass validation before more complex logic is applied. For example, checking that an email string has a basic email-like structure before attempting further validation.
Transformation Logic
After regex validation passes, the parse
function is called with the collection of parameter values:
// Inside parameter handler code
try {
return validator.parse(parser, values)
} catch (e: Exception) {
throw ValidationException("Failed to parse ${validator.description}: ${e.message}")
}
The parse function is responsible for:
- Handling single vs. multiple values
- Converting strings to the target type
- Performing business-specific validation
- Throwing exceptions for invalid inputs
The transformation typically has access to the Parser
instance, which provides useful utilities for working with common formats like JSON.
Error Handling
Validators report errors by throwing exceptions, which Snitch catches and converts to appropriate HTTP responses (typically 400 Bad Request).
This happens at several levels:
- Regex mismatch: Throws a
ValidationException
- Empty collection: Throws a
NoSuchElementException
from thesingle()
call - Custom validation: Validator-specific exceptions from the mapper function
Snitch provides automatic handling for all of these, generating clear error messages for API consumers.
The Parser's Role
You may have noticed that the validator functions all pass a Parser
instance to the mapper function. The Parser
is an interface for converting between strings and structured data:
interface Parser {
fun <T> fromJson(json: String): T
fun <T> toJson(value: T): String
fun <T : Enum<T>> String.parse(enumClass: Class<T>): T
}
This allows validators to leverage the application's JSON parser for complex transformations, particularly for request bodies.
Example using the Parser:
val ofUser = stringValidator<User>("user") {
parser.fromJson<User>(it)
}
This is particularly powerful for body validators, allowing seamless conversion between JSON strings and domain objects.
Custom Validators
While the factory functions cover most use cases, you can also implement the Validator
interface directly for complete control:
object UserIdValidator : Validator<String, UserId> {
override val description = "Valid user ID"
override val regex = """^[a-zA-Z0-9]{8,12}$""".toRegex()
override val parse: Parser.(Collection<String>) -> UserId = { collection ->
val value = collection.first()
// Custom validation logic
if (!userRepository.exists(value)) {
throw IllegalArgumentException("User ID does not exist")
}
UserId(value)
}
}
This approach is useful when:
- You need complex validation logic
- You want to encapsulate validation in a self-contained object
- You need to inject dependencies (like repositories) into the validator
Validator Internals
Let's explore what happens when a validator is used with a parameter:
val userId by path(ofUUID)
Here's the sequence of events:
- The
path
function creates aParameter
object, storing the validator - When a request arrives, Snitch extracts the raw path parameter value
- The validator's regex is checked against the value
- If the regex matches, the parse function is called
- The parse function converts the string to a UUID
- The result is cached and made available via
request[userId]
If any step fails, the request processing is halted, and an error response is returned to the client.
Best Practices
Based on the internal workings of validators, here are some best practices:
-
Use specific regex patterns: The more specific your regex, the faster you can reject invalid inputs
-
Keep transformation functions pure: Avoid side effects in mapper functions for easier testing and reasoning
-
Provide clear error messages: When throwing exceptions, include specific details about why validation failed
-
Define domain-specific validators: Create validators for your domain types to encapsulate validation logic
-
Compose validators: Build complex validators by combining simpler ones
-
Avoid heavy computation in validators: Validators run on every request, so keep them efficient
-
Use the optional() method: For truly optional parameters, apply
optional()
to your validator instead of handling nullability in mapper functions
Putting It All Together
Let's see a complete example of a custom validator used in an endpoint:
// Domain type
data class UserId(val value: String)
// Custom validator
val ofUserId = validator<String, UserId>(
"valid user ID",
"""^[a-zA-Z0-9]{8,12}$""".toRegex()
) {
if (it.length < 8 || it.length > 12) {
throw IllegalArgumentException("User ID must be 8-12 characters long")
}
if (!it.matches("""^[a-zA-Z0-9]*$""".toRegex())) {
throw IllegalArgumentException("User ID must contain only letters and numbers")
}
UserId(it)
}
// Parameter definition
val userId by path(ofUserId)
// Route with validated parameter
GET("users" / userId) isHandledBy {
// UserId is already validated and transformed
val id: UserId = request[userId]
userRepository.findById(id).ok
}
This approach ensures:
- Early validation at the HTTP layer
- Type-safe access to domain types
- Clean separation of validation and business logic
- Clear error messages for API consumers
Conclusion
The validator DSL in Snitch provides a powerful, type-safe way to transform raw HTTP inputs into domain types. By understanding its internal workings, you can create more robust, maintainable APIs with clear error handling and strong type safety.
Remember that validators aren't just about rejecting invalid inputs—they're about bridging the gap between the untyped world of HTTP and the strongly-typed world of your domain model.