Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for positional arguments in middleware #10

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -11,7 +11,13 @@ You can think of routing in a CLI the same way as routing in an HTTP server:
- Flags are URL query parameters
- STDIN/STDOUT are the request/response bodies

CLIR is a Command Line Interface Router.
CLIR is a Command Line Interface Router that provides:
- Intuitive routing with support for subcommands
- Middleware for cross-cutting concerns
- Built-in support for flags via the standard `flag` package
- Built-in support for positional arguments with multiple data types (string, int, bool, float64)
- A clean, composable API inspired by HTTP routers
- No dependencies

⚠️ **This library is currently at the proof-of-concept level**. Feel free to play with it, but probably don't use anywhere serious yet. ⚠️

@@ -66,6 +72,24 @@ func main() {
// Add a named route which calls get.
r.Route("get", get(c))

// Add a greet command with positional arguments
r.Branch("greet", func(r *clir.Router) {
var name *string
var count *int

r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "World", "name to greet")
count = as.Int("count", 1, "number of times to greet")
}))

r.RouteFunc("", func(ctx clir.Context) error {
for range *count {
ctx.Printfln("Hello, %s!", *name)
}
return nil
})
})

// Branch with subcommands
r.Branch("post", func(r *clir.Router) {
r.Use(ping(c, v))
18 changes: 18 additions & 0 deletions internal/examples/app/main.go
Original file line number Diff line number Diff line change
@@ -38,6 +38,24 @@ func main() {
// Add a named route which calls get.
r.Route("get", get(c))

// Add a greet command with positional arguments
r.Branch("greet", func(r *clir.Router) {
var name *string
var count *int

r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "World", "name to greet")
count = as.Int("count", 1, "number of times to greet")
}))

r.RouteFunc("", func(ctx clir.Context) error {
for range *count {
ctx.Printfln("Hello, %s!", *name)
}
return nil
})
})

// Branch with subcommands
r.Branch("post", func(r *clir.Router) {
r.Use(ping(c, v))
188 changes: 188 additions & 0 deletions middleware/middleware.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ package middleware
import (
"errors"
"flag"
"io"
"strconv"

"maragu.dev/clir"
)
@@ -27,3 +29,189 @@ func Flags(cb func(fs *flag.FlagSet)) clir.Middleware {
})
}
}

// ArgSet is like [flag.FlagSet] but for positional arguments.
// The order of calls is significant.
type ArgSet struct {
Usage func()
args []string
formal []*flag.Flag
w io.Writer
}

// String defines a string positional argument.
func (a *ArgSet) String(name string, value string, usage string) *string {
p := new(string)
a.StringVar(p, name, value, usage)
return p
}

// StringVar defines a string positional argument with a pointer.
func (a *ArgSet) StringVar(p *string, name string, value string, usage string) {
a.Var(newStringValue(value, p), name, usage)
}

// Int defines an int positional argument.
func (a *ArgSet) Int(name string, value int, usage string) *int {
p := new(int)
a.IntVar(p, name, value, usage)
return p
}

// IntVar defines an int positional argument with a pointer.
func (a *ArgSet) IntVar(p *int, name string, value int, usage string) {
a.Var(newIntValue(value, p), name, usage)
}

// Bool defines a bool positional argument.
func (a *ArgSet) Bool(name string, value bool, usage string) *bool {
p := new(bool)
a.BoolVar(p, name, value, usage)
return p
}

// BoolVar defines a bool positional argument with a pointer.
func (a *ArgSet) BoolVar(p *bool, name string, value bool, usage string) {
a.Var(newBoolValue(value, p), name, usage)
}

// Float64 defines a float64 positional argument.
func (a *ArgSet) Float64(name string, value float64, usage string) *float64 {
p := new(float64)
a.Float64Var(p, name, value, usage)
return p
}

// Float64Var defines a float64 positional argument with a pointer.
func (a *ArgSet) Float64Var(p *float64, name string, value float64, usage string) {
a.Var(newFloat64Value(value, p), name, usage)
}

