One of the things I do quite frequently as a developer is setting up a development environment. I do lots of prototyping, so I’m always creating new applications that have differing environment requirements. Typically, I use foreman to run an application in an environment defined in a .env file (e.g. foreman run mix phoenix.server), and I define an application’s environment needs in an app.json file. This works well, but filling in a .env file from the requirements outlined in an app.json file is really tedious.

In order to solve this, I decided to build a command-line application to help with filling in .env files. The application would need to:

  1. Parse the “env” section in the app.json file
  2. Merge the parsed “env” with values from the “environments” section, if needed
  3. Merge any existing values from an existing .env file
  4. Prompt the user to optionally override any values
  5. Write the values back to the .env file

I wanted the application to be easy to install with as few requirements as possible. For me, that narrowed the choices down to Crystal and Go. I decided to go with Crystal, because although I appreciate that it’s very easy to cross-compile Go with no needed dependencies, I like Crystal’s Ruby-like syntax and useful built-in command-line option parser. If you like, you can skip to the end result at jclem/bstrap (brew tap jclem/bstrap && brew install bstrap on a Mac). In this post, I’m going to reflect on what I liked about building this small application with Crystal and what was difficult.

Parsing Command-Line Options

One of the first things that I found useful was, as I mentioned before, Crystal’s command-line option parser that comes as part of its standard library. I wanted to have the commonly seen sort of command line options where a user can pass a full option name or an alias. This was just as easy to do in Crystal as it is in Ruby:

require "option_parser"

class MyCLI
  def run
    path = "./default-path.txt"

    OptionParser.parse! do |parser|
      parser.banner = "Usage mycli [arguments]"

      parser.on("-p PATH", "--path PATH", "Path to a file") do |opt_path|
        path = opt_path
      end

      parser.on("-h", "--help", "Show this help") do
        puts parser
        exit 0
      end
    end

    puts "Your path is #{path}."
  end
end

With just a handful of lines and some other simple code to call this class, a user can mycli -p file.txtmycli --path=file.txt, and mycli --help. I was really happy with how easy this was.

If you’ve ever used Ruby’s OptionParser class before, you’ll notice that the Crystal equivalent is almost identical. A more complete example of option parsing in Crystal is in the bstrap repo, or you can try out similar code in a Crystal playground.

Parsing JSON

The next hurdle for me, parsing the JSON contents of an app.json file, was the one I knew I’d have the most trouble with. Crystal is a statically type-checked language, so I knew that there might be a good deal of boilerplate involved in parsing an app.json file and ensuring that I’m working with the types I expect to be working with. Further complicating things is the fact that the app.json specification allows multiple different types for many values. For example, an entry in “env” can be either a string representing the default value of that environment variable or an object describing the environment variable, e.g.:

{
  "env": {
    "NODE_ENV": "production",
    "DATABASE_URL": {
      "description": "A URL pointing to a PostgreSQL database"
    }
  }
}

The first step in parsing JSON was to write a simple function to read the app.json file, parse it, and return a hash or raise if the root of the JSON document is not an object. This was relatively straightforward—I’ll define a function called parse_app_json_env (we’ll add the “env” parsing to it soon):

class Bstrap::AppJSON
  class InvalidAppJSON < Exception
  end

  def parse_app_json_env(path : String)
    raw_json = File.read(path)

    if app_json = JSON.parse(raw_json).as_h?
      app_json
    else
      raise InvalidAppJSON.new("app.json was file not an object")
    end
  rescue JSON::ParseException
    raise InvalidAppJSON.new("app.json was not valid JSON")
  end
end

The basic form of this function was relatively straightforward. First, we read the file at the given path and parse it as JSON (notice that we rescue invalid JSON and return our custom exception). Then, we check whether the parsed JSON is an object. We do this because in JSON, a document may be an object, an array, or a scalar value. Obviously, we want to ensure that the contents of our app.json aren’t, for example, an array or simply an integer.

It took me a little bit of getting used to, but Crystal actually makes this checking pretty easy. The JSON.parse class method returns a type called JSON::Any. This type is simply a wrapper around all possible JSON types, and provides some useful methods to ensure we’re wrapping the type we want. In the above example, you’ll see #as_h? called. This method returns the type Hash(String, JSON::Type)? meaning either nil or a hash with string keys and JSON type values. Putting things together, we can check #as_h? and either return that hash or raise an error because the contents of our app.json file was something other than an object.