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
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
Load the spec:
model, err := climate.LoadFileV3("api.yaml") // or climate.LoadV3 with []byte
Define a cobra root command:
rootCmd := &cobra.Command{
Use: "calc",
Short: "My Calc",
Long: "My Calc powered by OpenAPI",
}
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
}
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)
}
}
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,
}
Bootstrap the root command:
err := climate.BootstrapV3(rootCmd, *model, handlers)
Continue adding more commands and/or execute:
// add more commands not from the spec
rootCmd.Execute()
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}}"
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)