Writing Shell Scripts Using Elixir

Posted by Alex Koutmos on Monday, April 11, 2022

Contents

Intro

First of all, my apologies for not having written a blog post since July of last year! Between maintaining all my open source libraries, publishing Build a Weather Station with Elixir and Nerves, starting Elixir Patterns with Hugo Barauna, running my consulting business and being a host on the BEAM Radio podcast…something had to give and unfortunately it was my Elixir blog 😔. But alas, I have promised myself that I can (and will) write a new blog post every quarter as opposed to every month like I used to. All this to say: don’t worry, the blog is not dead and new content is coming!

With that aside, let’s dive into today’s article! In this blog post, we’ll be talking about something really awesome that was introduced in Elixir 1.12.0, but that perhaps goes underutilized. Specifically, I am talking about Mix.install and how it can be used to write useful shell scripts. While this won’t replace all of your existing Bash and Python CLI tools, this can be an easy way for you to write your own utilities in your favorite programming language 😉. Without further ado, let’s dive right into things!

Writing a basic Elixir shell script

While Elixir 1.12.0 didn’t necessarily unlock the ability to write shell scripts using Elixir, it definitely made writing shell scripts easier when you have external dependencies. But before we get into that, let’s see what a very basic shell script looks like using Elixir.

Start off by creating a file anywhere on your filesystem. For example, I created the file hello_world with the following contents:

#! /usr/bin/env elixir

IO.puts("Hello world!")

The first line in this file tells your shell to use the elixir executable that first appears in your $PATH environment variable. You can also hard-code a path like #! /bin/elixir, but this has the ability to fail since that path may not exist on an other user’s machine. In addition, if you use something like ASDF to manage your runtime versions, you may not be invoking the version you think you are. The last line is a simple Elixir print statement so you can make sure that our script runs as expected.

After creating that file, make sure that you run chmod a+x ./hello_world so that your file has execution privileges. With all that in place, all you need to do is run the script:

$ ./hello_world
Hello world!

And with that, you have a shell script that you can run from your command line! Let’s turn it up a notch and create a simple HTTP stress tester using the same approach and more of the Elixir standard library.

Writing an HTTP stress test script

For this next shell script, you’ll start off in much the same way as before. Create a new file using your favorite editor. In my case I called my file http_stress (make sure you give the file execution permissions via chmod). This script will be a bit more complicated than the previous script as we need to accept user CLI input, parse it, start networking Erlang applications and run the actual load test. Let’s start by parsing the user’s CLI arguments:

#! /usr/bin/env elixir

cli_args = System.argv()
supported_cli_args = [url: :string, total_requests: :integer, concurrency: :integer]

%{url: url, total_requests: total_requests, concurrency: concurrency} =
  cli_args
  |> OptionParser.parse(strict: supported_cli_args)
  |> case do
    {parsed_cli_args, [], []} when length(parsed_cli_args) == 3 ->
      Map.new(parsed_cli_args)

    _ ->
      raise "Invalid arguments were provided"
  end

In this code snippet, we fetch the command line arguments provided to the script via System.argv(), and then pass those arguments along with a definition of all the supported arguments for the script (the supported_cli_args keyword list) to the OptionParser.parse/2 function. This function parses the CLI arguments and returns a three element tuple where the first element contains a keyword list of all the valid arguments, the second arguments contains the arguments that were not specified in the supported arguments list, and the last argument contains any invalid arguments. In the above snippet, I use a guard clause to ensure that the length of the valid arguments list provided by OptionParser.parse/2 is three, else raise an error. You can get a little fancier here and check which arguments are missing and provide a more specific error message 😊.

With our input fully parsed we are ready to start stress testing! Add the following to your shell script and let’s then review how it all works:

#! /usr/bin/env elixir

...

:inets.start()
:ssl.start()

case :httpc.request(:get, {String.to_charlist(url), []}, [ssl: [verify: :verify_none]], []) do
  {:error, _} ->
    raise "Failed to connect to the provided URL"

  _ ->
    :ok
end

test_start_time = System.monotonic_time()

