Ammonite + Uberjar = Domain Shell

I had an issue, when on my test server I had to modify some values. I could log in directly into database, but I didn’t want to. I could use REST API, but not all services are mapped to endpoints (and for a good reason!). Nonetheless, sometimes I needed to call them.

This problem could be solved in a lot of ways. I could create dedicated API just for using superuser-like features and then enable them just on my testing server. I could map all services to endpoints, create additional permissions and hide some of them from normal API users. But all of that would be time-consuming and I am too lazy to create such an elaborate solution just for my test server.

This is where Ammonite comes into the picture. It’s an improved Scala REPL, that makes it possible to write shell scripts with Scala. Besides just evaluating code it also includes tools to navigate filesystem, run processes, etc. But what interested me the most, is the ability to dynamically load library: from Ivy, Maven or local directory.

My plan was simple: run Ammonite, figure out how to load backend.jar into it, import services into the scope and run them. Since settings like database credentials are taken from environment variables, I would be able to use services immediately.

First I attempted to load uberjar into the Ammonite. After some searching I figured it would be:

import $cp.`backend.jar` // add uberjar to classpath
@ // force loading it
import my.backend.domain.models._ // does it work?
DomainModel("test") // yes, it does!

However, this leave me with issue of initializing all services I need.

On backend services initialization looked kind of like this:

class Services(database: Database, scheduler: Scheduler) {
  val companyServices = new CompanyServices(...)
  val entitlementServices = new EntitlementServices(...)
  ...
}

I didn’t want to create them manually each time I run the REPL. So I created an object to solve this problem for me:

object AmmoniteHelper {
  implicit val scheduler = ...
  val database = ...
  val services = new Services(database, scheduler)
}

(Objects are lazily evaluated, so I just needed to nevel touch it in backend’s main function to avoid double initialization).

Now, I could access all services after calling in Ammonite:

import $cp.`backend.jar`
@
import my.backend.AmmoniteHelper._

To make things perfect I needed to make sure, that amm has all right environment variables set and load this code on start. The former I achieved with simple shells script:

#!/bin/sh
cd ${0:a:h} # set current dir to script's directory
# here add environment variables used by database
amm

For the latter, I put initialization commands into backend.sc and added --predef "backend.sc" arguments to amm. For final touch I changed amm welcome command to Ammonite shell for My Domain backend:

#!/bin/sh
cd ${0:a:h} # set current dir to script's directory
# here add environment variables used by database
amm --predef 'backend.sc' --banner 'Ammonite shell for My Domain backend'

Et voilà! Now I can manage my backend calling services directly:

$ ./domain-shell.sh
Ammonite shell for My Domain backend
@ services.companyServices.listCompanies().runAsync

(Yes, I use Monix’s Task for my services).

All in all, it’s not a complex thing - just two 5-line-long scripts - but I found it amazing, how easy it is to build a full-featured domain-embedded admin shell just by feeding Ammonite the right stuff. Kudos to Li Haoyi for such an amazing job!