Ammonite + Uberjar = Domain Shell
I had an issue when I had to modify some values on my test server. I could log in directly to the database, but I didn’t want to. I could use the 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 a 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 the filesystem, run processes, etc. But what interested me the most is the ability to dynamically load a library from Ivy, Maven or a 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 the uberjar into 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 left me with the issue of initializing all the services I needed.
On the backend, service 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 never touch it in the backend’s main function to avoid double initialization).
Now, I could access all services after running this in Ammonite:
import $cp.`backend.jar`
@
import my.backend.AmmoniteHelper._
To make things perfect, I needed to make sure that amm had all the right environment variables set and that it loaded this code on start. The former I achieved with a simple shell script:
#!/bin/sh
cd ${0:a:h} # set current dir to script's directory
# here add environment variables used by the database
amm
For the latter, I put initialization commands into backend.sc and added --predef "backend.sc" arguments to amm. For the 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 the database
amm --predef 'backend.sc' --banner 'Ammonite shell for My Domain backend'
Et voilà! Now I can manage my backend by 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!