Branch Framework
Branch is a zero-dependency framework for Scala 3 on Java 21+.
Why zero-dependency? For fun! Fun, and to illustrate how much you can get done with Scala without relying on bigger frameworks. Branch will not be the fastest, most performant solution, but it will (hopefully) let you get things done quickly! Think of it as the framework for your side-project, not your job.
Branch is made up of a collection of modules, each focusing on different parts:
- Lzy - Lazy Futures or Tiny Effects?
- Spider - A wrapper/framework around the Java HttpServer (I bet you didn't even know there was one!), as well as HttpClient helpers.
- Piggy - A SQL framework, probably focused on PostgreSQL.
- Friday - A Json library, because Scala doesn't already have enough.
- Macaroni - Some re-usable helpers and meta-programming utilities.
- Veil -
.env
/ (Json based) Config utilities. - Blammo - It's better than bad, it's (Json) logging!
- Keanu - A simple typed EventBus implementation, and a mediocre untyped ActorSystem.
- Ursula - A slim CLI framework.
... and more to come!
A list of other things important to this framework's goals are (but not limited to):
- Web Sockets
- (HTML) Templating
Lzy
Lzy is somewhere between lazy Futures, and a tiny Effect System.
A Prelude
A (Scala) Future
is an eager calculation - once referenced, it's going to run! This also means once it's done, we
can't re-run it - only evaluate the finished state. Sometimes it's beneficial to describe some logic in a lazy fashion,
where it's more of a blueprint of what to do. Calling the blueprint runs the code, whose output can be assigned to a
value, but then the blueprint can be run again!
Let's compare the following Future code to Lazy code:
given ExecutionContext = LazyRuntime.executionContext
val f1: Future[Int] = Future(Random.nextInt(10))
// f1 is already running, kicked off by an implicit ExecutionContext
val f2: Future[Int] = Future(Random.nextInt(10))
// f2 is now running...
def fRandomSum: Future[Int] = for {
a <- f1
b <- f2
} yield (a + b)
// fRandomSum will be the same every time it's called
println(Await.result(fRandomSum, Duration.Inf))
println(Await.result(fRandomSum, Duration.Inf))
println(Await.result(fRandomSum, Duration.Inf))
val l1: Lazy[Int] = Lazy.fn(Random.nextInt(10))
// l1 is a description of what you want to do, nothing is running yet
val l2: Lazy[Int] = Lazy.fn(Random.nextInt(10))
def lzyRandomSum: Lazy[Int] = for {
a <- l1
b <- l2
} yield (a + b)
// lzyRandomSum will be different each time, because the whole blueprint is evaluated on each call
println(lzyRandomSum.runSync)
println(lzyRandomSum.runSync)
println(lzyRandomSum.runSync)
This description/lazy evaluation approach lets you structure you're programs in descriptive ways, but also has the
bonus of all the combinator effects you can add on. For example, let's say we want to add some ergonomic error handling
to one of out lazy function and add a way to .recover
a failure.
def myLazyOp(arg: Int): Lazy[Int] =
Lazy.fn(42 / arg)
myLazyOp(0).runSync()
// -> Failure(java.lang.ArithmeticException: / by zero)
myLazyOp(0).recover(_ => Lazy.fn(0)).runSync()
// => Success(0)
The recovery doesn't have to be part of our definition of myLazyOp
, and can be applied where needed, and different
recovery strategies used in various places.
Other Libraries
If you like Lzy, you should check out ZIO
Spider
Oh, what a tangled web we weave when first we practice http
Spider is built on top of the built-in Java HttpServer
.
JVM HttpServer
In the context of the HttpServer
, you build HttpHandler
s that handles an HttpExchange
(receiving a request and
returning a response). These handlers are registered with a root URI to the server via an HttpContext
When a HTTP request is received, the appropriate HttpContext
(and handler) is located by finding the context whose
path is the longest matching prefix of the request URI's path. Paths are matched literally, which means that the strings
are compared case sensitively, though there is a ci
string interpolator to help with case-insensitive matching.
Spider
Spider encapsulates this via RequestHandler
s and ContextHandlers
.
There is a RequestHandler[I,O]
to extend for handling a route. You will need to use a Convserion
from Array[Byte]
to an Input model I
, and a similar outgoing conversion for you output model O
. With these conversions, the
RequestHandler takes care of parsing the input streams from the HttpExchange
and writing the response to the output
stream. Some conversions for simple types are provided by import RequestHandler.given
.
trait RequestHandler[I, O](using
requestDecoder: Conversion[Array[Byte], I],
responseEncoder: Conversion[O, Array[Byte]]
)
Implementing this trait, you will then need to write the function that will handle a Request[I]
and return a
Response[O]
.
Here is an example that returns the string Aloha
.
import RequestHandler.given
case class GreeterGetter() extends RequestHandler[Unit, String] {
override def handle(request: Request[Unit]): Response[String] = {
Response(Map.empty, "Aloha")
}
}
Request
is a case class that wraps/holds some values based on the request of the HttpContext
, and Response
similarly models the output in a more Scala friendly way.
With all of your request handlers made, we can then use them in a Contexthandler
.
trait ContextHandler(val path: String)
The main thing to implement here is the contextRouter
. The contextRouter
is a PartialFunction
that matches the
http method/verb and request path and maps to a specific Requesthandler
.
Here is an example:
case class EchoGetter(msg: String) extends RequestHandler[Unit, String] {
override def handle(request: Request[Unit]): Response[String] = {
Response(msg)
}
}
val myhandler = new ContextHandler("/") {
override val contextRouter
: PartialFunction[(HttpVerb, Path), RequestHandler[?, ?]] = {
case HttpVerb.GET -> >> / "some" / "path" => alohaGreeter
case HttpVerb.GET -> >> / "some" / "path" / s"$arg" => EchoGetter(arg)
}
}
We can then register out ContextHandler
to an instance of the http server
ContextHandler.registerHandler(myhandler)(using httpServer: HttpServer)
ContextHandler
s support Filter
s (what might typically be described as middleware, that ge process in the
request/response chain), as well as the Authenticator
class. These are specific to the root path the ContextHandler
is bound to, and this could help determine when to group things into multiple ContextHandler
s.
There is an HttpApp
trait that sets up the server for you in an entry point. A quick example:
object HttpAppExample extends HttpApp {
import RequestHandler.given
case class GreeterGetter() extends RequestHandler[Unit, String] {
override def handle(request: Request[Unit]): Response[String] = {
Response("Aloha")
}
}
val alohaGreeter = GreeterGetter()
case class EchoGetter(msg: String) extends RequestHandler[Unit, String] {
override def handle(request: Request[Unit]): Response[String] = {
Response(msg)
}
}
val myhandler = new ContextHandler("/") {
override val filters: Seq[Filter] = Seq(
ContextHandler.timingFilter
)
override val contextRouter
: PartialFunction[(HttpVerb, Path), RequestHandler[?, ?]] = {
case HttpVerb.GET -> >> / "some" / "path" => alohaGreeter
case HttpVerb.GET -> >> / "some" / "path" / s"$arg" => EchoGetter(arg)
}
}
ContextHandler.registerHandler(myhandler)
}
Other Libraries
If you like Spider, you should check out Tapir
Piggy
Other Libraries
If you like Piggy, you should check out Magnum
Friday
Friday is built off of a parser that is the topic of a chapter in Function Programming in Scala (2nd Ed) of parser combinators.
The library provides an AST to convert JSON to/from, as well as type-classes for Encoders, Decoders, and Codecs.
There is also an emphasis on Json AST helper methods to easily work with JSON, without having to convert to/from an explicit schema.
Great uses of this library are for encoding/decoding JSON with the Spider http server/client project, or simple JSON driven configuration files.
Working with the AST
The Json AST is described fully as
enum Json {
case JsonNull
case JsonBool(value: Boolean)
case JsonNumber(value: Double)
case JsonString(value: String)
case JsonArray(value: IndexedSeq[Json])
case JsonObject(value: Map[String, Json])
}
A json string can be parsed to Json
, using the parse
method on the Json
companion object.
def parse(json: String): Either[ParseError, Json]
Once you have a reference to a Json
, you can (dangerously) access the underlying value by calling one of
- strVal
- numVal
- boolVal
- arrVal
- objVal
These methods will throw an exception if the underlying value is not the approriate type, for example
JsonString("Some Str").strVal // Works fine
JsonString("Some Str").numVal // Throws exception
You can safely use one of the following methods to get an Option of the underlying value
- strOpt
- numOpt
- boolOpt
- arrOpt
- objOpt
To quickly parse through possible sub-fields on a JsonObject, there is a ?
extension method on both Json
and
Option[Json]
that takes a field name as an argument
def ?(field: String): Option[Json]
With this, if we had some JSON
{
"name": "Branch",
"some": {
"nested": {
"key": "value"
}
}
}
We can do things like
val js: Json = ???
val maybeName: Option[Json] = js ? "name" // It's there!
val deepField: Option[Json] = js ? "some" ? "nested" ? "key" // This too!
// Not present, but doesn't throw an exception attempting to access deeper fields!
val probablyNot: Option[String] = js ? "totally" ? "not" ? "there"
Encoder, Decoders, and Codecs
Friday provides type-classes to convert Json to/from Scala models.
Decoders
For some type A
, we can define a JsonEncoder[A]
that can convert Json
to A
by providing an implementation of
def decode(json: Json): Try[A]
For example, the following decoder can convert Json
to String
given JsonDecoder[String] with {
def decode(json: Json): Try[String] =
Try(json.strVal)
}
Some decoders for common types like this are provided and can accessed by importing
import dev.wishingtree.branch.friday.JsonDecoder.given
Auto derivation is also supported for Product
types (Sum
types soon™️). We can use derives
on case classes as
case class Person(name: String, age: Int)derives JsonDecoder
With the proper decoder in scope, we can decode JSON (or JSON that is still in String form) with
val personJson =
"""
|{
| "name": "Mark",
| "age": 42
|}
|""".stripMargin
println {
Json.decode[Person](personJson) // returns a Try[Person]
}
Encoders
For some type A
, we can define an encoder that can convert A
to Json
by providing an implementation of
def encode(a: A): Json
For example, the following encoder can convert String
to Json
given JsonEncoder[String] with {
def encode(a: String): Json = Json.JsonString(a)
}
Some encoders for common types are provided and can accessed by importing
import dev.wishingtree.branch.friday.JsonEncoder.given
Auto derivation is also supported for Product
types (Sum
types soon™️). We can use derives
on case classes as
case class Person(name: String, age: Int)derives JsonEncoder
With the proper encoder in scope, we can use the extension method provided by the type class, or the method on the Json
companion object to convert to Json
Person("Mark", 42).toJson
Json.encode(Person("Mark", 42))
Codecs
We often want to be able to go both ways, so this library provides a codec which has the ability to encode and decode.
trait JsonCodec[A] extends JsonDecoder[A], JsonEncoder[A]
This also supports auto derivation for Product
types (Sum
types soon™️). We can use derives
on case classes as
case class Person(name: String, age: Int)derives JsonCodec
With both an encoder and decoder, there is also the JsonCodec.apply
method to easily create an instance
def apply[A](using
encoder: JsonEncoder[A],
decoder: JsonDecoder[A]
): JsonCodec[A] =
new JsonCodec[A] {
override def decode(json: Json): Try[A] = decoder.decode(json)
override def encode(a: A): Json = encoder.encode(a)
}
Other Libraries
If you like Friday, you should check out uPickle
Macaroni
This module has a collection of reusable modules that could be helpful in any project.
Meta Programming
There are a couple of reusable inline helpers to summon lists of things by type, as well as some extra type helpers for Tuples.
Parser
There is a parser, which is the topic of a chapter in
Function Programming in Scala (2nd Ed)
of parser combinators. It is currently used to power the Friday JSON library, but is useful for
any parsing application, I imagine. There is a Parsers
trait, and a Reference
implementation which can be used.
ResourcePool
If you need a resource pool of type R
, then there is a simple trait ResourcePool[R]
to extend.
The borrowing of resources is gated by a Semaphore
with val poolSize: Int
(defaults to 5) permits.
The pool is eagerly filled on create.
Implement def acquire: R
with how to create a resource, and def release(resource: R): Unit
with how to cleanly close
the resource when shutting the pool down.
You can optionally over def test(resource: R): Boolean
to provide a test to run after borrowing a resource, to make
sure it is still healthy.
Veil
Veil is a small layer to help with configs and environment variables.
Veil can load a .env
, .env.test
, or .env.prod
file based on the environment variable SCALA_ENV
being set to
DEV
, TEST
, or PROD
. Values in this file are loaded into an in-memory map, and you can look up an env variable with
Veil.get(key: String): Option[String]
. If it's not present in the in-memory map, it will then search Java's
System.getenv()
.
There is also a Config
type-class that helps with loading json from files/resources, and mapping them to a case
class (which presumably is used for configuration).
Blammo
This module contains some helpers around java.util.logging
. So far, the main thing it has going for it is a
JsonFormatter
for some structured logging.
There is also a JsonConsoleLogger
trait that you can mix in to your classes to get a logger that uses the formatter.
Keanu
This module currently contains a simple typed EventBus, and a (local) ActorSystem.
EventBus
Extend EventBus[T]
for your type, e.g.
object IntEventBus extends EventBus[Int]
Then, you can have some implementation extend Subsciber[T]
and subscribe, or pass in an anonymous implementation (e.g.
IntEventBus.subscribe((msg: Int) => println(s"Got message $msg"))
)
Under the hood, the subscriber gets a queue of messages, and a thread that processes them, so things will be processed in order, but asynchronously.
ActorSystem
The AcotrSystem
trait is implemented, and you can have an object extend it for easy singleton access. The apply
method also returns a new instance as well.
let's say you have two actors:
case class EchoActor() extends Actor {
override def onMsg: PartialFunction[Any, Any] = {
case any =>
println(s"Echo: $any")
}
}
case class SampleActor(actorSystem: ActorSystem) extends Actor {
println("starting actor")
var counter = 0
override def onMsg: PartialFunction[Any, Any] = {
case n: Int =>
counter += n
actorSystem.tell[EchoActor]("echo", s"Counter is now $counter")
case "boom" => 1 / 0
case "count" =>
counter
case "print" => println(s"Counter is $counter")
case _ => println("Unhandled")
}
}
You can register props
to the actor system which capture arguments used to create actor instances. This is a bit
unsafe in
the sense that it takes varargs that should be supplied to create the actor, so no compiler checks at the moment.
val as = ActorSystem()
val saProps = ActorContext.props[SampleActor](as)
as.registerProp(saProps)
as.registerProp(ActorContext.props[EchoActor]())
at this point, you can send messages to the actors with the tell
method on the ActorSystem
:
// Helper lambda to send messages to the SampleActor named counter
val counterActor = as.tell[SampleActor]("counter", _)
counterActor(1)
counterActor(2)
counterActor(3)
counterActor(4)
counterActor("boom")
counterActor(5)
counterActor("print")
Actors are indexed based on name and type, so you can have multiple actors of the same type, but they must have unique names (and you can have actors with the same name, as long as they are different types).
If you want to shut down the ActorSystem, you can use the shutdownAwait
method, which will send a PoisonPill
to all
actors, and attempt to wait for their queues to finish processing.
Ursula
A slim framework to make Scala CLI apps.
Anatomy of the Framework
Here is a general overview if of the pieces fit together.
How it works: UrsulaApp
You only need to make an object that extends the UrsulaApp
trait, and provide
a Seq[Command]
, which are your actions you wish to be available in your
app. UrsulaApp
has a final main
method entrypoint, and does some processing automatically.
It parses the arguments passed, and uses that to pull out the Command
provided, and runs
accordingly, passing on the arguments to it.
There are some built in commands provided, currently only the
HelpCommand
, that are also automatically injected. This
means that even if you only have:
object App extends UrsulaApp {
override val commands: Seq[Command] = Seq.empty
}
you already have a functioning cli app that has a help
command that prints all
the available commands accepted (as little as they are so far).
At this point, you need only implement some Command
s that wrap the
functionality you desire, and add them to the commands: Seq
.
Commands
There is a trait Command
to extend, and the essence of this the
implementation of
def action(args: Seq[String]): Unit
You consolidate all of your logic you want to run in this method.
Commands
are meant to be a one-off calls from the main entry point,
and generally not composed with other Command
s, so the return type is Unit
.
There are a few other items to implement, such as
val trigger: String
val description: String
val usage: String
val examples: Seq[String]
trigger
is the String that should be used at the start of your CLI arguments
to call that particular command. The others are simple informational strings
about your command - and those are automatically used by the built-in help
command to print documentation!
Two other important things to declare are
val flags: Seq[Flag[?]]
val arguments: Seq[Argument[?]]
Flags and Arguments are discussed below, but know that
they are simple traits to extend that help you parse/provide values for the
args
passed in - and they too have some simple Strings to implement that
provide auto documentation for your app. At the end of the day, you can just
parse the args
on your own in your ZIO logic - but usage of the Flag
s
andArguments
should hopefully simplify things for your and your apps users.
Built-In Commands
- HelpCommand - handles the printing of documentation
Flags
Flags (trait Flag[R]
) are non-positional arguments passed to the command.
Flags can be generally used as either an argument flag, which expects the next
element in the command arguments to be parsed as type R
, or boolean flags
which do not (i.e. present/not present).
Some general highlights are that it has things built in to
- parse argument(s) that can then be used in you
Command
- declare conflicts with other flags
- declare requirements of other flags
- provide defaults, of ENV variables to be used
Arguments
Arguments (trait Argument[R]
) are positional arguments passed to the
command, and are to be parsed to type R
Some general highlights are that you can encode the parsing logic.