DEV Community

Cover image for Create a Server Driven CLI from your REST API
Adrian Machado for Zuplo

Posted on • Originally published at zuplo.com

Create a Server Driven CLI from your REST API

This article is written by Rahul Dé, a VP of > Site Reliability Engineering at Citi and creator/maintainer of popular tools > like babashka, > bob, and now > climate. All opinions expressed are > his own.

APIs, specifically the REST APIs are everywhere and the OpenAPI is pretty much a standard. Accessing them via various means is a fairly regular thing that a lot of us do often and when it comes to the CLI (Command Line Interface) languages like Go, Rust etc are quite popular choices when building. These languages are mostly of statically typed in nature, favouring a closed world approach of knowing all the types and paths at compile time to be able to produce lean and efficient binary for ease of deployment and use.

Like with every engineering choice, there are trade-offs. The one that's here is the loss of dynamism, namely we see a lot of bespoke tooling in these languages doing fundamentally the same thing: make HTTP calls and let users have a better experience than making those calls themselves. The need to know all the types and paths beforehand causes these perceived maintenance issues:

  • Spec duplication: the paths, the schemas etc need to be replicated on the client side again. eg when using the popular Cobra lib for Go, one must tell it all the possible types beforehand.
  • Tighter coupling of client and server: As we have to know each of the paths and the methods that a server expects, we need to essentially replicate that same thing when making the requests making a tight coupling which is susceptible to breakage when the API changes. API is a product having its own versioning. eg kubectl only supports certain versions of kubernetes. Similarly podman or docker CLIs.
  • Servers can't influence the client: Ironically to the previous point, as we now have replicated the server spec on the client side we effectively have a split brain: changes on the server like needing a new parameter etc need to be copied over to the client.

All of this put together increases the maintenance overhead and its specially true for complex tooling like kubectl.

Using standards

I work primarily on the infra side of this, namely Platform and Site Reliability Engineering which involves me having other developers as my users and this cascading effect of an API breakage is quite painful. There are way to work around this issue and from my experience, being spec-first seems to offer the best balance of development and maintenance velocities.

I am quite a big fan of being spec-first, mainly for the following reasons:

  • The API spec is the single source of truth: This is what your users see and not your code. Make this the first class citizen like your users and the code should use this and not the other way round.
  • This keeps all the servers and clients in sync automatically with less breakage.
  • This keeps a nice separation between the business logic (the API handler code) and the infra thereby allowing developers to focus on what's important.

Another project of mine Bob can be seen as an example of spec-first design. All its tooling follow that idea and its CLI inspired Climate. A lot of Bob uses Clojure a language that I cherish and who's ideas make me think better in every other place too.

Codegen

Although codegen is one of the ways to be spec-first, I personally don't subscribe to the approach of generating code:

  • Introduces another build step adding complexity and more layers of debugging.
  • Makes the build more fragile in keeping up with tooling and language changes.
  • The generated code comes with its own opinions and is often harder to change/mould to our needs.
  • It is static code at the end, can't do much at runtime.

Prior art

  • restish: Inspired some of the ideas behind this. This is a project with different goals of being a fully automatic CLI for an OpenAPI REST API and is a bit hard to use as a lib.
  • navi: Server side spec-first library I wrote for Clojure which inspired the handler mechanism in Climate.

What is Climate?

Keeping all of the above into consideration and the fact that Go is one of the most widely used CLI languages, Climate was built to address the issues.

As the name implies, its your mate or sidekick when building CLIs in Go with the intentions of:

  • Keeping the REST API boilerplate away from you.
  • Keep the CLI code always in sync with the changes on the server.
  • Ability to bootstrap at runtime without any code changes.
  • Decoupling you from API machinery, allowing you to focus on just the handlers, business logic and things that may not the part of the server calls.
  • It does just enough to take the machinery out and not more like making the calls for you too; that's business logic.

How does it work?

Every OpenAPI3 Schema consists of one or more Operations having an OperationId. An Operation is a combination of the HTTP path, the method and some parameters.

Overall, Climate works by with these operations at its core. It:

  • Parses these from the YAML or JSON file.
  • Transforms each of these into a corresponding Cobra command by looking at hints from the server.
  • Transform each of the parameters into a Flag with the type.
  • Build a grouped Cobra command tree and attach it to the root command.

Servers influencing the CLI

Climate allows the server to influence the CLI behaviour by using OpenAPI's extensions. This is the secret of Climate's dynamism. Influenced by some of the ideas behind restish it uses the following extensions as of now:

  • x-cli-aliases: A list of strings which would be used as the alternate names for an operation.
  • x-cli-group: A string to allow grouping subcommands together. All operations in the same group would become subcommands in that group name.
  • x-cli-hidden: A boolean to hide the operation from the CLI menu. Same behaviour as a cobra command hide: it's present and expects a handler.
  • x-cli-ignored: A boolean to tell climate to omit the operation completely.
  • x-cli-name: A string to specify a different name. Applies to operations and request bodies as of now.

Type checking

As of now, only the primitive types are supported:

  • boolean
  • integer
  • number
  • string

More support for types like collections and composite types are planned. These are subject to limitations of what Cobra can do out of the box and what makes sense from a CLI perspective. There are sensible default behaviour like for request bodies its implicity string which handles most cases. These types are converted to Flags with the appropriate type checking functions and correctly coerced or the errors reported when invoked.