func (a *ArgSet) Var(value flag.Value, name string, usage string) {
f := &flag.Flag{Name: name, Usage: usage, Value: value, DefValue: value.String()}
a.formal = append(a.formal, f)
}

func (a *ArgSet) Parse(args []string) error {
a.args = args

// Process all positional arguments based on formal definitions
for i, f := range a.formal {
if i < len(args) {
// Set the value from the args
if err := f.Value.Set(args[i]); err != nil {
return err
}
}
// else use default value which is already set
}

return nil
}

func (a *ArgSet) Args() []string {
// Return any remaining args after the positional ones we consumed
if len(a.formal) < len(a.args) {
return a.args[len(a.formal):]
}
return []string{}
}

func (a *ArgSet) SetOutput(w io.Writer) {
a.w = w
}

// Args middleware allows you to set positional arguments on a route.
func Args(cb func(as *ArgSet)) clir.Middleware {
as := &ArgSet{}
cb(as)

return func(next clir.Runner) clir.Runner {
return clir.RunnerFunc(func(ctx clir.Context) error {
as.SetOutput(ctx.Err)
if err := as.Parse(ctx.Args); err != nil {
return err
}
ctx.Args = as.Args()
return next.Run(ctx)
})
}
}

// Value implementations for different types

type stringValue string

func newStringValue(val string, p *string) *stringValue {
*p = val
return (*stringValue)(p)
}

func (s *stringValue) Set(val string) error {
*s = stringValue(val)
return nil
}

func (s *stringValue) Get() any { return string(*s) }

func (s *stringValue) String() string { return string(*s) }

type intValue int

func newIntValue(val int, p *int) *intValue {
*p = val
return (*intValue)(p)
}

func (i *intValue) Set(val string) error {
v, err := strconv.ParseInt(val, 0, strconv.IntSize)
if err != nil {
return err
}
*i = intValue(v)
return nil
}

func (i *intValue) Get() any { return int(*i) }

func (i *intValue) String() string { return strconv.Itoa(int(*i)) }

type boolValue bool

func newBoolValue(val bool, p *bool) *boolValue {
*p = val
return (*boolValue)(p)
}

func (b *boolValue) Set(val string) error {
v, err := strconv.ParseBool(val)
if err != nil {
return err
}
*b = boolValue(v)
return nil
}

func (b *boolValue) Get() any { return bool(*b) }

func (b *boolValue) String() string { return strconv.FormatBool(bool(*b)) }

type float64Value float64

func newFloat64Value(val float64, p *float64) *float64Value {
*p = val
return (*float64Value)(p)
}

func (f *float64Value) Set(val string) error {
v, err := strconv.ParseFloat(val, 64)
if err != nil {
return err
}
*f = float64Value(v)
return nil
}

func (f *float64Value) Get() any { return float64(*f) }

func (f *float64Value) String() string { return strconv.FormatFloat(float64(*f), 'g', -1, 64) }
188 changes: 188 additions & 0 deletions middleware/middleware_test.go
Original file line number Diff line number Diff line change
@@ -127,3 +127,191 @@ func ExampleFlags() {
})
// Output: Hello!
}

func ExampleArgs() {
r := clir.NewRouter()

var name *string
r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "world", "name to greet")
}))

r.RouteFunc("", func(ctx clir.Context) error {
ctx.Printfln("Hello, %s!", *name)
return nil
})

_ = r.Run(clir.Context{
Args: []string{"Funky Person"},
Out: os.Stdout,
})
// Output: Hello, Funky Person!
}

func TestArgs(t *testing.T) {
t.Run("can set args on a root route", func(t *testing.T) {
r := clir.NewRouter()

var name *string
r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "default", "set a name")
}))

var called bool
r.RouteFunc("", func(ctx clir.Context) error {
called = true
return nil
})

