Creating a CLI with Smithy4s and Scala Native

Smithy4s provides a module for creating CLIs with Decline. Smithy4s cross-compiles many of its modules to Scala Native, including this one. This means we can create CLIs that work with Scala Native to avoid the JVM boot up time when invoking the CLI. We could similarly use Scala.js, or we could just use the typical JVM runtime as well, but for this post we will focus on Scala Native.

What We’re Creating

In this post, we will be creating a tiny CLI that will act as a client for the NumbersApi. This is a free API that doesn’t require any authentication, so it will work well as an example.

The Code

Here is the full code for this blog post if you want to clone it or follow along.

Dependencies

Here is a list of the dependencies we are using in this article, along with an explanation of what they are for:

SBT Plugins

  • "org.scala-native" % "sbt-scala-native" % "0.4.17": Provides support for compiling to Scala Native.
  • "com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.27": Allows Smithy4s to generate code from our Smithy file(s) and add them as managed sources to our SBT Project.

Compile-Time Dependencies

  • "com.disneystreaming.smithy4s" %%% "smithy4s-http4s" % smithy4sVersion.value: We will use this to create an Http4s client to call out to the NumbersApi with.
  • "com.disneystreaming.smithy4s" %%% "smithy4s-decline" % smithy4sVersion.value: Allows creation of a Decline CLI automatically using Smithy4s.
  • "org.http4s" %%% "http4s-ember-client" % "0.23.30": This is the Http4s client implementation we will be using, since it supports Scala Native.
  • "com.monovore" %%% "decline-effect" % "2.4.1": Provides an easy way to bootstrap Decline CLIs with Cats Effect.
  • "com.armanbilge" %%% "epollcat" % "0.1.6": Provides a runtime for Cats Effect that is compatible with Scala Native.

Environmental Dependencies

To use Scala Native, you’ll need to have a few things set up on your local machine (see Scala Native docs for more info).

  • brew install llvm: A required compiler toolchain for working with Scala Native.
  • brew install s2n: For TLS/SSL, required by Ember when using Scala Native.

Implementation

src/main/smithy/numbers.smithy

First, we create a Smithy specification that defines the endpoint we’ll be calling on the NumbersApi. It looks like this:

$version: "2"

namespace cli

@alloy#simpleRestJson // 1
service Numbers {
    operations: [
        GetNumberFact
    ]
}

@readonly
@http(method: "GET", uri: "/{number}/{type}")
operation GetNumberFact {
    input := {
        @required
        @pattern("^(([0-9]*)|(random))$") // 2
        @httpLabel
        number: String

        @required
        @httpLabel
        type: Type

        @required
        @httpQuery("json")
        json: Boolean = true
    }

    output := {
        @required
        text: String

        @required
        found: Boolean
    }
}

enum Type { // 3
    TRIVIA = "trivia"
    MATH = "math"
    DATE = "date"
    YEAR = "year"
}

Here we are defining a single operation, GetNumberFact that takes a number and a fact type and gives back a fact about that number. We are not going to bother exhaustively implementing the entire NumbersApi, but rather focus on this small part of it. I commented above numbers 1-3 to give a little more explanation about these pieces of the Smithy spec:

  1. This is assigning the alloy#simpleRestJson protocol to the service. Smithy itself is protocol-agnostic, meaning we can define any API using it, including ones using Protobuf, XML, or other. In this case, we are using JSON, so we apply the alloy#simpleRestJson protocol. See Alloy Docs for more on this particular protocol, or the Smithy4s Docs for more about protocols in general.
  2. Here we are adding the smithy.api#pattern constraint trait onto our field called number. This is because the NumbersAPI accepts not only integers as input, but also the string value "random". As such, we cannot use a simple Integer for this field, but we also don’t want to permit any string in general. The pattern trait allows us to specify a regex that will be enforced for the field.
  3. Here, we are defining an enum that represents the possible types of facts that can be requested from the NumbersApi. The syntax you see with TRIVIA = "trivia" is creating an enum name and an enum value (e.g., NAME = "value"). The name is what is used in generated code to refer to this case of the enum. The value is what is actually sent over the wire at runtime. In this case, the NumbersApi expects lowercase strings to be sent, so we define the values as lowercase.

src/main/scala/Main.scala

import smithy4s.http4s.*
import smithy4s.decline.Smithy4sCli
import cats.effect.*
import com.monovore.decline.*
import com.monovore.decline.effect.CommandIOApp
import org.http4s.ember.client.EmberClientBuilder
import cats.effect.kernel.Resource
import org.http4s.client.Client
import org.http4s.Uri
import epollcat.EpollApp

object Main extends EpollApp { // 1

  private val http4sClient: Resource[IO, Client[IO]] =
    EmberClientBuilder.default[IO].build // 2

  private val numbersClient = http4sClient.flatMap { baseClient =>
    SimpleRestJsonBuilder(cli.Numbers) // 3
      .client(baseClient)
      .uri(Uri.unsafeFromString("http://numbersapi.com"))
      .resource
  }

  private def createCli(args: List[String]): IO[ExitCode] = numbersClient.use {
    client =>
      val command = Smithy4sCli
        .standalone(Opts(client)) // 4
        .command
        .map(
          _.redeem(_ => ExitCode.Error, _ => ExitCode.Success)
        )

      CommandIOApp.run(command, args) // 5
  }

  def run(args: List[String]): IO[ExitCode] = {
    createCli(args)
  }
}
  1. This is where we are using EpollApp where we would normally use IOApp. The reason for this is to get a Cats Effect runtime that is compatible with Scala Native.
  2. Here we are creating an Http4s client with Ember using default settings.
  3. This creates a client for the NumbersApi using Smithy4s. This client has all the ability to call our operation we defined in our Smithy file.
  4. Now we can create the Decline CLI using the Smithy4sCli#standalone builder provided by the smithy4s-decline module.
  5. Finally, we use CommandIOApp, provided by the decline-effect library, to run our CLI.

Running the CLI

All that’s left now is running the CLI. To do this, we use sbt and can run a command such as:

sbt:native-cli> run get-number-fact 10000 trivia
> GetNumberFactInput(10000,TRIVIA,false)
< GetNumberFactOutput(10000 is the number of other neurons each neuron is connected to in the human brain.,true)

Note that compilation for Scala Native apps takes quite a bit longer than it does for JVM ones. This is to be expected, but once compiled it is very fast to call the CLI.

Conclusion

Smithy4s provides an easy way to construct a CLI for any API defined in Smithy (that’s using the alloy#simpleRestJson protocol). There are some peculiar mechanics required to get things working with Scala Native, but all things considered it is quite straightforward. Hopefully, this post provides you enough information to get started on a CLI of your own.