Checkout Wendy as a proper example of a project built with Climate.

Usage

This assumes an installation of Go 1.23+ is available.

go get github.com/lispyclouds/climate
Enter fullscreen mode Exit fullscreen mode

Given a spec:

openapi: "3.0.0"

info:
  title: My calculator
  version: "0.1.0"
  description: My awesome calc!

paths:
  "/add/{n1}/{n2}":
    get:
      operationId: AddGet
      summary: Adds two numbers
      x-cli-name: add-get
      x-cli-group: ops
      x-cli-aliases:
        - ag

      parameters:
        - name: n1
          required: true
          in: path
          description: The first number
          schema:
            type: integer
        - name: n2
          required: true
          in: path
          description: The second number
          schema:
            type: integer
    post:
      operationId: AddPost
      summary: Adds two numbers via POST
      x-cli-name: add-post
      x-cli-group: ops
      x-cli-aliases:
        - ap

      requestBody:
        description: The numbers map
        required: true
        x-cli-name: nmap
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NumbersMap"
  "/health":
    get:
      operationId: HealthCheck
      summary: Returns Ok if all is well
      x-cli-name: ping
  "/meta":
    get:
      operationId: GetMeta
      summary: Returns meta
      x-cli-ignored: true
  "/info":
    get:
      operationId: GetInfo
      summary: Returns info
      x-cli-group: info

      parameters:
        - name: p1
          required: true
          in: path
          description: The first param
          schema:
            type: integer
        - name: p2
          required: true
          in: query
          description: The second param
          schema:
            type: string
        - name: p3
          required: true
          in: header
          description: The third param
          schema:
            type: number
        - name: p4
          required: true
          in: cookie
          description: The fourth param
          schema:
            type: boolean

      requestBody:
        description: The requestBody
        required: true
        x-cli-name: req-body

components:
  schemas:
    NumbersMap:
      type: object
      required:
        - n1
        - n2
      properties:
        n1:
          type: integer
          description: The first number
        n2:
          type: integer
          description: The second number
Enter fullscreen mode Exit fullscreen mode

Load the spec:

model, err := climate.LoadFileV3("api.yaml") // or climate.LoadV3 with []byte
Enter fullscreen mode Exit fullscreen mode

Define a cobra root command:

rootCmd := &cobra.Command{
    Use:   "calc",
    Short: "My Calc",
    Long:  "My Calc powered by OpenAPI",
}
Enter fullscreen mode Exit fullscreen mode

Handlers and Handler Data:

Define one or more handler functions of the following signature:

func handler(opts *cobra.Command, args []string, data climate.HandlerData) error {
    slog.Info("called!", "data", fmt.Sprintf("%+v", data))
    err := doSomethingUseful(data)

    return err
}
Enter fullscreen mode Exit fullscreen mode

Handler Data

As of now, each handler is called with the cobra command it was invoked with, the args and an extra climate.HandlerData, more info here

This can be used to query the params from the command mostly in a type safe manner:

// to get all the int path params
for _, param := range data.PathParams {
    if param.Type == climate.Integer {
        value, _ := opts.Flags().GetInt(param.Name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Define the handlers for the necessary operations. These map to the operationId field of each operation:

handlers := map[string]Handler{
    "AddGet":      handler,
    "AddPost":     handler,
    "HealthCheck": handler,
    "GetInfo":     handler,
}
Enter fullscreen mode Exit fullscreen mode

Bootstrap the root command:

err := climate.BootstrapV3(rootCmd, *model, handlers)
Enter fullscreen mode Exit fullscreen mode

Continue adding more commands and/or execute:

// add more commands not from the spec

rootCmd.Execute()
Enter fullscreen mode Exit fullscreen mode

Sample output:

$ go run main.go --help
My Calc powered by OpenAPI

Usage:
  calc [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  info        Operations on info
  ops         Operations on ops
  ping        Returns Ok if all is well

Flags:
  -h, --help   help for calc

Use "calc [command] --help" for more information about a command.

$ go run main.go ops --help
Operations on ops

Usage:
  calc ops [command]

Available Commands:
  add-get     Adds two numbers
  add-post    Adds two numbers via POST

Flags:
  -h, --help   help for ops

Use "calc ops [command] --help" for more information about a command.

$ go run main.go ops add-get --help
Adds two numbers

Usage:
  calc ops add-get [flags]

Aliases:
  add-get, ag

Flags:
  -h, --help     help for add-get
      --n1 int   The first number
      --n2 int   The second number

$ go run main.go ops add-get --n1 1 --n2 foo
Error: invalid argument "foo" for "--n2" flag: strconv.ParseInt: parsing "foo": invalid syntax
Usage:
  calc ops add-get [flags]

Aliases:
  add-get, ag

Flags:
  -h, --help     help for add-get
      --n1 int   The first number
      --n2 int   The second number

$ go run main.go ops add-get --n1 1 --n2 2
2024/12/14 12:53:32 INFO called! data="{Method:get Path:/add/{n1}/{n2}}"
Enter fullscreen mode Exit fullscreen mode

Conclusion

Climate results from my experiences of being at the confluence of many teams developing various tools and proving the need to keep specifications at the centre of things. WIth this it hopefully inspires others to adopt such approaches and with static tooling like Go, its still possible to make flexible things which keep the users at the forefront.

Top comments (0)