Conditions
Conditions are one of Snitch's most powerful features, allowing you to implement sophisticated access control and request validation with minimal code. This tutorial will guide you through everything you need to know about conditions, from basic usage to advanced patterns.
Understanding Conditions
In Snitch, a condition is a predicate that evaluates a request and determines whether it should proceed or be rejected. Conditions are represented by the Condition
interface, which has three key components:
- Description: A human-readable description of what the condition checks
- Transform function: A function that can modify an endpoint (usually for documentation purposes)
- Check function: The actual logic that evaluates the request
When a condition is applied to an endpoint using onlyIf
, it becomes part of the request processing pipeline. If the condition evaluates to Successful
, the request proceeds; if it evaluates to Failed
, the request is rejected with the specified error response.
Basic Condition Usage
The simplest way to use conditions is with the onlyIf
method on an endpoint:
GET("resource" / resourceId) onlyIf isResourceOwner isHandledBy { getResource() }
This ensures that the endpoint will only be accessible if the isResourceOwner
condition evaluates to Successful
.
Creating Custom Conditions
You can create custom conditions using the condition
factory function:
val hasAdminRole = condition("hasAdminRole") {
val role = (request[accessToken] as? Authentication.Authenticated)?.claims?.role
when (role) {
Role.ADMIN -> ConditionResult.Successful
else -> ConditionResult.Failed("Admin role required".forbidden())
}
}
The first parameter is the description, which will be used in documentation and error messages. The lambda receives a RequestWrapper
and should return a ConditionResult
.
Parameterized Conditions
You can create reusable condition factories that accept parameters:
fun hasMinimumAge(minAge: Int) = condition("hasMinimumAge($minAge)") {
val userAge = userRepository.getAge(request[userId])
if (userAge >= minAge) {
ConditionResult.Successful
} else {
ConditionResult.Failed("User must be at least $minAge years old".forbidden())
}
}
// Usage
GET("adult-content") onlyIf hasMinimumAge(18) isHandledBy { getAdultContent() }
Logical Operators
Snitch conditions support three logical operators:
AND (and
)
The and
operator creates a condition that succeeds only if both conditions succeed:
val canAccessResource = isAuthenticated and hasPermission
When evaluating an and
condition, if the first condition fails, the second one is not evaluated (short-circuit evaluation).
OR (or
)
The or
operator creates a condition that succeeds if either condition succeeds:
val canModifyResource = isResourceOwner or hasAdminRole
When evaluating an or
condition, if the first condition succeeds, the second one is not evaluated.
NOT (not
or !
)
The not
operator inverts a condition:
val isNotLocked = !isResourceLocked
You can combine these operators to create complex access rules:
val canEditDocument = isAuthenticated and (isDocumentOwner or hasEditorRole) and !isDocumentLocked
Applying Conditions to Route Hierarchies
You can apply conditions to entire route hierarchies using the onlyIf
block:
onlyIf(isAuthenticated) {
GET("profile") isHandledBy { getProfile() }
onlyIf(hasAdminRole) {
GET("admin/dashboard") isHandledBy { getDashboard() }
GET("admin/users") isHandledBy { getUsers() }
}
}
In this example, all routes require authentication, and the admin routes additionally require the admin role.
Short-Circuit Evaluation
Snitch's condition operators use short-circuit evaluation for efficiency:
- For
and
, if the first condition fails, the second is not evaluated - For
or
, if the first condition succeeds, the second is not evaluated
This is particularly useful when you have conditions with side effects or expensive operations:
// The database query will only run if the user is authenticated
val canAccessResource = isAuthenticated and hasPermissionInDatabase
You can test this behavior:
@Test
fun `short-circuits condition evaluation`() {
var secondConditionEvaluated = false
val trackingCondition = condition("tracking") {
secondConditionEvaluated = true
ConditionResult.Successful
}
given {
GET("short-circuit")
.onlyIf(alwaysFalse and trackingCondition)
.isHandledBy { "".ok }
} then {
GET("/short-circuit").expectCode(403)
assert(!secondConditionEvaluated) { "Second condition should not have been evaluated" }
}
}
Error Handling and Custom Responses
When a condition fails, it returns a ConditionResult.Failed
with an error response. You can customize this response:
val isResourceOwner = condition("isResourceOwner") {
if (principal.id == request[resourceId]) {
ConditionResult.Successful
} else {
ConditionResult.Failed(
ErrorResponse(
code = "FORBIDDEN",
message = "You don't have permission to access this resource",
details = mapOf("resourceId" to request[resourceId])
).error(StatusCodes.FORBIDDEN)
)
}
}
This allows you to provide detailed, context-specific error messages to clients.
Best Practices
1. Keep Conditions Focused
Each condition should check one specific thing. This makes them more reusable and easier to understand.
2. Use Descriptive Names
Choose condition names that clearly describe what they check:
// Good
val hasAdminRole = condition("hasAdminRole") { ... }
// Not as good
val adminCheck = condition("adminCheck") { ... }
3. Leverage Composition
Build complex access rules by composing simple conditions:
val canEditDocument = isAuthenticated and isDocumentOwner and !isDocumentLocked
4. Provide Helpful Error Messages
When a condition fails, the error message should help the client understand why:
ConditionResult.Failed("Resource not found or you don't have permission to access it".forbidden())
5. Document Conditions
Use the description parameter to document what the condition checks:
val hasPermission = condition("User has permission to access the resource") { ... }
This description will appear in the generated API documentation.
Real-World Examples
Authentication and Authorization
// Authentication
val isAuthenticated = condition("isAuthenticated") {
when (request[accessToken]) {
is Authentication.Authenticated -> ConditionResult.Successful
else -> ConditionResult.Failed("Authentication required".unauthorized())
}
}
// Authorization
val hasAdminRole = condition("hasAdminRole") {
val auth = request[accessToken] as? Authentication.Authenticated
?: return@condition ConditionResult.Failed("Authentication required".unauthorized())
when (auth.claims.role) {
Role.ADMIN -> ConditionResult.Successful
else -> ConditionResult.Failed("Admin role required".forbidden())
}
}
// Resource ownership
fun isResourceOwner(resourceIdParam: Parameter<String, *>) = condition("isResourceOwner") {
val auth = request[accessToken] as? Authentication.Authenticated
?: return@condition ConditionResult.Failed("Authentication required".unauthorized())
if (auth.claims.userId == request[resourceIdParam]) {
ConditionResult.Successful
} else {
ConditionResult.Failed("You don't own this resource".forbidden())
}
}
// Usage
val routes = routes {
onlyIf(isAuthenticated) {
GET("profile") isHandledBy { getProfile() }
"resources" / resourceId / {
GET() onlyIf isResourceOwner(resourceId) isHandledBy { getResource() }
PUT() onlyIf (isResourceOwner(resourceId) or hasAdminRole) isHandledBy { updateResource() }
DELETE() onlyIf (isResourceOwner(resourceId) or hasAdminRole) isHandledBy { deleteResource() }
}
onlyIf(hasAdminRole) {
GET("admin/dashboard") isHandledBy { getDashboard() }
GET("admin/users") isHandledBy { getUsers() }
}
}
}
Rate Limiting
fun rateLimit(maxRequests: Int, perTimeWindow: Duration) = condition("rateLimit($maxRequests per $perTimeWindow)") {
val clientIp = request.remoteAddress
val requestCount = rateLimiter.getRequestCount(clientIp, perTimeWindow)
if (requestCount <= maxRequests) {
ConditionResult.Successful
} else {
ConditionResult.Failed(
ErrorResponse(
code = "TOO_MANY_REQUESTS",
message = "Rate limit exceeded. Try again later.",
details = mapOf(
"maxRequests" to maxRequests,
"timeWindow" to perTimeWindow.toString(),
"retryAfter" to rateLimiter.getRetryAfter(clientIp)
)
).error(StatusCodes.TOO_MANY_REQUESTS)
)
}
}
// Usage
onlyIf(rateLimit(100, Duration.ofMinutes(1))) {
POST("api/v1/messages") isHandledBy { sendMessage() }
}
Feature Flags
fun featureEnabled(featureName: String) = condition("featureEnabled($featureName)") {
if (featureFlagService.isEnabled(featureName)) {
ConditionResult.Successful
} else {
ConditionResult.Failed("Feature not available".notFound())
}
}
// Usage
GET("new-feature") onlyIf featureEnabled("new-feature") isHandledBy { useNewFeature() }
By mastering Snitch's condition system, you can implement sophisticated access control and request validation with minimal code, keeping your routes clean and focused on business logic.