Skip to content

Commit 2ad7ac1

Browse files
authored
Add support for urfave/cli (#1)
1 parent d0bbdf8 commit 2ad7ac1

File tree

9 files changed

+616
-144
lines changed

9 files changed

+616
-144
lines changed

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Read the detailed [blogpost](https://zuplo.com/blog/2025/02/02/generate-cli-from
88
Go is a fantastic language to build CLI tooling, specially the ones for interacting with an API server. `<your tool>ctl` anyone?
99
But if you're tired of building bespoke CLIs everytime or think that the swagger codegen isn't just good enough or don't quite subscribe to the idea of codegen in general (like me!), look no further.
1010

11-
What if you can influence the CLI behaviour from the server? This enables you to bootstrap your [cobra](https://cobra.dev/) CLI tooling from an [OpenAPI](https://swagger.io/specification/) spec. Checkout [Wendy](https://bob-cd.github.io/cli/#wendy) as an example of a full CLI project made using climate.
11+
What if you can influence the CLI behaviour from the server? This enables you to bootstrap your [cobra](https://cobra.dev/) or [urfave/cli](https://cli.urfave.org/) CLI tooling from an [OpenAPI](https://swagger.io/specification/) spec. Checkout [Wendy](https://bob-cd.github.io/cli/#wendy) as an example of a full CLI project made using climate.
1212

1313
## Getting started
1414

@@ -53,25 +53,41 @@ Load the spec:
5353
model, err := climate.LoadFileV3("api.yaml") // or climate.LoadV3 with []byte
5454
```
5555

56-
Define a cobra root command:
56+
Define a root command:
5757

5858
```go
59+
// Cobra
5960
rootCmd := &cobra.Command{
6061
Use: "calc",
6162
Short: "My Calc",
6263
Long: "My Calc powered by OpenAPI",
6364
}
65+
66+
// urfave/cli
67+
rootCmd := &cli.Command{
68+
Name: "calc",
69+
Description: "My Calc",
70+
}
6471
```
6572

6673
Define one or more handler functions of the following signature:
6774

6875
```go
76+
// Cobra
6977
func handler(opts *cobra.Command, args []string, data climate.HandlerData) error {
7078
slog.Info("called!", "data", fmt.Sprintf("%+v", data))
7179
err := doSomethingUseful(data)
7280

7381
return err
7482
}
83+
84+
// urfave/cli
85+
func handler(opts *cli.Command, args []string, data climate.HandlerData) error {
86+
slog.Info("called!", "data", fmt.Sprintf("%+v", data))
87+
err := doSomethingUseful(data)
88+
89+
return err
90+
}
7591
```
7692

7793
#### Handler Data
@@ -86,7 +102,11 @@ This can be used to query the params from the command mostly in a type safe mann
86102
// to get all the int path params
87103
for _, param := range data.PathParams {
88104
if param.Type == climate.Integer {
105+
// Cobra
89106
value, _ := opts.Flags().GetInt(param.Name)
107+
108+
// urfave/cli
109+
value, _ := opts.Int(param.Name)
90110
}
91111
}
92112
```
@@ -105,15 +125,23 @@ handlers := map[string]Handler{
105125
Bootstrap the root command:
106126

107127
```go
108-
err := climate.BootstrapV3(rootCmd, *model, handlers)
128+
// Cobra
129+
err := climate.BootstrapV3Cobra(rootCmd, *model, handlers)
130+
131+
// urfave/cli
132+
err := climate.BootstrapV3UrfaveCli(rootCmd, *model, handlers)
109133
```
110134

111135
Continue adding more commands and/or execute:
112136

113137
```go
114138
// add more commands not from the spec
115139

140+
// Cobra
116141
rootCmd.Execute()
142+
143+
// urfave/cli
144+
rootCmd.Run(context.TODO(), os.Args)
117145
```
118146

119147
Sample output:

lib.go renamed to cobra.go

Lines changed: 26 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -10,84 +10,16 @@ package climate
1010
import (
1111
"fmt"
1212
"log/slog"
13-
"os"
1413
"regexp"
1514
"strconv"
1615

1716
"github.com/pb33f/libopenapi"
1817
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
19-
"github.com/pb33f/libopenapi/orderedmap"
2018
"github.com/spf13/cobra"
21-
"go.yaml.in/yaml/v4"
2219
)
2320

24-
// Currently supported OpenAPI types
25-
type OpenAPIType string
26-
27-
const (
28-
String OpenAPIType = "string"
29-
Number OpenAPIType = "number"
30-
Integer OpenAPIType = "integer"
31-
Boolean OpenAPIType = "boolean"
32-
)
33-
34-
// Metadata for all parameters
35-
type ParamMeta struct {
36-
Name string
37-
Type OpenAPIType
38-
}
39-
40-
// Data passed into each handler
41-
type HandlerData struct {
42-
Method string // the HTTP method
43-
Path string // the path with the path params filled in
44-
PathParams []ParamMeta // List of path params
45-
QueryParams []ParamMeta // List of query params
46-
HeaderParams []ParamMeta // List of header params
47-
CookieParams []ParamMeta // List of cookie params
48-
RequestBodyParam *ParamMeta // The optional request body
49-
}
50-
51-
// The handler signature
5221
type Handler func(opts *cobra.Command, args []string, data HandlerData) error
53-
54-
type extensions struct {
55-
hidden bool
56-
aliases []string
57-
group string
58-
ignored bool
59-
name string
60-
}
61-
62-
func parseExtensions(exts *orderedmap.Map[string, *yaml.Node]) (*extensions, error) {
63-
ex := extensions{}
64-
aliases := []string{}
65-
66-
for ext, val := range exts.FromOldest() {
67-
var opts any
68-
if err := val.Decode(&opts); err != nil {
69-
return nil, err
70-
}
71-
72-
switch ext {
73-
case "x-cli-hidden":
74-
ex.hidden = opts.(bool)
75-
case "x-cli-aliases":
76-
for _, alias := range opts.([]any) {
77-
aliases = append(aliases, alias.(string))
78-
}
79-
ex.aliases = aliases
80-
case "x-cli-group":
81-
ex.group = opts.(string)
82-
case "x-cli-ignored":
83-
ex.ignored = opts.(bool)
84-
case "x-cli-name":
85-
ex.name = opts.(string)
86-
}
87-
}
88-
89-
return &ex, nil
90-
}
22+
type HandlerCobra Handler
9123

9224
func addParams(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) {
9325
var (
@@ -99,13 +31,7 @@ func addParams(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) {
9931
flags := cmd.Flags()
10032

10133
for _, param := range op.Parameters {
102-
schema := param.Schema.Schema()
103-
t := String
104-
if schema != nil {
105-
t = OpenAPIType(schema.Type[0])
106-
} else {
107-
slog.Warn("No type set for param, defaulting to string", "param", param.Name, "id", op.OperationId)
108-
}
34+
t := getParamType(param, op)
10935

11036
switch t {
11137
case String:
@@ -122,6 +48,7 @@ func addParams(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) {
12248
continue
12349
}
12450

51+
// TODO: Extract commom
12552
meta := ParamMeta{Name: param.Name, Type: t}
12653
switch param.In {
12754
case "path":
@@ -145,48 +72,32 @@ func addParams(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) {
14572
handlerData.CookieParams = cookieParams
14673
}
14774

148-
func addRequestBody(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) error {
149-
if body := op.RequestBody; body != nil {
150-
// TODO: hammock on ways to handle the req bodies. Maybe take in a stdin?
151-
bExts, err := parseExtensions(body.Extensions)
152-
if err != nil {
153-
return err
154-
}
155-
156-
paramName := "climate-data"
157-
if altName := bExts.name; altName != "" {
158-
paramName = altName
159-
} else {
160-
slog.Warn(
161-
fmt.Sprintf("No name set of requestBody, defaulting to %s", paramName),
162-
"id",
163-
op.OperationId,
164-
)
165-
}
75+
func addRequestBodyCobra(cmd *cobra.Command, op *v3.Operation, handlerData *HandlerData) error {
76+
name, desc, required, err := makeRequestBody(op, handlerData)
77+
if err != nil {
78+
return err
79+
}
16680

167-
// TODO: Handle all the different MIME types and schemas from body.Content
168-
// maybe assert the shape if mime is json and schema is an object
169-
// Treats all request body content as a string as of now
170-
handlerData.RequestBodyParam = &ParamMeta{Name: paramName, Type: String}
171-
cmd.Flags().String(paramName, "", body.Description)
81+
cmd.Flags().String(name, "", desc)
17282

173-
if req := body.Required; req != nil && *req {
174-
cmd.MarkFlagRequired(paramName)
175-
}
83+
if required {
84+
cmd.MarkFlagRequired(name)
17685
}
17786

17887
return nil
17988
}
18089

181-
func interpolatePath(cmd *cobra.Command, h *HandlerData) error {
90+
func interpolatePathCobra(cmd *cobra.Command, h *HandlerData) error {
91+
// TODO: Extract commom
92+
flags := cmd.Flags()
93+
18294
for _, param := range h.PathParams {
18395
pattern, err := regexp.Compile(fmt.Sprintf("({%s})+", param.Name))
18496
if err != nil {
18597
return err
18698
}
18799

188100
var value string
189-
flags := cmd.Flags()
190101

191102
switch param.Type {
192103
case String:
@@ -208,33 +119,8 @@ func interpolatePath(cmd *cobra.Command, h *HandlerData) error {
208119
return nil
209120
}
210121

211-
// Loads and verifies an OpenAPI spec frpm an array of bytes
212-
func LoadV3(data []byte) (*libopenapi.DocumentModel[v3.Document], error) {
213-
document, err := libopenapi.NewDocument(data)
214-
if err != nil {
215-
return nil, err
216-
}
217-
218-
model, err := document.BuildV3Model()
219-
if err != nil {
220-
return nil, fmt.Errorf("Cannot create v3 model: %e", err)
221-
}
222-
223-
return model, nil
224-
}
225-
226-
// Loads and verifies an OpenAPI spec from a file path
227-
func LoadFileV3(path string) (*libopenapi.DocumentModel[v3.Document], error) {
228-
data, err := os.ReadFile(path)
229-
if err != nil {
230-
return nil, err
231-
}
232-
233-
return LoadV3(data)
234-
}
235-
236122
// Bootstraps a cobra.Command with the loaded model and a handler map
237-
func BootstrapV3(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Document], handlers map[string]Handler) error {
123+
func BootstrapV3Cobra(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Document], handlers map[string]Handler) error {
238124
cmdGroups := make(map[string][]cobra.Command)
239125

240126
for path, item := range model.Model.Paths.PathItems.FromOldest() {
@@ -251,7 +137,7 @@ func BootstrapV3(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Docum
251137

252138
hData := HandlerData{Method: method, Path: path}
253139
addParams(&cmd, op, &hData)
254-
if err := addRequestBody(&cmd, op, &hData); err != nil {
140+
if err := addRequestBodyCobra(&cmd, op, &hData); err != nil {
255141
return err
256142
}
257143

@@ -268,7 +154,7 @@ func BootstrapV3(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Docum
268154
cmd.Short = op.Summary
269155
}
270156
cmd.RunE = func(opts *cobra.Command, args []string) error {
271-
if err := interpolatePath(&cmd, &hData); err != nil {
157+
if err := interpolatePathCobra(&cmd, &hData); err != nil {
272158
return err
273159
}
274160

@@ -307,3 +193,11 @@ func BootstrapV3(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Docum
307193

308194
return nil
309195
}
196+
197+
// Bootstraps a cobra.Command with the loaded model and a handler map
198+
//
199+
// Deprecated: Will be kept for backwards compatibility.
200+
// Use BootstrapV3Cobra instead.
201+
func BootstrapV3(rootCmd *cobra.Command, model libopenapi.DocumentModel[v3.Document], handlers map[string]Handler) error {
202+
return BootstrapV3Cobra(rootCmd, model, handlers)
203+
}

lib_test.go renamed to cobra_test.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ import (
77
"github.com/stretchr/testify/assert"
88
)
99

10-
func TestLoadFileV3(t *testing.T) {
11-
_, err := LoadFileV3("api.yaml")
12-
assert.NoError(t, err)
13-
}
14-
1510
func TestInterpolatePath(t *testing.T) {
1611
cmd := cobra.Command{}
1712
hData := HandlerData{
@@ -30,7 +25,7 @@ func TestInterpolatePath(t *testing.T) {
3025
cmd.Flags().Float64("baz", 420.69, "baz usage")
3126
cmd.Flags().Bool("quxx", false, "quxx usage")
3227

33-
err := interpolatePath(&cmd, &hData)
28+
err := interpolatePathCobra(&cmd, &hData)
3429
assert.NoError(t, err)
3530

3631
assert.Equal(t, hData.Path, "/path/yes/to/420/with/420.69/and/false/together")
@@ -72,7 +67,7 @@ func assertCmdTree(t *testing.T, cmd *cobra.Command, assertConf map[string]map[s
7267
}
7368
}
7469

75-
func TestBootstrapV3(t *testing.T) {
70+
func TestBootstrapV3Cobra(t *testing.T) {
7671
model, err := LoadFileV3("api.yaml")
7772
assert.NoError(t, err)
7873

@@ -97,7 +92,7 @@ func TestBootstrapV3(t *testing.T) {
9792
"GetInfo": handler,
9893
}
9994

100-
err = BootstrapV3(rootCmd, *model, handlers)
95+
err = BootstrapV3Cobra(rootCmd, *model, handlers)
10196
assert.NoError(t, err)
10297

10398
var noAlias []string
@@ -168,5 +163,6 @@ func TestBootstrapV3(t *testing.T) {
168163
"--req-body",
169164
"the string body",
170165
})
171-
rootCmd.Execute()
166+
167+
assert.NoError(t, rootCmd.Execute())
172168
}

0 commit comments

Comments
 (0)