results =
  1..total_requests
  |> Task.async_stream(
    fn _ ->
      start_time = System.monotonic_time()
      :httpc.request(:get, {String.to_charlist(url), []}, [ssl: [verify: :verify_none]], [])
      System.monotonic_time() - start_time
    end,
    max_concurrency: concurrency
  )
  |> Enum.map(fn
    {:ok, req_time} -> System.convert_time_unit(req_time, :native, :millisecond)
    _ -> []
  end)
  |> List.flatten()

total_test_time = System.convert_time_unit(System.monotonic_time() - test_start_time, :native, :millisecond)

IO.puts("Total test time: #{total_test_time}ms")
IO.puts("Average response time to GET #{url}: #{Enum.sum(results) / length(results)}ms")
IO.puts("Min response time: #{Enum.min(results)}ms")
IO.puts("Max response time: #{Enum.max(results)}ms")

We being this portion of the code by starting the :inets and :ssl Erlang applications as we need those to make HTTP requests. After those two applications are started, we make a single request to the provided URL to ensure that it is a valid URL. If that succeeds, we capture the system monotonic time to know when the test started and we then use Task.async_stream/3 to run all of the stress tests with the desired level of concurrency, capturing the duration for each request that is made. After all the tests complete, some basic statistics are generated and then presented to the user.

With the HTTP stress test script all put together, it is time to take it for a test drive. Take a look at some of the following examples to see how the script behaves when valid arguments are provided and when invalid arguments are provided.

$ ./http_stress --url https://akoutmosssssssssss.com --total-requests 500 --concurrency 100
** (RuntimeError) Failed to connect to the provided URL
    http_stress:22: (file)
    (elixir 1.13.3) lib/code.ex:1183: Code.require_file/2

$ ./http_stress
** (RuntimeError) Invalid arguments were provided
    http_stress:14: (file)
    (elixir 1.13.3) lib/code.ex:1183: Code.require_file/2

$ ./http_stress --url https://akoutmos.com --total-requests 500 --concurrency 50
Total test time: 2201ms
Average response time to GET https://akoutmos.com: 104.462ms
Min response time: 31ms
Max response time: 2192ms

$ ./http_stress --url https://akoutmos.com --total-requests 500 --concurrency 100
Total test time: 1526ms
Average response time to GET https://akoutmos.com: 263.004ms
Min response time: 38ms
Max response time: 1372ms

While this simple stress test script may not compare with something like ApacheBench, k6, or wrk in terms of features, it is pretty cool what can be done in about 50 lines of Elixir code.

Now that you have a good handle on how to write shell scripts using Elixir, it’s time to get to the meat of this article and learn how to leverage external dependencies in your script!

Using External Dependencies

In order to really flex our Elixir CLI muscles, in this section, we’ll be writing an Elixir JSON pretty printer, that takes its input from STDIN, and uses the Jason library to parse the provided JSON payload. After the JSON payload is parsed, we’ll recursively traverse the data structure, and properly tab and print the contents of the JSON payload with colors for each of the data types. With a plan in place, let’s get started and see how this works.

Let’s create a new file called json in the current directory and give it execute permissions. With that done, open up json with your editor and add the following to get started:

#! /usr/bin/env elixir

Mix.install([:jason])

defmodule JsonPrettyPrinter do
  def get_stdin_data do
    :stdio
    |> IO.read(:all)
    |> Jason.decode()
    |> case do
      {:ok, json_data} -> json_data
      _ -> raise "Invalid JSON payload was provided"
    end
  end
end

JsonPrettyPrinter.get_stdin_data()
|> JsonPrettyPrinter.pretty_print_json()
|> IO.puts()

As you can see, we start the script in exactly the same way as we did the other examples with #! /usr/bin/env elixir. After that, we call Mix.install([:jason]) which tells Mix to install the Jason dependency and store it in either the path provided in the MIX_INSTALL_DIR variable or by default your operating system’s cache directory. To see where this cache directory is, you can run Path.join(Mix.Utils.mix_cache(), "installs") in an IEx session. Mix.Utils is an undocumented Elixir module, so that may or may not exist in the future. On my Macbook for example, the Mix cache directory is /Users/HOME/Library/Caches/mix. You can also specify a version for your dependencies by providing a tuple exactly like you would in your mix.exs file for a project: Mix.install([{:jason, "~> 1.3.0"}].

