REST Server
uni provides a Netty-based HTTP server with a type-safe router framework for building REST APIs.
Quick Start
import wvlet.uni.http.{HttpMethod, Request, Response}
import wvlet.uni.http.router.{Endpoint, Router}
import wvlet.uni.http.netty.{NettyServer, RouterHandler}
// Define a controller
class UserController:
@Endpoint(HttpMethod.GET, "/users")
def listUsers(): String = """["alice", "bob"]"""
@Endpoint(HttpMethod.GET, "/users/:id")
def getUser(id: String): String = s"""{"id": "${id}"}"""
// Create router and start server
val router = Router.of[UserController]
val handler = RouterHandler(router)
NettyServer
.withPort(8080)
.withRxHandler(handler)
.start { server =>
println(s"Server running on port ${server.localPort}")
// Server runs until the block completes
}Defining Endpoints
Use the @Endpoint annotation to mark controller methods as HTTP endpoints:
import wvlet.uni.http.HttpMethod
import wvlet.uni.http.router.Endpoint
class ApiController:
@Endpoint(HttpMethod.GET, "/health")
def health(): String = "ok"
@Endpoint(HttpMethod.POST, "/data")
def createData(request: Request): Response =
val body = request.content.toContentString
Response.ok(s"Received: ${body}")
@Endpoint(HttpMethod.PUT, "/items/:id")
def updateItem(id: String, request: Request): Response =
Response.ok(s"Updated item ${id}")
@Endpoint(HttpMethod.DELETE, "/items/:id")
def deleteItem(id: String): Response =
Response.noContentPath Parameters
Use :paramName syntax in the path to capture path segments:
class ItemController:
@Endpoint(HttpMethod.GET, "/items/:id")
def getItem(id: String): String = s"Item ${id}"
@Endpoint(HttpMethod.GET, "/users/:userId/posts/:postId")
def getUserPost(userId: String, postId: String): String =
s"User ${userId}, Post ${postId}"Path parameter names must match the method parameter names.
Query Parameters
Method parameters that don't match path parameters are automatically bound from query string:
class SearchController:
@Endpoint(HttpMethod.GET, "/search")
def search(query: String, limit: Int = 10): String =
s"Searching for '${query}' with limit ${limit}"Request: GET /search?query=hello&limit=20
Supported parameter types:
StringInt,Long,Short,ByteDouble,FloatBooleanOption[T]for optional parameters
Request Object Access
Add a Request parameter to access the full request:
class MyController:
@Endpoint(HttpMethod.POST, "/upload")
def upload(request: Request): Response =
val contentType = request.headers.get("Content-Type")
val body = request.content.toContentString
Response.ok(s"Received ${body.length} bytes")Request and Response
Request Properties
request.method // HttpMethod (GET, POST, etc.)
request.uri // Full URI string
request.path // Path portion of URI
request.headers // HttpHeaders
request.content // HttpContent (request body)
request.getQueryParam(name) // Option[String]Response Factory Methods
Response.ok // 200 OK
Response.ok("body") // 200 with text body
Response.created // 201 Created
Response.noContent // 204 No Content
Response.badRequest("message") // 400 Bad Request
Response.notFound // 404 Not Found
Response.notFound("message") // 404 with message
Response.internalServerError(msg) // 500 Internal Server ErrorResponse Content
Response.ok
.withTextContent("Hello") // text/plain
.withJsonContent("""{"a": 1}""") // application/json
.withBytesContent(bytes) // application/octet-stream
.withContent(HttpContent.json(str)) // Custom content
.addHeader("X-Custom", "value") // Add headerResponse Conversion
Controller methods can return various types that are automatically converted to responses:
| Return Type | Conversion |
|---|---|
Response | Returned as-is |
Rx[Response] | Async response |
String | 200 OK with text/plain |
Seq[T] | 200 OK with JSON array |
Map[K, V] | 200 OK with JSON object |
Option[T] | Value or 204 No Content |
Unit | 204 No Content |
| Other types | Serialized to JSON |
class DataController:
@Endpoint(HttpMethod.GET, "/text")
def getText(): String = "plain text"
@Endpoint(HttpMethod.GET, "/list")
def getList(): Seq[String] = Seq("a", "b", "c")
@Endpoint(HttpMethod.GET, "/map")
def getMap(): Map[String, Int] = Map("count" -> 42)
@Endpoint(HttpMethod.GET, "/async")
def getAsync(): Rx[Response] =
Rx.single(Response.ok("async result"))Filters
Use RxHttpFilter to intercept requests and responses:
import wvlet.uni.http.netty.{RxHttpFilter, RxHttpHandler}
val loggingFilter = RxHttpFilter { (request, next) =>
println(s"Request: ${request.method} ${request.path}")
next
.handle(request)
.map { response =>
println(s"Response: ${response.status}")
response
}
}
val authFilter = RxHttpFilter { (request, next) =>
request.headers.get("Authorization") match
case Some(token) if isValid(token) =>
next.handle(request.addHeader("X-User-Id", extractUserId(token)))
case _ =>
Rx.single(Response.unauthorized("Missing or invalid token"))
}Applying Filters
// Apply filter to server
NettyServer
.withPort(8080)
.withFilter(loggingFilter)
.withFilter(authFilter)
.withRxHandler(handler)
.start()
// Or compose filters
val combinedFilter = loggingFilter.andThen(authFilter)Router-Level Filters
Define filters that apply to specific controllers:
class LogFilter extends RxHttpFilter:
def apply(request: Request, next: RxHttpHandler): Rx[Response] =
println(s"Handling: ${request.path}")
next.handle(request)
val router = Router
.filter[LogFilter]
.andThen(Router.of[UserController])Server Configuration
NettyServerConfig Options
NettyServer
.withPort(8080) // Port to listen on (0 for random)
.withHost("0.0.0.0") // Bind address
.withName("my-server") // Server name for logging
.withMaxContentLength(1024 * 1024) // Max request body size (1MB)
.withMaxHeaderSize(8192) // Max header size
.withMaxInitialLineLength(4096) // Max request line length
.noNativeTransport // Disable native transport (epoll/kqueue)
.withHandler(handler)
.start()Server Lifecycle
// Start and get server instance
val server = NettyServer
.withPort(8080)
.withHandler(handler)
.start()
println(s"Running on port ${server.localPort}")
server.isRunning // true
// Stop when done
server.stop()
server.isRunning // falseBlock-based Server
For simpler use cases, use the block form that automatically stops the server:
NettyServer
.withPort(8080)
.withHandler(handler)
.start { server =>
// Server runs while this block executes
println(s"Server on port ${server.localPort}")
Thread.sleep(60000) // Run for 1 minute
}
// Server automatically stopped hereSimple Handler
For simple cases without routing, use a direct handler:
NettyServer
.withPort(8080)
.withHandler { request =>
Response.ok(s"Hello from ${request.path}")
}
.start()Or with async responses:
NettyServer
.withPort(8080)
.withRxHandler { request =>
Rx.single(Response.ok("async response"))
}
.start()Combining Controllers
Combine multiple controllers into a single router:
class UserController:
@Endpoint(HttpMethod.GET, "/users")
def listUsers(): String = "[]"
class ItemController:
@Endpoint(HttpMethod.GET, "/items")
def listItems(): String = "[]"
val router = Router.of[UserController]
.andThen(Router.of[ItemController])
val handler = RouterHandler(router)Example: Complete REST API
import wvlet.uni.http.{HttpMethod, Request, Response}
import wvlet.uni.http.router.{Endpoint, Router}
import wvlet.uni.http.netty.{NettyServer, RouterHandler, RxHttpFilter}
import wvlet.uni.rx.Rx
case class User(id: String, name: String)
class UserController:
private var users = Map(
"1" -> User("1", "Alice"),
"2" -> User("2", "Bob")
)
@Endpoint(HttpMethod.GET, "/users")
def listUsers(): Seq[User] = users.values.toSeq
@Endpoint(HttpMethod.GET, "/users/:id")
def getUser(id: String): Response =
users.get(id) match
case Some(user) => Response.ok.withJsonContent(s"""{"id":"${user.id}","name":"${user.name}"}""")
case None => Response.notFound(s"User ${id} not found")
@Endpoint(HttpMethod.POST, "/users")
def createUser(request: Request): Response =
// Parse and create user
Response.created.withJsonContent("""{"id":"3","name":"New User"}""")
@Endpoint(HttpMethod.DELETE, "/users/:id")
def deleteUser(id: String): Response =
users -= id
Response.noContent
val loggingFilter = RxHttpFilter { (request, next) =>
val start = System.currentTimeMillis()
next.handle(request).map { response =>
val duration = System.currentTimeMillis() - start
println(s"${request.method} ${request.path} -> ${response.status} (${duration}ms)")
response
}
}
val router = Router.of[UserController]
val handler = RouterHandler(router)
NettyServer
.withPort(8080)
.withFilter(loggingFilter)
.withRxHandler(handler)
.start { server =>
println(s"API running at http://localhost:${server.localPort}")
Thread.currentThread().join() // Run indefinitely
}