Snitch is a small and typesafe web framework for Kotlin
fun main() {
snitch(GsonJsonParser).onRoutes {
GET("hello") isHandledBy { "world".ok }
}.start()
}
- Lightweight and fast.
- Functional approach
- OpenAPI 3 support
- Fully asynchronous execution
- Plain Kotlin. No reflection, code generation, annotation processing.
- Kotlin compiler is enough. No Gradle plugins
dependencies {
implementation("io.github.memoizr:snitch-bootstrap:4.0.1")
}
That's it, no need for command line tools, gradle plugins. It's just a simple library.
val root = routes {
GET("foo") isHandledBy {
"bar".ok
}
POST("foo") with body<FooRequest>() isHandledBy {
"fooValue: ${body.fooValue}".created
}
}
The infix style is optional and a classic fluent approach is also supported.
val root = routes {
GET("foo").isHandledBy {
"bar".ok
}
POST("foo")
.with(body<FooRequest>())
.isHandledBy {
"fooValue: ${body.fooValue}".created
}
}
Notice that GET("/foo")
and GET("foo")
are the same thing
You pass the router to the onRoutes
function
fun main() {
snitch(GsonJsonParser).onRoutes(root).start()
}
Of course in a real application you'd like to separate the route declarations from the endpoint implementations.
val root = routes {
GET("foo") isHandledBy getFoo
POST("foo") with body<FooRequest>() isHandledBy postFoo
}
val getFoo by handling {
"bar".ok
}
val postFoo by parsing<FooRequest>() handling {
"fooValue: ${body.fooValue}".created
}
Services often have hundreds of routes, organized hierarchically. This can be modeled in Snitch:
val root = routes {
"health" / healthController
"users" / usersController
"posts" / postsController
...
}
val usersController = routes {
POST() with body<CreateUserRequest> isHandledBy createUser
userId / {
GET() isHandledBy getUser
DELETE() isHandledBy deleteUser
"posts" / {
GET() isHandledBy getPosts
POST() with body<CreatePostRequest> isHandledBy createPost
postId / {
GET() isHandledBy getPost
}
}
}
}
This will define the following routes:
POST users
GET users/{userId}
DELETE users/{userId}
GET users/{userId}/posts
POST users/{userId}/posts
GET users/{userId}/posts/{postId}
Different teams however will have different styles that they endorse, so for those who would rather have a less DRY but more explicit route declaration, they can define the routes as:
val root = routes {
healthController
usersController
postsController
...
}
val usersController = routes {
POST("users") with body<CreateUserRequest> isHandledBy createUser
GET("users" / userId) isHandledBy getUser
DELETE("users" / userId) isHandledBy deleteUser
GET("users" / userId / "posts") isHandledBy getPosts
POST("users" / userId / "posts") with body<CreatePostRequest> isHandledBy createPost
GET("users" / userId / "posts" / postId) isHandledBy getPost
}
The DSL is flexible so for teams that would like a measured and hybrid approach they can define the routes howerver they wish. For example grouping by path for all the actions supported on it:
val root = routes {
healthController
usersController
postsController
...
}
val usersController = routes {
"users" / {
POST() with body<CreateUserRequest> isHandledBy createUser
}
"users" / userId / {
GET() isHandledBy getUser
DELETE() isHandledBy deleteUser
}
"users" / userId / "posts" / {
GET() isHandledBy getPosts
POST() with body<CreatePostRequest> isHandledBy createPost
}
"users" / userId / "posts" / postId / {
GET() isHandledBy getPost
}
}
val userId by path()
val showDetails by query(ofBoolean)
val root = routes {
GET("users" / userId / "profile")
.with(showDetails)
.isHandledBy {
val user = UserId(request[userId])
if (request[showDetails]) {
usersRepository().profileDetails(user)
} else {
usersRepository().profileSummary(user)
}.ok
}
}
Note: userId
and showDetails
are typed and validated. request[showDetails]
will return a Boolean
and request[userId]
will return a String
. If you don't pass a Validator
such as ofBoolean
, it defaults to ofNonEmptyString
.
Note: you have to declare the usage of a certain parameter in order to use it.
All parameters are validated and transformed to another type by default. Here's some more examples, let's add the type parameters explicitly so it's clear what's happening:
val userId: String by path(nonEmptyString)
val filters: Set<String> by path(ofNonEmptySet)
val showDetails: Boolean by path(ofBoolean)
Although there are a few built in validator-transformers, they offer a relatively weak typing. Best practice involves transforming and validating raw platform types into domain types. For example a userId
is rarely actually just a string, for example it's unlikely the content of Shakespeare.txt
parsed as string could possibly be a valid ID for a user. You most likely have a value class UserId
defined somewhere. Likewise, a search filter is usually something like an Enum
where you have a set of pre-determined filter values.
Defining custom validator-transformers in snitch is simple:
value class UserId(val id: UUID)
enum class Filter { EXPIRED, ACTIVE, CANCELLED, PENDING }
val ofUserId = validator<String, UserId> { UserId(UUID.fromString(it)) }
// explicit types can be omitted for conciseness, here included for illustrative purposes
val userId: UserId by path(ofUserId)
val filters: Collection<Filter> by query(ofRepeatableEnum<Filter>())
val filter: Filter by query(ofEnum<Filter>())
Note: Snitch is optimized for production code use cases, and in the spirit of Kotlin, it enforces best practices. In production, you almost always need to validate and transfrom inputs consistently. Snitch lets you do this in only one line of code in most cases, leading to a more concise, explicit and consistent codebase, making it easier to maintain larger codebases and for new developers to quickly become productive.
Declaring a parameter with query
or header
will make it required. If the parameter is not supplied a 400
message will be returned specifying that that particular parameter was expected but not provided, as well as any other parameter that also does not pass validation. Optional parameters can be declared as such:
// request[sort] is nullable
val sort: Sorting? by optionalQuery(ofEnum<Sorting>())
The optionality functionality is quite powerful, offering a clear and consistent way of specifying default values as well as defining a behaviour for when these values are provided as empty as or as invalid inputs:
// request[sort] is not nullable, NEW is the default value
val sort: Sorting by optionalQuery(ofEnum<Sorting>(), default = NEW)
val limit: Int by optionalQuery(ofNonNegativeInt, default = 20, emptyAsMissing = true, invalidAsMissing = true)
val offset: Int by optionalQuery(ofNonNegativeInt, default = 0, emptyAsMissing = true, invalidAsMissing = true)
Snitch aims at being as concise and as less verbose as possible while delivering a full feature set for production use-cases. In this spirit when you define an input parameter such as val q by query()
it will create a named query parameter that should be supplied as such for example:?q=urlencodedquery
. Note that the name of the parameter val
in the codebase is by default the same name as in the API. If you want it to be different, it's simple:
val searchQuery by query(name = "searchQuery")
limit
and offset
here are defined so that if these parameters were not provided, or provided incorrectly, a default value would be provided instead. This is in case a "fail quietly" behaviour is desired. By default, a fail explicitly
behaviour is supported, so empty or invalid inputs will return a 400 to inform the API user they're probably doing something wrong.
While Snitch enforces best practices, leading to a less verbose and more consistent codebase that implements them, it also supports an unsafe traditional approach. If you want to access a parameter sneakily, and you don't care for the parameter to be included in the documentation, you can do it very simply with the cowboy-friendly syntax:
val getCows by handling {
...
request.queryParams("numberOfCows")
request.headerParams("ranch")
request.pathParams("ranchId")
...
}
Although this approach is supported for niche use cases, it is strongly discouraged that this be used for most production applications unless there is a good reason for it.
In HTTP one of the hidden challenges to creating a robust and production grade API is that of handling the edge case of query or header parameters provided repeatedly when exactly one or at most one is expected. By default val searchQuery by query()
expects exactly one value being provided and val searchQuery by optionalQuery()
provides at most one semantics, unexpected repetition will result in 400. Support for repeated parameters can be made explicity by using ... by query(ofStringSet)
for example, which uses a repeatable validator. Custom validator for repeatable can be created in a very similar way to non-repeatable validators:
val ofUserId = repeatableValidator<String, UserId> { UserId(UUID.fromString(it)) }
Body parameters are treated differently than other input parameters as they are used in different ways. While it's common to share the same query parameters or headers between several endpoints (for example consider limit
, offset
, orderBy
, Access-Token
and so on), body parameters are often single use. Snitch aims at encouraging best practices while reducing verbosity and clutter as much as possible, and in that spirit body parameter types are declared very simply:
POST("mypath") with body<MyRequest>() isHandleBy {
// already parsed to MyRequest domain type
request.body
}
This approach is typesafe, so if you were to omit the declaration of the body type, it would not be possible for you to access it within the handler:
POST("mypath") isHandleBy {
// this resolves to Kotlin's Nothing special type and would not compile
request.body
}
Binary path can also be supported inituitively by: with(body<ByteArray>())
Snitch supports a very powerful and flexible middleware mechanism that can be used to implement a wide variety of features. Let's see how you can use it to create a simple logging behaviour applied to a route hierarchy:
val Router.log get() = decorateWith {
logger().info("Begin Request: ${request.method.name} ${request.path}")
next().also {
logger().info("End Request: ${request.method.name} ${request.path} ${it.statusCode.code} ${it.value(parser)}")
}
}
here log
is a custom defined middleware logging behaviour. Its usage is very intuitive, and it's clear that such behaviour should be applied to any route defined within its block. Defining a new middleware is as straightforward as possible, here's the identity middleware, that simply calls the next action:
val Router.identity get() = decorateWith { next() }
the code block provided to decorateWith
works similarly to the way handlers work, you can still access the request parameter in the same way with request[myParam]
and can return responses with ok
created
badRequest()
etc like in normal handlers.
Calling next()
executes the code in the block of any nested middleware until it gets to the code block of the handler. next()
returns the response from the next layer of the middleware and as such it can be transformed as appropriate.
The order of execution, that is, what code is executed by the next()
call, is dependent on the order of declaration. It works as your intuition would expect, inside out, from most nested to least nested:
//called second
log {
// called first
statistics {
GET() ...
}
}
middleware allows for the implementation of powerful and granular access control systems. Here's a realistic example:
val Router.authenticated
get() = transformEndpoints {
with(listof(accessToken)).decorate {
when (request[accessToken]) {
is Authentication.Authenticated -> next()
is Authentication.Unauthenticated -> UNAUTHORIZED()
}
}
}
val accessToken: Authentication by header(validAccesstoken)
val validAccesstoken = validator<String, Authentication> { jwt().validate(it) }
sealed interface Authentication {
data class Authenticated(val claims: JwtClaims) : Authentication
interface Unauthenticated : Authentication
object InvalidToken : Unauthenticated
object ExpiredToken : Unauthenticated
object MissingToken : Unauthenticated
object InvalidClaims : Unauthenticated
}
and this is how this is used:
authenticated {
...
GET(userId / "posts") isHandledBy getPosts
...
}
}
now, there's a lot to unpack in a few lines of code, let's break it down:
val Router.authenticated
get() = decorateEndpoints {
withHeader(accessToken).decorate {
when (request[accessToken]) {
is Authentication.Authenticated -> next()
is Authentication.Unauthenticated -> UNAUTHORIZED()
}
}
}
decorateEndpoints
will apply whatever transformation inside the block to any endpoint to which this will be applied. withHeader(accessToken)
is declaring and adding the accessToken
header parameter to the endpoints, documentation will reflect that. request[accessToken]
parses, validates and transforms the access token provided in the headers. It returns a domain type, and we can proceed to the next layer of middleware in case the token is valid, and return a 401 error in case it is not.
Snitch integrates seamlessly with Shank, the highest-performing dependency injection library available for the JVM. Shank provides best-in-class performance with strictly type-safe dependency management, built-in cycle detection, and zero reflection overhead - all in a lightweight 300kb package.
Organize your dependencies by creating modules:
import snitch.shank.ShankModule
import snitch.shank.single
import snitch.shank.new
// Application-wide dependencies
object ApplicationModule : ShankModule {
val clock = single { -> Clock.systemUTC() }
val logger = single { -> LoggerImpl() }
val now = new { -> Instant.now(clock()) }
}
// Database-related dependencies
object DatabaseModule : ShankModule {
val connection = single { ->
Database.connect(
"jdbc:postgresql://localhost:5432/postgres",
driver = "org.postgresql.Driver",
user = "postgres",
password = "postgres"
)
}
val database = single { -> PostgresDatabase(connection()) }
}
// Repository dependencies
object RepositoryModule : ShankModule {
val usersRepository = single<UsersRepository> { -> PostgresUsersRepository() }
val postsRepository = single<PostsRepository> { -> PostgresPostsRepository() }
}
Inject dependencies into your handlers:
val getUsers by handling {
val logger = ApplicationModule.logger()
val usersRepo = RepositoryModule.usersRepository()
logger.info("Fetching all users")
usersRepo.getUsers().ok
}
val createUser by parsing<CreateUserRequest>() handling {
val usersRepo = RepositoryModule.usersRepository()
usersRepo.createUser(body.name, body.email).created
}
Combine middleware with dependency injection for powerful patterns:
val Router.withLogging get() = decorateWith {
val logger = ApplicationModule.logger()
logger.info("Request: ${request.method} ${request.path}")
next().also {
logger.info("Response: ${it.statusCode}")
}
}
val Router.withTransaction get() = decorateWith {
val db = DatabaseModule.database()
db.transaction {
next()
}
}
Then use these in your routes:
routes {
withLogging {
withTransaction {
"users" / {
GET() isHandledBy getUsers
POST() with body<CreateUserRequest>() isHandledBy createUser
}
}
}
}
For a comprehensive guide to using Shank with Snitch, see the Using Shank with Snitch guide.
Snitch is an HTTP focused tool, and as such it abstains from offering solutions to non-HTTP problems such as deeply integrating with databases. We believe it is better to leave that job to specialized tools such as Jooq or Exposed. That said what snitch does offer is an extremely easy way of integrating with such tools. For example, here's how simple it is to declare that endpoints within a given hierarchy should all execute the code within an Exposed
transaction:
withTransaction {
POST() with body<CreateUserRequest>() isHandledBy createUser
POST("login") with body<LoginRequest>() isHandledBy userLogin
userId / "posts" / {
authenticated {
GET() onlyIf principalEquals(userId) isHandledBy getPosts
POST() onlyIf principalEquals(userId) with body<CreatePostRequest>() isHandledBy createPost
GET(postId) isHandledBy getPost
PUT(postId) with body<UpdatePostRequest>() onlyIf principalEquals(userId) isHandledBy updatePost
DELETE(postId) onlyif (principalEquals(userId) or hasAdminRole) isHandledBy deletePost
}
}
}
here's how withtransaction is implemented:
// transaction {} from Exposed framework
val Router.withTransaction get() = decorateWith { transaction { next() } }
The ease with which this feature can be implemented is a testament to the power and flexibility of middleware. This can also be done in a granular way, by endpoint:
GET() decorated withExposedTransaction onlyIf principalEquals(userId) isHandledBy getPosts
and this is the declaration of this decoration, which can be reused across different endpoints:
val withExposedTransaction = decoration { transaction { next() } }
this code hardly needs any explanation, in the spirit of Snitch philosophy.
the transaction example was just one way of showing how the flexibility and power of the dsl makes it extremely convenient to integrate with purpose built tools for database and other purposes. Snitch focuses on HTTP, but it seamlessly integrates with other tools with other focuses.
Still on top of the same underlying mechanism we've built a powerful and granular guard mechanism, here's an example of it at work:
val requestWrapper.role: Role get() = (request[accessToken] as Authentication.Authenticated).claims.role
val hasAdminRole = condition {
when (role) {
ADMIN -> Successful()
else -> Failed(FORBIDDEN())
}
}
and this is how it's used
DELETE(postId) onlyIf hasAdminRole isHandledBy deletePost
onlIf
takes a condition which can be either Successful
or Failed
and will either proceed with the request or terminate early accordingly.
This offers a high degree of granularity when specifying access control as applied to individual routes.
Conditions are composable and support basic boolean logic operations:
DELETE(postId) onlyIf (principalEquals(userId) or hasAdminRole) isHandledBy deletePost
The code above hardly needs an explanation for what it's doing, despite the fact that it's not trivial behaviour.
here's the definition of principalEquals
:
fun principalEquals(param: Parameter<out Any, *>) = condition {
if (principal.value == request[param]) Successful()
else Failed(FORBIDDEN())
}
val RequestWrapper.principal: UserId get() = (request[accessToken] as Authentication.Authenticated).claims.userId
Although it's possible to customize each and every endpoint to lock it down to the exact security guarantees your business logic needs to enforce, it's often the case that you need to share the same guard logic across several endpoints. Snitch offers two ways of doing this, the first one is obvious:
val ownerOrAdmin = principalEquals(userId) or hasAdminRole
DELETE(postId) onlyIf ownerOrAdmin isHandledBy deletePost
The second one is even more generic, as it can be applied to an entire sub-hierarchy of routes. It works similarly to how middleware does:
onlyIf(principalEquals(userId) or hasAdminRole) {
...
DELETE(postId) isHandledBy deletePost
PATCH(postId) with body<UpdatePostRequest>() isHandledBy updatePost
...
}
Note that this approach to Guards is in line with what we call "Snitch's way" or "Snitchy". Of course good old imperative checks inside the handler are still possible and supported, and in some cases that's the best thing to do. But sticking to Snitch's way allows for more consistent, readable and manageable codebases at any scale.
Although Snitch encourages a more functional approach to errors, it also supports global exception handling for both unexpected behaviour and for flow control.
snitch(GsonJsonParser)
.onRoutes(root)
.handleException(MyException::class) { exception ->
MyCustomErroResponse(exception.reason)
.also { logger().error(it.toString()) }
.badRequest()
}
.start()
Note that the body of the exception handler works like the normal handlers', with the only difference that it has a referfence to the exception being handled, thie it
of the lambda, which can be optionally named as in the example above. Note that it's not necessary to return an error response, it's possible to return an alternative successful response instead. You can see that there is a lot of functionality packed in a small amount of code, yet it still remains fairly intuitive and readable.
Note that error handling is polymorphic, so if MyException
extends MyBaseException
.handleException(MyBaseException::class)...
would handle MyException
as well as any other subclass of MyBaseException
. For this reason, ordering of the declaration of exception handlers matters. You should always put the most specific handlers first, otherwise a more generic polymorphic handler would handle the exception instead. Note that this feature implementation relies on some reflection, and while it's relatively efficient, it's not as efficient as a more functional approach. For that reason this should not be used as a main flow control mechanism for performance critical applications.
Snitch supports a fluent and concise integration testing DSL. In accordance with the rest of the library, it is designed with simplicity, intuitiveness and readability in mind. The expressiveness and simplicity is aimed at encouraging a TDD approach. Here is what simple tests would look like, including an example of the base class and application object:
class HealthTest : BaseTest() {
@Test
fun `foo does bar`() {
GET("/foo/bar")
.expectCode(200)
.expectBody("""{"status": "ok"}""")
}
@Test
fun `post`() {
POST("/foo/bar")
.withBody(myRequestBody)
.expectCode(200)
.expectBody("""{"status": "ok"}""")
}
}
@TestInstance(Testinstance.Lifecycle.PER_CLASS)
abstract class BaseTest : SnitchTest({ Application.setup(it) }) {
@BeforeAll
fun beforeAll() = super.before()
@AfterAll
fun afterAll() = super.after()
}
object Application {
fun setup(port: Int): RoutedService =
snitch(GsonJsonParser, snitchConfig(Service(port = port)))
.onRoutes(rootRouter)
.handleExceptions()
}
please refer to the example
module in the repository source code for more in-depth examples involving real-world use cases complete with database setup and access, dependency injection and more.
Snitch has the best in class IntelliJ integration plugin and it ships by default with the IDE: Jetbrain's Kotlin plugin. that's all that's needed to unleash the full power of snitch and have autocompletion, syntax higlighting and so on. because snitch aims at being plain kotlin only, without reflection or annotation processing or code generation, the kotlin compiler is fully capable of understanding each aspect of the library and its uses. Snitch usage errors are more often than not resolved at compile time. One of the leading design principles of Snitch is that the user of the library should always be able to use the IDE to navigate to middleware definitions, follow the nesting of routes upsream and downstream, and so on. A user should never be in the position of not knowing what some code does. They might not necessarily understand every aspect of how the internals work, but they should at the very least be able to see the internals, and explore them with their IDE. The pure kotlin approach makes this easy.
It is often the case that your API will need versioning for backwards compatibility with deployed clients. There are several approaches to versioning. A common and annoying problem is handling relatively minor version differences on only a subset of endpoints. Setting up a whole separate path hierarchy may be an overkill in such cases. E.g you want to have these routes:
GET /v1/hey/there/foo
GET /v2/hey/there/foo
GET /v1/hey/all/bar
This is how easy it is to extend Snitch's DSL to support this type of versioning:
val baseVersion = "v1"
baseVersion / {
"hey" / {
"there" / {
GET("foo") isHandledBy { "this is foo v1".ok }
GET("foo").v2 isHandledBy { "this is foo v2".ok }
}
"all" / {
GET("bar") isHandledBy { "this is bar v1".ok }
}
}
}
// demo only, use a more robust path editing logic for production
val <T : Any> Endpoint<T>.v2 get() = copy(path = path.replace("/$baseVersion/", "/v2/"))
And that's it. Endpoints are data classes and can be customized like any other data class in Kotlin.
This style is also supported, if you prefer:
GET("foo") v 2 isHandledBy { "this is foo v2".ok }
This uses an extension function instead of an extension property.
// demo only, use a more robust path editing logic for production
infix fun <T: Any> Endpoint<T>.v(version: Int) = copy(
path = path.replace("/$baseVersion/", "/v$version/")
)
Snitch is asynchronous by default, and as such it works well with coroutines. You can use your suspend functions just like normal code, just use a coHandling
or isCoHandledBy
.
val getUsers by coHandling {
usersRepository().getUsersAsync().ok
}
GET("users") isHandledBy getUsers
GET("posts") isCoHandledBy { postsRepository().getPostsAsync().ok }
Coroutine support are not included by default, so you need to add this to your gradle build file:
dependencies {
implementation("io.github.memoizr:snitch-coroutines:4.0.1")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
}
}
Depending on usage patterns we might include coroutines as part of the main API and thus remove the need for the coHandling
and isCoHandledBy
functions and extra gradle code.
Snitch is a thin layer on top of the underlying HTTP server. By default, that's Undertow, so Snitch will handle concurrency in the samw way Undertow does.
Snitch is a small library with very little overhead on top of the HTTP server. By default that's Undertow, and multiple benchmarks have put Undertow consistently in the top 3 servers in the JVM for years. Undertow is also one of the lightest embedded HTTP servers. In short, Snitch is in practice as fast and as lightweight as Undertow is.
Snitch uses information from the type system to know everything there is to know about an endpoint and its inputs and outputs.
Snitch enforces explicit declarations on any inputs and outputs for each endpoint, and these declarations include type information. This way when the inputs are used inside the handlers, they are already verified and transformed, and any unexpected inputs automatically and consistently dealt with.
It doesn't and it won't. Snitch is designed for highly scalable stateless applications. Session management, caching and any other stateful solution, can and should be implemented at the infrastructure layer.
Yes it does as long as X has a Java api. The middleware system's flexibility makes it easy to add support for X. No Snitch does not ship with X support by default because your needs are most likely than not unique and you'd rather have an easy way of doing it your way than a default that won't suit you.
Snitch is a low overhead, fast execution framework. It is stateless by default, and it's designed for deployment in clusters. Snitch once containerized and deployed in Kubernetes, ECS or analogous clusters will scale to fit any requirement. Most likely your limiting factor will be your database system.
Snitch offers both Validators, Guards and Middleware as powerful tools to implement granular and effective security. Used in combination, these features will greatly enhance the security of your endpoints. Refer to the documentation to learn how to use these features. Do keep in mind security is a multi-layer issue, so do not rely exclusively on application layer security for your critical use cases.
How does Snitch compare to other Kotlin web frameworks like Ktor or Spring Boot in terms of performance and ease of use?
Snitch was developed keeping a variety of other frameworks in mind, shamelessly taking inspiration from them. As such it is aimed at improving the development experience over those frameworks too. In the case of Spring, we believe that framework is overly complicated for most use cases. The "convention over configuration" approach most often than not shifts the burden from the explicit, to the implicit, greatly aggravating cognitive load. KTor is somewhat better in that regard, but we believe it does not go far enough and does not exploit Kotlin's full potential, despite its pedigree. Snitch is aimed at being the most powerful simple tool you can use while retaining excellent readability qualities and a minimal API.