err := r.Run(clir.Context{
Args: []string{"fancypants"},
})
is.NotError(t, err)
is.True(t, called)
is.NotNil(t, name)
is.Equal(t, "fancypants", *name)
})

t.Run("can set multiple args in order", func(t *testing.T) {
r := clir.NewRouter()

var name *string
var age *string
r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "default", "set a name")
age = as.String("age", "0", "set an age")
}))

var called bool
r.RouteFunc("", func(ctx clir.Context) error {
called = true
return nil
})

err := r.Run(clir.Context{
Args: []string{"alice", "30"},
})
is.NotError(t, err)
is.True(t, called)
is.NotNil(t, name)
is.Equal(t, "alice", *name)
is.NotNil(t, age)
is.Equal(t, "30", *age)
})

t.Run("falls back to default if arg is missing", func(t *testing.T) {
r := clir.NewRouter()

var name *string
var age *string
r.Use(middleware.Args(func(as *middleware.ArgSet) {
name = as.String("name", "default", "set a name")
age = as.String("age", "0", "set an age")
}))

var called bool
r.RouteFunc("", func(ctx clir.Context) error {
called = true
return nil
})

err := r.Run(clir.Context{
Args: []string{"alice"},
})
is.NotError(t, err)
is.True(t, called)
is.NotNil(t, name)
is.Equal(t, "alice", *name)
is.NotNil(t, age)
is.Equal(t, "0", *age) // Default value
})

t.Run("supports all positional argument types", func(t *testing.T) {
// Setup a test to verify the different positional argument types
var stringVal *string
var intVal *int
var boolVal *bool
var floatVal *float64

middleware := middleware.Args(func(as *middleware.ArgSet) {
stringVal = as.String("string", "default", "string positional arg")
intVal = as.Int("int", 0, "int positional arg")
boolVal = as.Bool("bool", false, "bool positional arg")
floatVal = as.Float64("float", 0.0, "float positional arg")
})

runner := middleware(clir.RunnerFunc(func(ctx clir.Context) error {
return nil
}))

// Run with arguments matching our positional arguments
err := runner.Run(clir.Context{
Args: []string{"hello", "42", "true", "3.14"},
})

is.NotError(t, err)
is.NotNil(t, stringVal)
is.Equal(t, "hello", *stringVal)
is.NotNil(t, intVal)
is.Equal(t, 42, *intVal)
is.NotNil(t, boolVal)
is.Equal(t, true, *boolVal)
is.NotNil(t, floatVal)
is.Equal(t, 3.14, *floatVal)
})

t.Run("handles remaining args correctly", func(t *testing.T) {
// Setup a test to verify our Args implementation passes the remaining arguments correctly
var argValues []string

middleware := middleware.Args(func(as *middleware.ArgSet) {
as.String("first", "default", "first positional arg")
})

runner := middleware(clir.RunnerFunc(func(ctx clir.Context) error {
argValues = ctx.Args
return nil
}))

// Run with three arguments, first should be consumed by the positional arg, remaining should be passed through
err := runner.Run(clir.Context{
Args: []string{"value1", "extra1", "extra2"},
})

is.NotError(t, err)
is.Equal(t, 2, len(argValues))
is.Equal(t, "extra1", argValues[0])
is.Equal(t, "extra2", argValues[1])
})

t.Run("works with subroutes", func(t *testing.T) {
r := clir.NewRouter()

var outerCalled, innerCalled bool
r.RouteFunc("", func(ctx clir.Context) error {
outerCalled = true
return nil
})

var command *string
r.Branch("run", func(r *clir.Router) {
r.Use(middleware.Args(func(as *middleware.ArgSet) {
command = as.String("command", "", "command to run")
}))

r.RouteFunc("", func(ctx clir.Context) error {
innerCalled = true
return nil
})
})

err := r.Run(clir.Context{
Args: []string{"run", "job"},
})
is.NotError(t, err)
is.True(t, !outerCalled)
is.True(t, innerCalled)
is.NotNil(t, command)
is.Equal(t, "job", *command)
})
}