This guide will help you quickly set up a basic web service using Snitch.
Add Snitch to your project dependencies:
dependencies {
implementation("io.github.memoizr:snitch-bootstrap:4.0.1")
}
Create a simple "Hello World" service:
import snitch.gson.GsonJsonParser
import snitch.dsl.snitch
import snitch.dsl.routes
import snitch.dsl.response.ok
fun main() {
snitch(GsonJsonParser)
.onRoutes {
GET("hello") isHandledBy { "world".ok }
}
.start()
.serveDocumenation()
}
This creates a service that:
- Responds with "world" when you make a GET request to
/hello
- Automatically generates API documentation available at
/docs
Let's create a more realistic example with multiple endpoints:
import snitch.gson.GsonJsonParser
import snitch.dsl.*
import snitch.dsl.response.*
// Define our data classes
data class User(val id: String, val name: String, val email: String)
data class CreateUserRequest(val name: String, val email: String)
// In-memory storage for this example
val users = mutableMapOf<String, User>()
fun main() {
snitch(GsonJsonParser)
.onRoutes {
"users" / {
// GET /users - List all users
GET() isHandledBy {
users.values.toList().ok
}
// POST /users - Create a new user
POST() with body<CreateUserRequest>() isHandledBy {
val id = java.util.UUID.randomUUID().toString()
val user = User(id, body.name, body.email)
users[id] = user
user.created
}
// GET /users/{userId} - Get a specific user
userId / {
GET() isHandledBy {
val id = request[userId]
users[id]?.ok ?: "User not found".notFound()
}
// DELETE /users/{userId} - Delete a user
DELETE() isHandledBy {
val id = request[userId]
if (users.containsKey(id)) {
users.remove(id)
"User deleted".ok
} else {
"User not found".notFound()
}
}
}
}
}
.start()
.serveDocumenation()
}
// Define a path parameter
val userId by path()
Let's enhance our API with parameter validation:
// Define validated parameters
val limit by query(ofNonNegativeInt(max = 30, default = 10))
val offset by query(ofNonNegativeInt(default = 0))
val email by query(ofEmail)
// Define our own custom validator
val ofEmail = stringValidator("valid email") {
it.contains("@") && it.contains(".")
}
// Use in routes
"users" / {
// GET /users?limit=10&offset=0
GET() with listOf(limit, offset) isHandledBy {
users.values
.toList()
.drop(request[offset])
.take(request[limit])
.ok
}
// GET /users/[email protected]
"search" / {
GET() with email isHandledBy {
val searchEmail = request[email]
users.values
.filter { it.email == searchEmail }
.toList()
.ok
}
}
}
Implement a simple logging middleware:
// Simple logging middleware that doesn't require parameters
val Router.log get() = decorateWith {
println("➡️ ${request.method} ${request.path} - Request started")
val response = next()
println("⬅️ ${request.method} ${request.path} - Response: ${response.statusCode}")
response
}
// Apply middleware to routes
routes {
log {
"users" / {
// All user routes will be logged
GET() isHandledBy { users.values.toList().ok }
// ...
}
}
}
Implement a basic authentication system:
// Define the header parameter for authentication
val accessToken by header(
condition = validAccessToken,
name = "Authorization",
description = "Bearer token for authentication"
)
// Validator for access token
val validAccessToken = stringValidator { token ->
if (token.startsWith("Bearer ")) {
val actualToken = token.substring(7)
if (isValidToken(actualToken)) {
Authentication.Authenticated(JWTClaims(getUserId(actualToken), getRole(actualToken)))
} else {
Authentication.InvalidToken
}
} else {
Authentication.MissingToken
}
}
// Authentication result model
sealed interface Authentication {
data class Authenticated(val claims: JWTClaims) : Authentication
interface Unauthenticated : Authentication
object InvalidToken : Unauthenticated
object MissingToken : Unauthenticated
}
// Data class for JWT claims
data class JWTClaims(val userId: UserId, val role: Role)
data class UserId(val value: String)
enum class Role { USER, ADMIN }
// Authentication middleware with proper parameter declaration
val Router.authenticated get() = decorateWith(accessToken) {
when (val auth = request[accessToken]) {
is Authentication.Authenticated -> {
next() // Proceed to the handler
}
is Authentication.Unauthenticated -> "Authentication required".unauthorized()
}
}
// Extension properties to access authentication data
val RequestWrapper.principal: UserId get() =
(request[accessToken] as Authentication.Authenticated).claims.userId
val RequestWrapper.role: Role get() =
(request[accessToken] as Authentication.Authenticated).claims.role
// Apply to protected routes
routes {
"public" / {
// Public endpoints...
}
"api" / {
authenticated {
// Protected endpoints...
"profile" / {
GET() isHandledBy {
getUserProfile(request.principal).ok
}
}
// Example of using principal in a handler
"posts" / {
GET() isHandledBy {
getPostsByUser(request.principal).ok
}
POST() with body<CreatePostRequest>() isHandledBy {
createPost(request.principal, body.title, body.content).created
}
}
}
}
}
Implement access control with conditions:
// Define conditions
val isAdmin = condition("isAdmin") {
if (request.role == Role.ADMIN) {
ConditionResult.Successful
} else {
ConditionResult.Failed("Admin access required".forbidden())
}
}
// Condition to check if the user is the owner of a resource
fun isOwner(resourceIdParam: Parameter<String, *>) = condition("isOwner") {
val resourceId = request[resourceIdParam]
val resource = getResourceById(resourceId)
if (resource?.ownerId == request.principal.value) {
ConditionResult.Successful
} else {
ConditionResult.Failed("You don't have permission to access this resource".forbidden())
}
}
// Apply conditions to endpoints
routes {
authenticated {
// Admin-only endpoint
"admin" / {
GET("dashboard") onlyIf isAdmin isHandledBy {
getAdminDashboard().ok
}
}
// User can only access their own posts
"posts" / postId / {
GET() onlyIf isOwner(postId) isHandledBy { getPost() }
PUT() onlyIf isOwner(postId) with body<UpdatePostRequest>() isHandledBy { updatePost() }
DELETE() onlyIf isOwner(postId) isHandledBy { deletePost() }
}
}
}
Snitch provides a clean way to define handler functions that can access the request context:
// Define a path parameter
val postId by path()
// Handler for getting a post
private val getPost by handling {
postsRepository().getPost(PostId(request[postId]))
?.toResponse?.ok
?: "Post not found".notFound()
}
// Handler for deleting a post
private val deletePost by handling {
postsRepository().deletePost(request.principal, PostId(request[postId]))
.noContent
}
// Handler for getting all posts for the current user
private val getPosts by handling {
postsRepository().getPosts(request.principal)
.toResponse.ok
}
// Handler with request body parsing
private val createPost by parsing<CreatePostRequest>() handling {
postsRepository().putPost(
CreatePostAction(
request.principal,
PostTitle(body.title),
PostContent(body.content),
)
).mapSuccess {
SuccessfulCreation(value).created
}.mapFailure {
FailedCreation().badRequest()
}
}
// Usage in routes
routes {
authenticated {
"posts" / {
GET() isHandledBy getPosts
POST() with body<CreatePostRequest>() isHandledBy createPost
postId / {
GET() isHandledBy getPost
DELETE() isHandledBy deletePost
}
}
}
}
Test your endpoints with the built-in testing DSL:
class UserApiTest : SnitchTest({ port -> setupApp(port) }) {
@Test
fun `get all users returns 200`() {
GET("/users")
.expectCode(200)
.expectBodyContains("[]") // Initially empty
}
@Test
fun `create user returns 201`() {
POST("/users")
.withBody("""{"name":"John","email":"[email protected]"}""")
.expectCode(201)
.expectBodyContains("John")
}
}
Now that you have a basic understanding of Snitch, explore:
- Documentation Generation: Learn how to enhance your API documentation
- Error Handling: Implement global exception handlers
- Custom Validators: Create complex validation rules
- Coroutines: Use Kotlin coroutines for asynchronous operations
For more details, check out: