hello world
/**
* Colleen Framework - Comprehensive Example
*
* This file demonstrates all core features of the Colleen web framework:
* - Routing (lambda, function, controller styles)
* - Parameter extraction (path, query, form, JSON, header, cookie, file)
* - Data validation
* - Middleware (global, prefix-based, route-level)
* - Dependency injection
* - Error handling
* - Event system
* - Sub-applications
* - SSE (Server-Sent Events)
* - Testing with TestClient
*/
import io.github.cymoo.colleen.*
import io.github.cymoo.colleen.Post as POST
import io.github.cymoo.colleen.middleware.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.lang.reflect.Parameter
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
val logger: Logger = LoggerFactory.getLogger("Main")
// =============================================================================
// Domain Models
// =============================================================================
data class User(
val id: Int,
val name: String,
val email: String,
val role: String = "user",
)
data class Post(
val id: Int,
val authorId: Int,
val title: String,
val content: String,
val tags: List<String> = emptyList(),
val createdAt: Instant = Instant.now(),
)
data class Comment(
val id: Int,
val postId: Int,
val authorId: Int,
val body: String,
)
// Request / response DTOs
data class CreateUserRequest(val name: String, val email: String, val password: String)
data class UpdateUserRequest(val name: String?, val email: String?)
data class CreatePostRequest(val title: String, val content: String, val tags: List<String> = emptyList())
data class CreateCommentRequest(val body: String)
data class SearchParams(val q: String, val limit: Int = 10, val offset: Int = 0)
data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val token: String, val userId: Int)
data class UploadResult(val filename: String, val size: Long, val contentType: String?)
// =============================================================================
// Services
// =============================================================================
class UserService {
private val store = ConcurrentHashMap<Int, User>()
private val nextId = AtomicInteger(1)
init {
// Seed data
create("Alice", "alice@example.com")
create("Bob", "bob@example.com")
val admin = create("Admin", "admin@example.com")
store[admin.id] = admin.copy(role = "admin")
}
fun findAll(): List<User> = store.values.toList()
fun findById(id: Int): User = store[id] ?: throw NotFound("User $id not found")
fun findByEmail(email: String): User? = store.values.find { it.email == email }
fun create(name: String, email: String): User {
if (store.values.any { it.email == email }) throw Conflict("Email already in use")
val user = User(nextId.getAndIncrement(), name, email)
store[user.id] = user
return user
}
fun update(id: Int, name: String?, email: String?): User {
val existing = findById(id)
val updated = existing.copy(
name = name ?: existing.name,
email = email ?: existing.email,
)
store[id] = updated
return updated
}
fun delete(id: Int) {
findById(id) // throws 404 if missing
store.remove(id)
}
fun search(q: String, limit: Int, offset: Int): List<User> =
store.values
.filter { it.name.contains(q, ignoreCase = true) || it.email.contains(q, ignoreCase = true) }
.drop(offset)
.take(limit)
}
class PostService {
private val store = ConcurrentHashMap<Int, Post>()
private val nextId = AtomicInteger(1)
init {
create(1, "Hello Colleen", "First post about the Colleen framework.", listOf("kotlin", "web"))
create(2, "Dependency Injection", "How DI works in Colleen.", listOf("di", "kotlin"))
}
fun findAll(tags: List<String> = emptyList()): List<Post> {
val all = store.values.toList()
return if (tags.isEmpty()) all else all.filter { post -> tags.any { it in post.tags } }
}
fun findById(id: Int): Post = store[id] ?: throw NotFound("Post $id not found")
fun create(authorId: Int, title: String, content: String, tags: List<String> = emptyList()): Post {
val post = Post(nextId.getAndIncrement(), authorId, title, content, tags)
store[post.id] = post
return post
}
fun delete(id: Int) {
findById(id)
store.remove(id)
}
}
class CommentService {
private val store = ConcurrentHashMap<Int, Comment>()
private val nextId = AtomicInteger(1)
fun findByPost(postId: Int): List<Comment> = store.values.filter { it.postId == postId }
fun create(postId: Int, authorId: Int, body: String): Comment {
val comment = Comment(nextId.getAndIncrement(), postId, authorId, body)
store[comment.id] = comment
return comment
}
}
/** Simple in-memory token store — not for production use. */
class AuthService {
// token → userId
private val tokens = ConcurrentHashMap<String, Int>()
fun login(user: User): String {
val token = UUID.randomUUID().toString()
tokens[token] = user.id
return token
}
fun verify(token: String): Int =
tokens[token] ?: throw Unauthorized("Invalid or expired token")
fun logout(token: String) {
tokens.remove(token)
}
}
/** Simple metrics collector. */
class MetricsService {
val requestCount = AtomicInteger(0)
val errorCount = AtomicInteger(0)
fun snapshot() = mapOf(
"requests" to requestCount.get(),
"errors" to errorCount.get(),
)
}
// =============================================================================
// Custom Parameter Extractor — BearerToken
// =============================================================================
/**
* Extracts a Bearer token from the Authorization header.
*
* Usage in a handler:
* fun myHandler(token: BearerToken, authService: AuthService): ...
*/
class BearerToken(value: String?) : ParamExtractor<String?>(value) {
companion object : ExtractorFactory<BearerToken> {
private const val PREFIX = "Bearer "
override fun build(paramName: String, param: Parameter): (Context) -> BearerToken {
return { ctx ->
val raw = ctx.header("Authorization")
val token = raw
?.takeIf { it.startsWith(PREFIX, ignoreCase = true) }
?.substring(PREFIX.length)
?.trim()
?.takeIf { it.isNotEmpty() }
BearerToken(token)
}
}
}
/** Returns the token value or throws 401 if absent. */
fun require(): String = value ?: throw Unauthorized("Bearer token is required")
}
// =============================================================================
// Middleware
// =============================================================================
/**
* Authentication middleware.
* Resolves the current user from the bearer token and stores it in context state.
*/
class AuthMiddleware(private val requiredRole: String = "user") : Middleware {
override fun invoke(ctx: Context, next: Next) {
val raw = ctx.header("Authorization")
?.removePrefix("Bearer ")
?: throw Unauthorized("Missing Authorization header")
val authService = ctx.getService<AuthService>()
val userService = ctx.getService<UserService>()
val userId = authService.verify(raw)
val user = userService.findById(userId)
if (requiredRole == "admin" && user.role != "admin") {
throw Forbidden("Admin access required")
}
ctx.setState("currentUser", user)
next()
}
}
/**
* Metrics middleware — counts total requests and errors.
*/
val metricsMiddleware: Middleware = { ctx, next ->
val metrics = ctx.getService<MetricsService>()
metrics.requestCount.incrementAndGet()
next()
if (ctx.error != null) metrics.errorCount.incrementAndGet()
}
// =============================================================================
// Function-style Handlers
// =============================================================================
// ----- Auth -----
fun login(body: Json<LoginRequest>, userService: UserService, authService: AuthService): LoginResponse {
val req = body.value
val user = userService.findByEmail(req.email) ?: throw Unauthorized("Invalid credentials")
// Password check omitted for brevity
val token = authService.login(user)
return LoginResponse(token, user.id)
}
fun logout(token: BearerToken, authService: AuthService): Status {
authService.logout(token.require())
return Status(204)
}
// ----- Users -----
fun listUsers(userService: UserService): List<User> = userService.findAll()
fun getUser(id: Path<Int>, userService: UserService): User = userService.findById(id.value)
fun createUser(body: Json<CreateUserRequest>, userService: UserService): Result<User> {
val req = body.value
expect {
field("name", req.name).required().notBlank().minSize(2).maxSize(50)
field("email", req.email).required().email()
field("password", req.password).required().minSize(8)
.matches(Regex("^(?=.*[A-Z])(?=.*[0-9]).*$"))
.message("Password must contain at least one uppercase letter and one digit")
}
val user = userService.create(req.name, req.email)
return Result.created(user)
}
fun updateUser(id: Path<Int>, body: Json<UpdateUserRequest>, userService: UserService): User {
val req = body.value
expect {
field("name", req.name).maxSize(50)
field("email", req.email).email()
}
return userService.update(id.value, req.name, req.email)
}
fun deleteUser(id: Path<Int>, userService: UserService): Status {
userService.delete(id.value)
return Status(204)
}
fun searchUsers(params: Query<SearchParams>, userService: UserService): List<User> {
val p = params.value
return userService.search(p.q, p.limit, p.offset)
}
// ----- Posts -----
fun listPosts(tags: Query<List<String>>, postService: PostService): List<Post> =
postService.findAll(tags.value)
fun getPost(id: Path<Int>, postService: PostService): Post = postService.findById(id.value)
fun createPost(body: Json<CreatePostRequest>, ctx: Context, postService: PostService): Result<Post> {
val currentUser = ctx.getState<User>("currentUser")
val req = body.value
expect {
field("title", req.title).required().notBlank().maxSize(200)
field("content", req.content).required().notBlank()
}
val post = postService.create(currentUser.id, req.title, req.content, req.tags)
return Result.created(post)
}
fun deletePost(id: Path<Int>, ctx: Context, postService: PostService): Status {
val currentUser = ctx.getState<User>("currentUser")
val post = postService.findById(id.value)
if (post.authorId != currentUser.id && currentUser.role != "admin") {
throw Forbidden("You can only delete your own posts")
}
postService.delete(id.value)
return Status(204)
}
// ----- Comments -----
fun listComments(postId: Path<Int>, commentService: CommentService): List<Comment> =
commentService.findByPost(postId.value)
fun createComment(
postId: Path<Int>,
body: Json<CreateCommentRequest>,
ctx: Context,
postService: PostService,
commentService: CommentService,
): Result<Comment> {
val currentUser = ctx.getState<User>("currentUser")
postService.findById(postId.value) // verify post exists
val req = body.value
expect {
field("body", req.body).required().notBlank().maxSize(2000)
}
val comment = commentService.create(postId.value, currentUser.id, req.body)
return Result.created(comment)
}
// ----- File upload -----
fun uploadAvatar(file: UploadedFile): UploadResult {
val item = file.value ?: throw BadRequest("No file uploaded")
if ((item.size) > 2 * 1024 * 1024) throw BadRequest("File exceeds 2 MB limit")
return UploadResult(item.name, item.size, item.contentType)
}
// =============================================================================
// Controller-style Handlers — Admin
// =============================================================================
@Controller("/admin")
class AdminController(private val userService: UserService, private val metricsService: MetricsService) {
/** List all users (admin only). */
@Get("/users")
fun listUsers(): List<User> = userService.findAll()
/** Promote a user to admin (admin only). */
@POST("/users/{id}/promote")
fun promoteUser(id: Path<Int>): User {
val user = userService.findById(id.value)
// In a real app, this would persist the role change.
return user.copy(role = "admin")
}
/** Application metrics (admin only). */
@Get("/metrics")
fun metrics(): Map<String, Any> = metricsService.snapshot()
}
// =============================================================================
// Sub-application — API v1
// =============================================================================
fun buildApiV1App(
userService: UserService,
postService: PostService,
commentService: CommentService,
authService: AuthService,
): Colleen {
val api = Colleen()
// Services available within this sub-app
api.provide(userService)
api.provide(postService)
api.provide(commentService)
api.provide(authService)
// Sub-app level middleware: CORS + request ID for every /api/v1 request
api.use(Cors.permissive())
api.use(RequestId())
// ---- Auth endpoints (no auth required) ----
api.post("/auth/login", ::login)
api.post("/auth/logout", ::logout)
// ---- User endpoints ----
api.get("/users", ::listUsers)
api.get("/users/search", ::searchUsers)
api.get("/users/{id}", ::getUser)
// Write operations require authentication
api.post("/users", ::createUser)
api.put("/users/{id}")
.use(AuthMiddleware())
.handle(::updateUser)
api.delete("/users/{id}")
.use(AuthMiddleware())
.handle(::deleteUser)
// ---- Post endpoints ----
api.get("/posts", ::listPosts)
api.get("/posts/{id}", ::getPost)
api.post("/posts/{id}")
.use(AuthMiddleware())
.handle(::createPost)
api.delete("/posts/{id}")
.use(AuthMiddleware())
.handle(::deletePost)
// ---- Comment endpoints ----
api.get("/posts/{postId}/comments", ::listComments)
api.post("/posts/{postId}/comments")
.use(AuthMiddleware())
.handle(::createComment)
// ---- File upload ----
api.post("/users/avatar")
.use(AuthMiddleware())
.handle(::uploadAvatar)
// ---- SSE — live notifications ----
api.get("/notifications") { ctx ->
val token = ctx.header("Authorization")?.removePrefix("Bearer ")
?: throw Unauthorized("Missing token")
authService.verify(token) // ensure the client is authenticated
ctx.sse { conn ->
conn.keepAlive(15)
conn.onClose { reason ->
logger.info("[SSE] client disconnected: $reason")
}
// Emit a few demo events and then close the stream
repeat(5) { i ->
conn.send("""{"event":"ping","seq":$i}""")
Thread.sleep(1_000)
}
}
}
// ---- Sub-app-level error handling ----
api.config.propagateExceptions = false
api.onError<ValidationException> { e, ctx ->
ctx.status(422).json(
mapOf("error" to "Validation failed", "fields" to e.errors),
)
}
api.onError<HttpException> { e, ctx ->
ctx.status(e.status).json(
mapOf("error" to e.code, "message" to e.message),
)
}
return api
}
// =============================================================================
// Sub-application — Admin
// =============================================================================
fun buildAdminApp(userService: UserService, metricsService: MetricsService): Colleen {
val admin = Colleen()
admin.provide(userService)
admin.provide(metricsService)
// All admin routes require an admin-level token
admin.use(AuthMiddleware(requiredRole = "admin"))
admin.addController(AdminController(userService, metricsService))
return admin
}
// =============================================================================
// Application Entry Point
// =============================================================================
fun main() {
// ---- Shared services (registered on the root app) ----
val userService = UserService()
val postService = PostService()
val commentService = CommentService()
val authService = AuthService()
val metricsService = MetricsService()
// ---- Root application ----
val app = Colleen()
// Root-level services (inherited by all sub-apps via DI resolution chain)
app.provide(metricsService)
// Note: domain services are provided by sub-apps to keep boundaries clear
// ---- Global middleware ----
app.use(SecurityHeaders())
app.use(RequestLogger())
app.use(metricsMiddleware)
app.use(Heartbeat(endpoint = "/health"))
app.use(RateLimiter(capacity = 200, refillRate = 50.0))
// ---- Event system ----
// Log every registered route at startup
app.on<Event.RouteRegistered> { event ->
logger.info("[Route] ${event.node.method} ${event.node.path}")
}
// Structured access log when the response is fully sent
app.on<Event.ResponseSent> { event ->
val ctx = event.ctx
logger.info(
"[Access] ${ctx.method} ${ctx.fullPath} → ${ctx.response.status} " +
"(${event.total.inWholeMilliseconds}ms, ${event.bytesSent}B)"
)
}
// Track errors globally
app.on<Event.ExceptionCaught> { event ->
if (event.exception !is HttpException) {
logger.error("[Error] Unhandled exception: ${event.exception.message}")
}
}
// ---- Root-level routes ----
// Simple lambda-style handler
app.get("/") {
mapOf(
"name" to "Colleen Demo API",
"version" to "1.0.0",
"endpoints" to listOf("/api/v1", "/admin"),
)
}
// Demonstrates raw query-param map binding
app.get("/echo") { ctx ->
val headers = mapOf(
"method" to ctx.method,
"path" to ctx.path,
"query" to ctx.queries(),
)
headers
}
// Demonstrates reading a signed cookie
app.use(SignedCookie(secret = "super-secret-key-change-in-prod-must-at-least-32-bytes"))
app.get("/session") { ctx ->
val userId = ctx.getSignedCookie("session_user")
mapOf("session_user" to (userId ?: "none"))
}
app.post("/session") { ctx ->
val userId = ctx.query("userId") ?: throw BadRequest("userId query param is required")
ctx.signedCookie(name = "session_user", value = userId)
mapOf("ok" to true)
}
// ---- Mount sub-applications ----
val apiV1 = buildApiV1App(userService, postService, commentService, authService)
val adminApp = buildAdminApp(userService, metricsService)
app.mount("/api/v1", apiV1)
app.mount("/admin", adminApp)
// ---- Root-level error handling (catch-all) ----
app.onError<Exception> { e, ctx ->
val (status, message) = if (e is HttpException) {
e.status to e.message
} else {
500 to "internal server error"
}
if (status >= 500) {
logger.error("[Unhandled] ${e::class.simpleName}: ${e.message}")
}
ctx.status(status).json(mapOf("error" to message))
}
// ---- Application configuration ----
app.config {
server {
host = "0.0.0.0"
port = 8080
useVirtualThreads = true
maxConcurrentRequests = 500
maxRequestSize = 30 * 1024 * 1024 // 30 MB
maxFileSize = 10 * 1024 * 1024 // 10 MB per file
idleTimeout = 60_000
readTimeout = 30_000
writeTimeout = 30_000
shutdownTimeout = 30_000
}
json {
pretty = false
failOnUnknownProperties = true
failOnNullForPrimitives = true
}
}
// ---- Start server ----
app.listen()
logger.info("Server running → http://localhost:8080")
runTests()
}
// =============================================================================
// Tests
// =============================================================================
fun runTests() {
logger.info("\n========== Running TestClient suite ==========\n")
val userService = UserService()
val postService = PostService()
val commentService = CommentService()
val authService = AuthService()
val metricsService = MetricsService()
// Build a minimal version of the app for testing
fun buildTestApp(): Colleen {
val app = Colleen()
app.provide(metricsService)
app.use(metricsMiddleware)
val api = buildApiV1App(userService, postService, commentService, authService)
app.mount("/api/v1", api)
val admin = buildAdminApp(userService, metricsService)
app.mount("/admin", admin)
app.onError<Exception> { e, ctx ->
ctx.status(500).json(mapOf("error" to "INTERNAL_SERVER_ERROR", "message" to e.message))
}
return app
}
val client = TestClient(buildTestApp())
// ---- Helper: obtain a valid token ----
fun loginAs(email: String): String {
val resp = client.post("/api/v1/auth/login")
.json(mapOf("email" to email, "password" to "irrelevant"))
.send()
resp.assertStatus(200)
return resp.json<LoginResponse>()!!.token
}
// ------------------------------------------------------------------
// 1. Public endpoints
// ------------------------------------------------------------------
logger.info("[Test] GET /api/v1/users — list users (public)")
client.get("/api/v1/users").send().assertStatus(200)
logger.info(" PASS")
logger.info("[Test] GET /api/v1/posts — list posts (public)")
client.get("/api/v1/posts").send().assertStatus(200)
logger.info(" PASS")
// ------------------------------------------------------------------
// 2. Authentication
// ------------------------------------------------------------------
logger.info("[Test] POST /api/v1/auth/login — valid credentials")
val tokenResp = client.post("/api/v1/auth/login")
.json(mapOf("email" to "alice@example.com", "password" to "any"))
.send()
tokenResp.assertStatus(200)
val aliceToken = tokenResp.json<LoginResponse>()!!.token
check(aliceToken.isNotBlank()) { "Token must not be blank" }
logger.info(" PASS (token=$aliceToken)")
logger.info("[Test] POST /api/v1/auth/login — unknown email → 401")
client.post("/api/v1/auth/login")
.json(mapOf("email" to "nobody@example.com", "password" to "x"))
.send()
.assertStatus(401)
logger.info(" PASS")
// ------------------------------------------------------------------
// 3. Create user with validation
// ------------------------------------------------------------------
logger.info("[Test] POST /api/v1/users — valid payload")
val createResp = client.post("/api/v1/users")
.json(mapOf("name" to "Charlie", "email" to "charlie@example.com", "password" to "Secret1234"))
.send()
createResp.assertStatus(201)
val charlie = createResp.json<User>()!!
check(charlie.name == "Charlie")
logger.info(" PASS (id=${charlie.id})")
logger.info("[Test] POST /api/v1/users — duplicate email → 409")
client.post("/api/v1/users")
.json(mapOf("name" to "Charlie2", "email" to "charlie@example.com", "password" to "Secret1234"))
.send()
.assertStatus(409)
logger.info(" PASS")
logger.info("[Test] POST /api/v1/users — weak password → 422")
client.post("/api/v1/users")
.json(mapOf("name" to "Dave", "email" to "dave@example.com", "password" to "short"))
.send()
.assertStatus(422)
logger.info(" PASS")
// ------------------------------------------------------------------
// 4. Authenticated update
// ------------------------------------------------------------------
logger.info("[Test] PUT /api/v1/users/{id} — authenticated update")
client.put("/api/v1/users/${charlie.id}")
.header("Authorization", "Bearer $aliceToken")
.json(mapOf("name" to "Charles"))
.send()
.assertStatus(200)
logger.info(" PASS")
logger.info("[Test] PUT /api/v1/users/{id} — unauthenticated → 401")
client.put("/api/v1/users/${charlie.id}")
.json(mapOf("name" to "Charles"))
.send()
.assertStatus(401)
logger.info(" PASS")
// ------------------------------------------------------------------
// 5. Search with query params
// ------------------------------------------------------------------
logger.info("[Test] GET /api/v1/users/search?q=alice")
val searchResp = client.get("/api/v1/users/search")
.query("q", "alice")
.query("limit", "5")
.send()
searchResp.assertStatus(200)
val results = searchResp.json<List<User>>()!!
check(results.isNotEmpty()) { "Search must return at least one result" }
logger.info(" PASS (found ${results.size} user(s))")
// ------------------------------------------------------------------
// 6. Post and comment flow
// ------------------------------------------------------------------
logger.info("[Test] POST /api/v1/posts/{id} — create post (authenticated)")
val postResp = client.post("/api/v1/posts/0") // id in path unused; authorId comes from token
.header("Authorization", "Bearer $aliceToken")
.json(mapOf("title" to "Test Post", "content" to "Hello world", "tags" to listOf("test")))
.send()
postResp.assertStatus(201)
val post = postResp.json<Post>()!!
logger.info(" PASS (postId=${post.id})")
logger.info("[Test] GET /api/v1/posts — filter by tag")
val tagResp = client.get("/api/v1/posts")
.query("tags", "test")
.send()
tagResp.assertStatus(200)
logger.info(" PASS")
logger.info("[Test] POST /api/v1/posts/{postId}/comments — create comment")
val commentResp = client.post("/api/v1/posts/${post.id}/comments")
.header("Authorization", "Bearer $aliceToken")
.json(mapOf("body" to "Great post!"))
.send()
commentResp.assertStatus(201)
logger.info(" PASS")
// ------------------------------------------------------------------
// 7. File upload
// ------------------------------------------------------------------
logger.info("[Test] POST /api/v1/users/avatar — multipart upload")
val uploadResp = client.post("/api/v1/users/avatar")
.header("Authorization", "Bearer $aliceToken")
.file("file", "avatar.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47), "image/png")
.send()
uploadResp.assertStatus(200)
logger.info(" PASS")
// ------------------------------------------------------------------
// 8. Admin routes
// ------------------------------------------------------------------
val adminToken = loginAs("admin@example.com")
logger.info("[Test] GET /admin/users — admin access")
client.get("/admin/users")
.header("Authorization", "Bearer $adminToken")
.send()
.assertStatus(200)
logger.info(" PASS")
logger.info("[Test] GET /admin/users — non-admin access → 403")
client.get("/admin/users")
.header("Authorization", "Bearer $aliceToken")
.send()
.assertStatus(403)
logger.info(" PASS")
logger.info("[Test] GET /admin/metrics — returns counters")
val metricsResp = client.get("/admin/metrics")
.header("Authorization", "Bearer $adminToken")
.send()
metricsResp.assertStatus(200)
logger.info(" PASS (metrics=${metricsResp.json<Map<String, Any>>()})")
// ------------------------------------------------------------------
// 9. 404 for unknown routes
// ------------------------------------------------------------------
logger.info("[Test] GET /api/v1/does-not-exist → 404")
client.get("/api/v1/does-not-exist").send().assertClientError()
logger.info(" PASS")
logger.info("\n========== All tests passed ✔ ==========\n")
}