Skip to content

Commit

Permalink
Merge pull request #291 from danielgtaylor/move-cli
Browse files Browse the repository at this point in the history
fix: move CLI to its own package, deprecate huma.NewCLI
  • Loading branch information
danielgtaylor authored Mar 11, 2024
2 parents ba48e2b + 90125f7 commit 13acb69
Show file tree
Hide file tree
Showing 22 changed files with 623 additions and 250 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import (

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/danielgtaylor/huma/v2/humacli"
"github.com/go-chi/chi/v5"
)

Expand All @@ -103,7 +104,7 @@ type GreetingOutput struct {

func main() {
// Create a CLI app which takes a port option.
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
// Create a new router & API
router := chi.NewMux()
api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
Expand Down Expand Up @@ -159,7 +160,7 @@ Official Go package documentation can always be found at https://pkg.go.dev/gith
- [APIs in Go with Huma 2.0](https://dgt.hashnode.dev/apis-in-go-with-huma-20)
- [Reducing Go Dependencies: A case study of dependency reduction in Huma](https://dgt.hashnode.dev/reducing-go-dependencies)
- [Golang News & Libs & Jobs shared on Twitter/X](https://twitter.com/golangch/status/1752175499701264532)
- Featured in [Go Weekly #495](https://golangweekly.com/issues/495)
- Featured in Go Weekly [#495](https://golangweekly.com/issues/495) & [#498](https://golangweekly.com/issues/498)
- [Bump.sh Deploying Docs from Huma](https://docs.bump.sh/guides/bump-sh-tutorials/huma/)
- Mentioned in [Composable HTTP Handlers Using Generics](https://www.willem.dev/articles/generic-http-handlers/)

Expand Down
243 changes: 19 additions & 224 deletions cli.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,28 @@
//go:build !humanewclipackage
// +build !humanewclipackage

package huma

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"reflect"
"strconv"
"strings"
"syscall"
"time"
"log"

"github.com/danielgtaylor/casing"
"github.com/danielgtaylor/huma/v2/humacli"
"github.com/spf13/cobra"
)

// CLI is an optional command-line interface for a Huma service. It is provided
// as a convenience for quickly building a service with configuration from
// the environment and/or command-line options, all tied to a simple type-safe
// Go struct.
type CLI interface {
// Run the CLI. This will parse the command-line arguments and environment
// variables and then run the appropriate command. If no command is given,
// the default command will call the `OnStart` function to start a server.
Run()

// Root returns the root Cobra command. This can be used to add additional
// commands or flags. Customize it however you like.
Root() *cobra.Command
}
//
// Deprecated: use `humacli.CLI` instead.
type CLI = humacli.CLI

// Hooks is an interface for setting up callbacks for the CLI. It is used to
// start and stop the service.
type Hooks interface {
// OnStart sets a function to call when the service should be started. This
// is called by the default command if no command is given. The callback
// should take whatever steps are necessary to start the server, such as
// `httpServer.ListenAndServer(...)`.
OnStart(func())

// OnStop sets a function to call when the service should be stopped. This
// is called by the default command if no command is given. The callback
// should take whatever steps are necessary to stop the server, such as
// `httpServer.Shutdown(...)`.
OnStop(func())
}

type contextKey string

var optionsKey contextKey = "huma/cli/options"

var durationType = reflect.TypeOf((*time.Duration)(nil)).Elem()
//
// Deprecated: use `humacli.Hooks` instead.
type Hooks = humacli.Hooks

// WithOptions is a helper for custom commands that need to access the options.
//
Expand All @@ -61,156 +32,11 @@ var durationType = reflect.TypeOf((*time.Duration)(nil)).Elem()
// fmt.Println("Hello " + opts.Name)
// }),
// })
//
// Deprecated: use `humacli.WithOptions` instead.
func WithOptions[Options any](f func(cmd *cobra.Command, args []string, options *Options)) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, s []string) {
var options *Options = cmd.Context().Value(optionsKey).(*Options)
f(cmd, s, options)
}
}

type option struct {
name string
typ reflect.Type
path []int
}

type cli[Options any] struct {
root *cobra.Command
optInfo []option
onParsed func(Hooks, *Options)
start func()
stop func()
}

func (c *cli[Options]) Run() {
var o Options

existing := c.root.PersistentPreRun
c.root.PersistentPreRun = func(cmd *cobra.Command, args []string) {
// Load config from args/env/files
v := reflect.ValueOf(&o).Elem()
flags := c.root.PersistentFlags()
for _, opt := range c.optInfo {
f := v
for _, i := range opt.path {
f = f.Field(i)
}
switch opt.typ.Kind() {
case reflect.String:
s, _ := flags.GetString(opt.name)
f.Set(reflect.ValueOf(s))
case reflect.Int, reflect.Int64:
var i any
if opt.typ == durationType {
i, _ = flags.GetDuration(opt.name)
} else {
i, _ = flags.GetInt64(opt.name)
}
f.Set(reflect.ValueOf(i).Convert(opt.typ))
case reflect.Bool:
b, _ := flags.GetBool(opt.name)
f.Set(reflect.ValueOf(b))
}
}

// Run the parsed callback.
c.onParsed(c, &o)

if existing != nil {
existing(cmd, args)
}

// Set options in context, so custom commands can access it.
cmd.SetContext(context.WithValue(cmd.Context(), optionsKey, &o))
}

// Run the command!
c.root.Execute()
}

func (c *cli[O]) Root() *cobra.Command {
return c.root
}

func (c *cli[O]) OnStart(fn func()) {
c.start = fn
}

func (c *cli[O]) OnStop(fn func()) {
c.stop = fn
}

func (c *cli[O]) setupOptions(t reflect.Type, path []int) {
var err error
flags := c.root.PersistentFlags()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)

if !field.IsExported() {
// This isn't a public field, so we cannot use reflect.Value.Set with
// it. This is usually a struct field with a lowercase name.
fmt.Println("warning: ignoring unexported options field", field.Name)
continue
}

currentPath := append([]int{}, path...)
currentPath = append(currentPath, i)

if field.Anonymous {
// Embedded struct. This enables composition from e.g. company defaults.
c.setupOptions(deref(field.Type), currentPath)
continue
}

name := field.Tag.Get("name")
if name == "" {
name = casing.Kebab(field.Name)
}

envName := "SERVICE_" + casing.Snake(name, strings.ToUpper)
defaultValue := field.Tag.Get("default")
if v := os.Getenv(envName); v != "" {
// Env vars will override the default value, which is used to document
// what the value is if no options are passed.
defaultValue = v
}

c.optInfo = append(c.optInfo, option{name, field.Type, currentPath})
switch field.Type.Kind() {
case reflect.String:
flags.StringP(name, field.Tag.Get("short"), defaultValue, field.Tag.Get("doc"))
case reflect.Int, reflect.Int64:
var def int64
if defaultValue != "" {
if field.Type == durationType {
var t time.Duration
t, err = time.ParseDuration(defaultValue)
def = int64(t)
} else {
def, err = strconv.ParseInt(defaultValue, 10, 64)
}
if err != nil {
panic(err)
}
}
if field.Type == durationType {
flags.DurationP(name, field.Tag.Get("short"), time.Duration(def), field.Tag.Get("doc"))
} else {
flags.Int64P(name, field.Tag.Get("short"), def, field.Tag.Get("doc"))
}
case reflect.Bool:
var def bool
if defaultValue != "" {
def, err = strconv.ParseBool(defaultValue)
if err != nil {
panic(err)
}
}
flags.BoolP(name, field.Tag.Get("short"), def, field.Tag.Get("doc"))
default:
panic("Unsupported option type: " + field.Type.Kind().String())
}
}
log.Println("huma.WithOptions is deprecated, use humacli.WithOptions instead")
return humacli.WithOptions(f)
}

// NewCLI creates a new CLI. The `onParsed` callback is called after the command
Expand Down Expand Up @@ -252,40 +78,9 @@ func (c *cli[O]) setupOptions(t reflect.Type, path []int) {
//
// // Run the thing!
// cli.Run()
//
// Deprecated: use `humacli.New` instead.
func NewCLI[O any](onParsed func(Hooks, *O)) CLI {
c := &cli[O]{
root: &cobra.Command{
Use: filepath.Base(os.Args[0]),
},
onParsed: onParsed,
}

var o O
c.setupOptions(reflect.TypeOf(o), []int{})

c.root.Run = func(cmd *cobra.Command, args []string) {
done := make(chan struct{}, 1)
if c.start != nil {
go func() {
c.start()
done <- struct{}{}
}()
}

// Handle graceful shutdown.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

select {
case <-done:
// Server is done, just exit.
case <-quit:
if c.stop != nil {
fmt.Println("Gracefully shutting down the server...")
c.stop()
}
}

}
return c
log.Println("huma.NewCLI is deprecated, use humacli.New instead")
return humacli.New(onParsed)
}
14 changes: 7 additions & 7 deletions docs/docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Options struct {

func main() {
// Then, create the CLI.
cli := huma.NewCLI(func(hooks huma.Hooks, opts *Options) {
cli := humacli.New(func(hooks humacli.Hooks, opts *Options) {
fmt.Printf("I was run with debug:%v host:%v port%v\n",
opts.Debug, opts.Host, opts.Port)
})
Expand All @@ -45,7 +45,7 @@ I was run with debug:true host:localhost port:8000
To do useful work, you will want to register a handler for the default start command and optionally a way to gracefully shutdown the server:

```go title="main.go"
cli := huma.NewCLI(func(hooks huma.Hooks, opts *Options) {
cli := humacli.New(func(hooks humacli.Hooks, opts *Options) {
// Set up the router and API
// ...

Expand Down Expand Up @@ -162,7 +162,7 @@ If you want to access your custom options struct with custom commands, use the [
You can set the app name and version to be used in the help output and version command. By default, the app name is the name of the binary and the version is unset. You can set them using the root [`cobra.Command`](https://pkg.go.dev/github.com/spf13/cobra#Command)'s `Use` and `Version` fields:

```go title="main.go"
// cli := huma.NewCLI(...)
// cli := humacli.New(...)

cmd := cli.Root()
cmd.Use = "appname"
Expand Down Expand Up @@ -194,10 +194,10 @@ appname version 1.0.1
- How-To
- [Graceful Shutdown](../how-to/graceful-shutdown.md) on service stop
- Reference
- [`huma.CLI`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#CLI) the CLI instance
- [`huma.NewCLI`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#NewCLI) creates a new CLI instance
- [`huma.Hooks`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#Hooks) for startup / shutdown
- [`huma.WithOptions`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#WithOptions) wraps a command with options parsing
- [`humacli.CLI`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/humacli#CLI) the CLI instance
- [`humacli.New`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/humacli#New) creates a new CLI instance
- [`humacli.Hooks`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/humacli#Hooks) for startup / shutdown
- [`humacli.WithOptions`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/humacli#WithOptions) wraps a command with options parsing
- [`huma.API`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2#API) the API instance
- External Links
- [Cobra](https://cobra.dev/) CLI library
3 changes: 2 additions & 1 deletion docs/docs/how-to/custom-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import (

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/danielgtaylor/huma/v2/humacli"
"github.com/go-chi/chi/v5"
)

Expand Down Expand Up @@ -100,7 +101,7 @@ var _ huma.ResolverWithPath = (*IntNot3)(nil)
func main() {
// Create the CLI, passing a function to be called with your custom options
// after they have been parsed.
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
router := chi.NewMux()

api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/how-to/graceful-shutdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/danielgtaylor/huma/v2/humacli"
"github.com/go-chi/chi/v5"
)

Expand All @@ -45,7 +46,7 @@ type GreetingOutput struct {

func main() {
// Create a CLI app which takes a port option.
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
// Create a new router & API
router := chi.NewMux()
api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/how-to/image-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/danielgtaylor/huma/v2/humacli"
"github.com/go-chi/chi/v5"
)

Expand All @@ -36,7 +37,7 @@ type ImageOutput struct {

func main() {
// Create a CLI app which takes a port option.
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
cli := humacli.New(func(hooks humacli.Hooks, options *Options) {
// Create a new router & API
router := chi.NewMux()
api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))
Expand Down
Loading

0 comments on commit 13acb69

Please sign in to comment.