Routing DSL on Netty.
- No extra functional stack (shapeless / scalaz / zio / cats / …) — only Netty.
- Cross-built for Scala 2.13 and Scala 3. The routing DSL is the same on both.
"com.github.fntz" %% "omhs-dsl" % "0.0.6"
// optional JSON modules
"com.github.fntz" %% "omhs-play-support" % "0.0.6"
"com.github.fntz" %% "omhs-circe-support" % "0.0.6"
"com.github.fntz" %% "omhs-jsoniter-support" % "0.0.6"Scala 2 and Scala 3 need slightly different project settings.
Scala 2.13
scalacOptions += "-Ydelambdafy:inline" // https://github.com/scala/bug/issues/10554
libraryDependencies ++= Seq(
"org.scala-lang" % "scala-reflect" % scalaVersion.value % "compile",
"io.netty" % "netty-codec-http" % NettyVersion,
"io.netty" % "netty-codec-http2" % NettyVersion
)Scala 3
// no -Ydelambdafy:inline, no scala-reflect
libraryDependencies ++= Seq(
"io.netty" % "netty-codec-http" % NettyVersion,
"io.netty" % "netty-codec-http2" % NettyVersion
)OMHS is a Netty routing library in the spirit of sinatra.rb.
When a request matches a path, OMHS runs your function and passes path/header/body parameters as ordinary function arguments:
/foo/bar -> () => ...
/foo/:string -> (s: String) => ...
Every handler should return something that can become an HTTP response. Internally that is a Response (CommonResponse for normal replies, StreamResponse for streaming).
In real apps the payload often comes from a DB or remote call and is wrapped in Future, zio.Task, etc. OMHS uses AsyncResult as the bridge: handlers return AsyncResult, and implicits/givens can lift String, Future[T], and so on into it.
get("test" / uuid) ~> { (id: UUID) =>
AsyncResult.completed(CommonResponse.plain(s"$id".getBytes))
}Example: turn a ZIO task into AsyncResult:
val value = zio.Runtime.default.unsafeRun(task) // task yields CommonResponse
AsyncResult.completed(value)Paths are written as HTTP method + URL pieces, with helpers such as uuid, string, long, regex, and the catch-all *.
Headers and cookies are not part of path matching; they are extracted and passed into the handler when you declare them.
For body / query you provide a reader that turns the raw input into your type.
The snippets below use the same DSL on Scala 2 and Scala 3. Where setup differs (implicits vs givens), both forms are shown.
import com.github.fntz.omhs.RoutingDSL._
import com.github.fntz.omhs.AsyncResult
import AsyncResult.Implicits._ // String / Future → AsyncResult
get(string / "test" / uuid) ~> { (x: String, u: UUID) =>
"done"
}
get("test" / *) ~> { (xs: List[String]) =>
"done"
}
get("foo" | "bar") ~> { (choice: String) =>
"done"
}post("test" << header("User-Agent") << cookie("name") << header("Accept")) ~> {
(userAgent: String, name: String, accept: String) =>
"done"
}To read the query string (?foo=bar), implement QueryReader.
Scala 2
case class SearchQuery(query: String)
implicit val querySearchReader: QueryReader[SearchQuery] =
new QueryReader[SearchQuery] {
override def read(queries: Map[String, Iterable[String]]): Option[SearchQuery] =
queries.get("query").flatMap(_.headOption).map(SearchQuery)
}Scala 3
case class SearchQuery(query: String)
given QueryReader[SearchQuery] with
def read(queries: Map[String, Iterable[String]]): Option[SearchQuery] =
queries.get("query").flatMap(_.headOption).map(SearchQuery)Then, on both Scala 2 and Scala 3:
get("test" :? query[SearchQuery]) ~> { (q: SearchQuery) =>
"done"
}Implement BodyReader to parse the request body.
Scala 2
case class Person(id: Int)
implicit val personBodyReader: BodyReader[Person] =
new BodyReader[Person] {
override def read(str: String): Person = ???
}Scala 3
case class Person(id: Int)
given BodyReader[Person] with
def read(str: String): Person = ???Then, on both:
post("test" <<< body[Person]) ~> { (p: Person) =>
"done"
}JSON helpers are available for play-json, circe, and jsoniter.
import io.netty.handler.codec.http.multipart.FileUpload
post("test" <<< file) ~> { (files: List[FileUpload]) =>
"done"
}get(string / "test") ~> { (s: String, request: CurrentHttpRequest) =>
"done"
}CurrentHttpRequest (and ChunkedOutputStream) must be the last argument, or the last two when both are used.
import com.github.fntz.omhs.streams.ChunkedOutputStream
import AsyncResult.Streaming._ // ChunkedOutputStream → AsyncResult
get("streaming") ~> { (stream: ChunkedOutputStream) =>
stream.write("123".getBytes())
stream.write("456".getBytes())
stream.write("789".getBytes())
stream << "000"
stream
}Valid last-argument shapes:
(stream: ChunkedOutputStream, req: CurrentHttpRequest) => ...
(req: CurrentHttpRequest, stream: ChunkedOutputStream) => ...implicit val ec: ExecutionContext = executor // Scala 2
// given ExecutionContext = executor // Scala 3
import com.github.fntz.omhs.AsyncResult.Implicits._
get("persons" / long) ~> { (id: Long) =>
Future(DB.persons.byId(id))
}route { ... } rewrites helpers such as status / contentType onto a mutable response state. The same code works on Scala 2 and Scala 3.
import com.github.fntz.omhs.moar._
import io.netty.handler.codec.http.cookie.{DefaultCookie, ServerCookieEncoder}
val rule = get("test" / string) ~> route { (x: String) =>
if (x == "foo") {
implicit val enc: ServerCookieEncoder = ServerCookieEncoder.STRICT // or `given` on Scala 3
status(200)
setHeader("foo", "bar")
setCookie("asd", "qwe")
val c = new DefaultCookie("a", "b")
c.setDomain("example.com")
setCookie(c)
setHeader("x-header", "v-value")
contentType("application/custom-type")
"done"
} else {
status(400)
setHeader("y-header", "y-value")
contentType("application/custom-another-type")
"not-found"
}
}Available helpers inside route:
contentTypestatussetCookiesetHeader
These must not be used outside a route block (the compiler rejects that).
val route = new Route().addRules(r1, r2, r3).onUnhandled {
case PathNotFound(p) =>
CommonResponse.json(404, s"$p not found")
case _ =>
CommonResponse.json(500, "boom")
}Unhandled reasons:
PathNotFound(path: String)
QueryIsUnparsable(params: Map[String, Iterable[String]])
CookieIsMissing(cookieName: String)
HeaderIsMissing(headerName: String)
BodyIsUnparsable(ex: Throwable)
FilesIsUnparsable(ex: Throwable)
UnhandledException(ex: Throwable)
val customSetup = Setup(
timeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME
.withZone(ZoneOffset.UTC).withLocale(Locale.US),
sendServerHeader = false,
cookieDecoderStrategy = CookieDecoderStrategy.Lax,
maxContentLength = 512 * 1024,
enableCompression = false,
chunkSize = 1000,
isSupportHttp2 = true
)val rule1 = ...
val rule2 = ...
val rule3 = ...
val route = new Route().addRules(rule1, rule2, rule3)
val server = OMHSServer.init(9000, route.toHandler)
// or customize ServerBootstrap
val server = OMHSServer.run(
port = 9000,
handler = route.toHandler,
sslContext = Some(OMHSServer.getJdkSslContext),
serverBootstrapChanges = (s: ServerBootstrap) => {
s.option(...).childOption(...)
}
)
server.start()
server.stop()Pass -Domhs.logLevel=verbose|info|none to sbt / JVM options to inspect generated routing code.
- More settings / swagger polish
MIT