Next, we define a module called JsonPrettyPrinter which we’ll use to house all of the pretty printing utility functions. The first function that we’ll create is the one that will read from STDIN. Using IO.read/2 and Jason.decode/1 we are able to read all of the data from STDIN and parse the data using Jason. If the data is not valid JSON, we raise and exit the script. After the module declaration, we have a very simple pipe chain that calls the JsonPrettyPrinter.get_stdin_data/0 function and then passes it to JsonPrettyPrinter.pretty_print_json and prints the result. Let’s fill out the rest of the JsonPrettyPrinter module to wrap up this implementation:

defmodule JsonPrettyPrinter do
  ...

  def pretty_print_json(data) do
    body =
      data
      |> Enum.to_list()
      |> Enum.sort()
      |> Enum.map(fn {key, value} ->
        ~s/  #{color_data(:green, key)}#{color_data(:yellow, ":")} #{pretty_print(value, 1)}/
      end)
      |> Enum.join(",\n")

    """
    #{color_data(:yellow, "{")}
    #{body}
    #{color_data(:yellow, "}")}
    """
  end

  defp pretty_print(current_data, _indent_level) when is_number(current_data),
    do: color_data(:blue, current_data)

  defp pretty_print(current_data, _indent_level) when is_boolean(current_data),
    do: color_data(:magenta, current_data)

  defp pretty_print(current_data, _indent_level) when is_binary(current_data),
    do: color_data(:cyan, ~s/"#{current_data}"/)

  defp pretty_print(current_data, _indent_level) when is_nil(current_data),
    do: color_data(:red, "null")

  defp pretty_print(current_data, _indent_level) when current_data == %{},
    do: color_data(:yellow, "{}")

  defp pretty_print(current_data, _indent_level) when current_data == [],
    do: color_data(:yellow, "[]")

  defp pretty_print(current_data, indent_level) when is_list(current_data) do
    indent_padding = String.duplicate(" ", indent_level * 2)
    next_indent_padding = String.duplicate(" ", (indent_level + 1) * 2)

    body =
      current_data
      |> Enum.sort()
      |> Enum.map(fn element ->
        ~s/#{next_indent_padding}#{pretty_print(element, indent_level + 1)}/
      end)
      |> Enum.join(",\n")

    Enum.join(
      [
        color_data(:yellow, "["),
        body,
        "#{indent_padding}#{color_data(:yellow, "]")}"
      ],
      "\n"
    )
  end

  defp pretty_print(current_data, indent_level) when is_map(current_data) do
    indent_padding = String.duplicate(" ", indent_level * 2)
    next_indent_padding = String.duplicate(" ", (indent_level + 1) * 2)

    body =
      current_data
      |> Enum.to_list()
      |> Enum.sort()
      |> Enum.map(fn {key, value} ->
        ~s/#{next_indent_padding}#{color_data(:green, key)}#{color_data(:yellow, ":")} #{pretty_print(value, indent_level + 1)}/
      end)
      |> Enum.join(",\n")

    Enum.join(
      [
        color_data(:yellow, "{"),
        body,
        "#{indent_padding}#{color_data(:yellow, "}")}"
      ],
      "\n"
    )
  end

  defp color_data(color, data), do: apply(IO.ANSI, color, []) <> "#{data}" <> IO.ANSI.reset()
end

I won’t dive too deep into the previous code as it is mostly some recursion, pattern matching and guard clauses to properly color and format the data. One function that I will touch on though is the color_data/2 function as that is useful for CLI tools. By using the IO.ANSI module, you can colorize your terminal output. Be sure to check out the documentation to see all the options available to you!

With that done, the JSON pretty printer is complete! Let’s throw some data at it and see how it responds (the dummy test file was taken from https://json.org/example.html):

And with that, our very basic JSON pretty printer is complete! As you can see, you can pipe any data you like to the scripts STDIN, and the Elixir script will handle it just the same.

Closing thoughts

Thanks for stick with me till the end and hopefully you enjoyed it! I hope you picked up a couple of cool tips and tricks along the way 😊. As you saw from the post, Elixir along with its standard library and Hex ecosystem makes for a great scripting language environment. For every day CLI scripts and tools that live along side your project repos, this is a viable path and it makes it easier for developers to stay within a single language. Feel free to leave comments or feedback or even what you would like to see in the next blog post. Till next time!


comments powered by Disqus