Scripting Remote Services With Ammonite

Reading time 8 min

At Bright IT, we often extend Content Management Systems for our customers. Predictably, this means that we need to interact with CMS services allowing remote access to content and data. Tasks for which this interaction is necessary are ofter either repetitive - like uploading development content - or formulaic - like migrating content to a new format. Naturally, this makes them prime candidates for automation.

As CMS services often have Java bindings available and we're a Scala company, we've begun considering using Scala to solve the issue. After all, Scala is both known for its strong static typing - which lends itself well to ensuring that, for instance, client's content is migrated safely - and its remarkable terseness which sometimes makes people confuse it with a dynamic language. This is where Ammonite, a modernized Scala REPL and script runner, comes in. Ammonite can run Scala files as though they were scripts. "Hello, world" is as simple as:

#!/usr/bin/env amm println("Hello, world!")

Notice that there's zero fuss necessary - we just write the code that we want to be executed with no ceremony. Of course, this is not where Ammonite's usefulness ends - the nice thing about it is that it not only makes writing Scala scripts possible, it also makes it easy. Let's see a script that will output the status of a specific issue on GitHub:

#!/usr/bin/env amm import $ivy.`org.kohsuke:github-api:1.93` import org.kohsuke.github._ @main def main(repo: String, issue: Int) = { val gh = GitHub.connectAnonymously() val issueState = gh.getRepository(repo).getIssue(issue).getState println(s"$repo issue #$issue is $issueState") }

Example interaction with this script looks like this:

> ./check-issue.sh --repo lihaoyi/ammonite --issue 10 lihaoyi/ammonite issue #10 is CLOSED

This time, we did need to write a main method - but this was because we needed to parse arguments. Notice that there's no argument parsing code in the script - we simply annotated a method with @main and Ammonite took care of the rest. We also needed to include a dependency, which again was done with an absolute minimum of fuss - all that was necessary was a "magic" $ivy import. In Python, the library would need to be downloaded before the script can be run, but Ammonite takes care of that on its own - if the library is not available locally, it will be downloaded when the script is executed for the first time.

Ammonite also allows scripts to include other scripts:

// banner.sc def display(text: String): Unit = { println("*" * (text.length + 5)) println(s"* $text *") println("*" * (text.length + 5)) } // greet.sc #!/usr/bin/env amm import $file.banner println("Hello! What is your name?") val name = Console.readLine() banner.display(s"Hello, $name!")

The above greet.sc behaves like this:

> ./greet.sc Hello! What is your name? Alex **************** * Hello, Alex! * ****************

This feature is very much necessary when scripting remote services, as we definitely do not want to duplicate service-related code inside each script. More interestingly, it plays very nicely with @main methods - a script can call other scripts in a simple way and remain type-safe since, after all, the entrypoints to other scripts are just methods.

Last but not least, Ammonite also helps with developing scripts. Since Scala is statically typed, we can expect a type error or ten when writing our scripts. Manually re-running them can get tedious, so Ammonite can instead watch script files and re-run them on each change if --watch flag is set. If combined with --predef flag, a REPL will instead be opened with the script's top-level definitions available. Ammonite allows opening source code for methods and objects and additionally has great tab-completion and multi-line editing - all of these features allow using Ammonite as a quasi-IDE for progressively writing the script.

An Example

To illustrate the points so far, we've prepared an example repository that allows interacting with a Northwind-like database.

> git clone $REPO && cd $REPONAME > # we will now bring up a Docker container with a DB and insert data into it > cmd/setup.sc Docker container is up and running Inserted: 10 employees 92 customers 101 orders > # Check the last order in the database > cmd/order.sc last Last order: Order(10348,5,FAMIA,Familia Arquibaldo,Sao Paulo) > # Add a new one and see if it was inserted > cmd/order.sc place --employee 2 --customer ANTON --shipName Boat --shipCity London > cmd/order.sc compare Last local order (10348) is 2 order(s) behind > cmd/order.sc last Last order: Order(10349,2,ANTON,Boat,London) > # Finally, check the status of the Docker container and bring it down > cmd/docker.sc status Docker container is up and running > cmd/docker.sc down

Scripts inside the cmd directory can be used to set up and tear down the local environment, download remote data and update it. Some of the scripts are lower-level than others - for example, docker.sc is responsible for Docker and is run by setup.sc when setting up the environment. If necessary, cmd/repl can be used to open up a REPL with all the scripts loaded and ready to run. This is very helpful if you're not entirely familiar with the scripts since inside the REPL we have tab-completion available. Since argument parsing is based on methods, running the scripts from the REPL is very similar to running them normally:

> cmd/repl Welcome to the Ammonite Repl 1.1.2 (Scala 2.12.6 Java 1.8.0_144) If you like Ammonite, please support our development at www.patreon.com/lihaoyi @ order.place( employee = 2, customer = "ANTON", shipName = "Boat", shipCity = "London" ) @ order.compare() Last local order (10347) is 1 order(s) behind @ order.last() Last order: Order(10348,2,ANTON,Boat,London)

On a higher level, it's worth noticing that there are two kinds of Scala files in the repository - "scripts", which are directly in cmd directory, and "modules" contained inside cmd/modules. "Modules" are code common to most, if not all scripts - in this specific case they are essentially bindings for the Northwind database. When interacting with a service for which only Java bindings are available, modules would mostly contain Scala adapters instead. A very good reason for organizing code like this, besides avoiding code duplication, is that it's very easy to create a library out of such modules - each file can be directly converted to an object. This, in turn, is very handy if it turns out that another project needs similar scripts - common parts can simply be lifted to a library, ready to be downloaded from the company repository.

This brings us to the last, but not least, problem which our example repository demonstrates how to solve - custom repositories and authorization. While Ammonite by default has support for the former, it doesn't really support the latter. We've prepared a minimal wrapper around Ammonite which solves this problem, aptly named amm+authbin/try-auth.sh will start a Bash session where amm is based on amm+auth:

> bin/try-auth.sc bash-4.4$ bin/is-prime.sc 5 true bash-4.4$ cat bin/is-prime.sc #!/usr/bin/env amm import $ivy.`org.apache.commons:commons-math3:3.4.1.redhat-3` @main def check(n: Int) = org.apache.commons.math3.primes.Primes.isPrime(n) bash-4.4$ ^D

As we can see, is-prime.sc uses a specific version of Apache commons-math3, only available from the Red Hat Maven repository. You can try running it without using try-auth.sh - it won't work! bin/try-auth.sc adds bin to your $PATH, which contains amm script using amm+auth internally. Hypothetically, if the Red Hat required authorization for its Maven repository, storing the credentials in your home directory so amm+auth can use them would be as simple as:

bash-4.4$ coursier+auth cred set redhat user: redhat-user password:

If you're interested in seeing how amm+auth can help you, head over to its repo at https://github.com/BrightIT/coursier-plus-auth.

Conclusion

To sum up: we've found the approach we've presented so far very useful for developing scripts that can interact with remote services. Ammonite not only allows writing such scripts, but it also lends itself naturally to building simple command-line interfaces out of them and, if necessary, creating libraries out of CLIs developed this way. Additionally, Ammonite's features are helpful enough during development that often no IDE is necessary. If you're too often manually dealing with services for which Java bindings are available, then scripting those interactions with Ammonite might be just what you need.

About Bright

We are a team of marketing and technology experts who team up with you to create amazing digital services and products – websites, web applications and online shops on the pulse of time.