Building an API with ZIO and Caliban
This is what a cat looks like. With a repository to collect them.
case class Cat(name: String, color: Color, pattern: Pattern)
object Cat {
case class Color(s: String) extends AnyVal
sealed abstract class Pattern extends EnumEntry
object Pattern extends Enum[Pattern] {
case object Stripes extends Pattern
case object Plain extends Pattern
val values: IndexedSeq[Pattern] = findValues
}
}
object CatRepo {
//in memory database of cats. not threadsafe or reasonable
var catDb: List[Cat] = List(Cat("Koffie", Color("grey"), Pattern.Stripes), Cat("Minoes", Color("brown"), Pattern.Plain))
def getAllCats: List[Cat] = catDb
def addCat(cat: Cat): Cat = {
catDb = cat :: catDb
cat
}
}
Then we can create the API around it. There are adapters for many http libraries. Here we use Akka Http.
case class Queries(cats: () => List[Cat])
case class Mutations(addCat: Cat => Cat)
class Api {
implicit val runtime = zio.Runtime.default
val interpreter = runtime.unsafeRun(createInterpreter)
def route = AkkaHttpAdapter.makeHttpService(interpreter)
def createInterpreter = {
GraphQL.graphQL(
RootResolver(
Queries(
() => CatRepo.getAllCats
),
Mutations(
CatRepo.addCat
)
)
).interpreter
}
}
For the complete code check https://github.com/tammosminia/calibanExample
We can run a request using curl to see that it works
$ curl --request POST \
> --url http://localhost:8080/graphql \
> --header 'Content-Type: application/json' \
> --data '{"query":"query cats {\n\tcats {\n\t\tname\n\t\tcolor\n\t\tpattern\n\t}\n}","variables":{},"operationName":"cats"}'
{"data":{"cats":[{"name":"Koffie","color":"grey","pattern":"Stripes"},{"name":"Minoes","color":"brown","pattern":"Plain"}]}}
$ curl --request POST \
> --url http://localhost:8080/graphql \
> --header 'Content-Type: application/json' \
> --data '{"query":"mutation addCat {\n addCat(name: \"Neighbour\", color: \"black\", pattern: Plain) {\n name\n color\n pattern\n }\n}","variables":{"name":"Neighbour","color":"black","pattern":"Plain"},"operationName":"addCat"}'
{"data":{"addCat":{"name":"Neighbour","color":"black","pattern":"Plain"}}}
We can see the GraphQl schema with get-graphql-schema
(base) tammo@ananas:~$ get-graphql-schema http:localhost:8080
schema {
query: Queries
mutation: Mutations
}
type Cat {
name: String!
color: String!
pattern: Pattern!
}
type Mutations {
addCat(name: String!, color: String!, pattern: Pattern!): Cat!
}
enum Pattern {
Plain
Stripes
}
type Queries {
cats: [Cat!]!
}
Caliban has some good defaults for generating a schema, but maybe we to have some things in a different way.
For example Cat.Color
is a value class, which is just shown as String
here.
I like color to be a type in the schema with some documentation.
To do this I can create an implicit for Cat.Color
and import it in Api.
object ApiSchema extends GenericSchema[Any] {
implicit val colorSchema: Schema[Any, Color] = scalarSchema[Color]("CatColor", Some("the color of the cat"), None, c => StringValue(c.s))
}
Resulting in this schema:
type Cat {
name: String!
color: CatColor!
pattern: Pattern!
}
"""the color of the cat"""
scalar CatColor
type Mutations {
addCat(name: String!, color: CatColor!, pattern: Pattern!): Cat!
}
Let’s make our example a bit more realistic. We’ll move the CatRepo
into the Zio environment
. Seen from the outside it will remain the same.
class CatRepo {
//in memory database of cats. not threadsafe or reasonable
var catDb: List[Cat] = List(Cat("Koffie", Color("grey"), Pattern.Stripes), Cat("Minoes", Color("brown"), Pattern.Plain))
def getAllCats: Task[List[Cat]] = ZIO.succeed(catDb)
def addCat(cat: Cat): Task[Cat] = Task {
catDb = cat :: catDb
cat
}
}
/** some convenience methods to use in the API */
object CatRepo {
def getAllCats: RIO[CatRepo, List[Cat]] = ZIO.environmentWithZIO[CatRepo](_.get.getAllCats)
def addCat(cat: Cat): RIO[CatRepo, Cat] = ZIO.environmentWithZIO[CatRepo](_.get.addCat(cat))
}
case class Queries(cats: () => RIO[CatRepo, List[Cat]])
case class Mutations(addCat: Cat => RIO[CatRepo, Cat])
class Api {
val catRepoLayer: ULayer[CatRepo] = ZLayer.succeed(new CatRepo())
implicit val runtime: Runtime[CatRepo] = zio.Runtime.unsafeFromLayer(catRepoLayer)
val i = runtime.unsafeRun(createInterpreter)
def route = AkkaHttpAdapter.makeHttpService(i)
def createInterpreter = {
import ApiSchema._
GraphQL.graphQL(
RootResolver(
Queries(
() => CatRepo.getAllCats
),
Mutations(
CatRepo.addCat
)
)
).interpreter
}
}
object ApiSchema extends GenericSchema[CatRepo] {
implicit val colorSchema: Schema[Any, Color] = scalarSchema[Color]("CatColor", Some("the color of the cat"), None, c => StringValue(c.s))
}
We can add a field that is only determined on demand. This is great for resource-heavy or circular fields.
class CatRepo {
def similar(cat: Cat): Task[List[Cat]] = Task {
catDb.filter(x => x != cat && x.color == cat.color)
}
}
object ApiSchema extends GenericSchema[CatRepo] {
implicit val catSchema: Schema[CatRepo, Cat] = obj[CatRepo, Cat]("Cat")(
implicit ft =>
List(
field("name")(_.name),
field("color")(_.color),
field("pattern")(_.pattern),
field("similar")(cat => CatRepo.similar(cat))
)
)
}