diff --git a/commands/cli/parse.go b/commands/cli/parse.go new file mode 100644 index 00000000000..08df7955bad --- /dev/null +++ b/commands/cli/parse.go @@ -0,0 +1,79 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/jbenet/go-ipfs/commands" +) + +// Parse parses the input commandline string (cmd, flags, and args). +// returns the corresponding command Request object. +func Parse(input []string, root *commands.Command) (commands.Request, error) { + path, input := parsePath(input, root) + opts, args, err := parseOptions(input) + if err != nil { + return nil, err + } + + return commands.NewRequest(path, opts, args, nil), nil +} + +// parsePath gets the command path from the command line input +func parsePath(input []string, root *commands.Command) ([]string, []string) { + cmd := root + i := 0 + + for _, blob := range input { + if strings.HasPrefix(blob, "-") { + break + } + + cmd := cmd.Subcommand(blob) + if cmd == nil { + break + } + + i++ + } + + return input[:i], input[i:] +} + +// parseOptions parses the raw string values of the given options +// returns the parsed options as strings, along with the CLI args +func parseOptions(input []string) (map[string]interface{}, []string, error) { + opts := make(map[string]interface{}) + args := []string{} + + for i := 0; i < len(input); i++ { + blob := input[i] + + if strings.HasPrefix(blob, "-") { + name := blob[1:] + value := "" + + // support single and double dash + if strings.HasPrefix(name, "-") { + name = name[1:] + } + + if strings.Contains(name, "=") { + split := strings.SplitN(name, "=", 2) + name = split[0] + value = split[1] + } + + if _, ok := opts[name]; ok { + return nil, nil, fmt.Errorf("Duplicate values for option '%s'", name) + } + + opts[name] = value + + } else { + args = append(args, blob) + } + } + + return opts, args, nil +} diff --git a/commands/cli/parse_test.go b/commands/cli/parse_test.go new file mode 100644 index 00000000000..1c5e1ff413d --- /dev/null +++ b/commands/cli/parse_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + //"fmt" + "testing" + + "github.com/jbenet/go-ipfs/commands" +) + +func TestOptionParsing(t *testing.T) { + cmd := &commands.Command{ + Options: []commands.Option{ + commands.Option{Names: []string{"b"}, Type: commands.String}, + }, + Subcommands: map[string]*commands.Command{ + "test": &commands.Command{}, + }, + } + + opts, input, err := parseOptions([]string{"--beep", "-boop=lol", "test2", "-c", "beep", "--foo=5"}) + /*for k, v := range opts { + fmt.Printf("%s: %s\n", k, v) + } + fmt.Printf("%s\n", input)*/ + if err != nil { + t.Error("Should have passed") + } + if len(opts) != 4 || opts["beep"] != "" || opts["boop"] != "lol" || opts["c"] != "" || opts["foo"] != "5" { + t.Error("Returned options were defferent than expected: %v", opts) + } + if len(input) != 2 || input[0] != "test2" || input[1] != "beep" { + t.Error("Returned input was different than expected: %v", input) + } + + _, _, err = parseOptions([]string{"-beep=1", "-boop=2", "-beep=3"}) + if err == nil { + t.Error("Should have failed (duplicate option name)") + } + + path, args := parsePath([]string{"test", "beep", "boop"}, cmd) + if len(path) != 1 || path[0] != "test" { + t.Error("Returned path was defferent than expected: %v", path) + } + if len(args) != 2 || args[0] != "beep" || args[1] != "boop" { + t.Error("Returned args were different than expected: %v", args) + } +} diff --git a/commands/command.go b/commands/command.go new file mode 100644 index 00000000000..b01a579e74b --- /dev/null +++ b/commands/command.go @@ -0,0 +1,122 @@ +package commands + +import ( + "errors" + "fmt" + "strings" + + u "github.com/jbenet/go-ipfs/util" +) + +var log = u.Logger("command") + +// Function is the type of function that Commands use. +// It reads from the Request, and writes results to the Response. +type Function func(Request, Response) + +// Command is a runnable command, with input arguments and options (flags). +// It can also have Subcommands, to group units of work into sets. +type Command struct { + Help string + Options []Option + Run Function + Subcommands map[string]*Command +} + +// ErrNotCallable signals a command that cannot be called. +var ErrNotCallable = errors.New("This command can't be called directly. Try one of its subcommands.") + +// Call invokes the command for the given Request +func (c *Command) Call(req Request) Response { + res := NewResponse(req) + + cmds, err := c.Resolve(req.Path()) + if err != nil { + res.SetError(err, ErrClient) + return res + } + cmd := cmds[len(cmds)-1] + + if cmd.Run == nil { + res.SetError(ErrNotCallable, ErrClient) + return res + } + + options, err := c.GetOptions(req.Path()) + if err != nil { + res.SetError(err, ErrClient) + return res + } + + err = req.ConvertOptions(options) + if err != nil { + res.SetError(err, ErrClient) + return res + } + + cmd.Run(req, res) + + return res +} + +// Resolve gets the subcommands at the given path +func (c *Command) Resolve(path []string) ([]*Command, error) { + cmds := make([]*Command, len(path)+1) + cmds[0] = c + + cmd := c + for i, name := range path { + cmd = cmd.Subcommand(name) + + if cmd == nil { + pathS := strings.Join(path[0:i], "/") + return nil, fmt.Errorf("Undefined command: '%s'", pathS) + } + + cmds[i+1] = cmd + } + + return cmds, nil +} + +// Get resolves and returns the Command addressed by path +func (c *Command) Get(path []string) (*Command, error) { + cmds, err := c.Resolve(path) + if err != nil { + return nil, err + } + return cmds[len(cmds)-1], nil +} + +// GetOptions gets the options in the given path of commands +func (c *Command) GetOptions(path []string) (map[string]Option, error) { + options := make([]Option, len(c.Options)) + + cmds, err := c.Resolve(path) + if err != nil { + return nil, err + } + cmds = append(cmds, globalCommand) + + for _, cmd := range cmds { + options = append(options, cmd.Options...) + } + + optionsMap := make(map[string]Option) + for _, opt := range options { + for _, name := range opt.Names { + if _, found := optionsMap[name]; found { + return nil, fmt.Errorf("Option name '%s' used multiple times", name) + } + + optionsMap[name] = opt + } + } + + return optionsMap, nil +} + +// Subcommand returns the subcommand with the given id +func (c *Command) Subcommand(id string) *Command { + return c.Subcommands[id] +} diff --git a/commands/command_test.go b/commands/command_test.go new file mode 100644 index 00000000000..63a3112be66 --- /dev/null +++ b/commands/command_test.go @@ -0,0 +1,146 @@ +package commands + +import "testing" + +func TestOptionValidation(t *testing.T) { + cmd := Command{ + Options: []Option{ + Option{[]string{"b", "beep"}, Int}, + Option{[]string{"B", "boop"}, String}, + }, + Run: func(req Request, res Response) {}, + } + + req := NewEmptyRequest() + req.SetOption("foo", 5) + res := cmd.Call(req) + if res.Error() == nil { + t.Error("Should have failed (unrecognized option)") + } + + req = NewEmptyRequest() + req.SetOption("beep", 5) + req.SetOption("b", 10) + res = cmd.Call(req) + if res.Error() == nil { + t.Error("Should have failed (duplicate options)") + } + + req = NewEmptyRequest() + req.SetOption("beep", "foo") + res = cmd.Call(req) + if res.Error() == nil { + t.Error("Should have failed (incorrect type)") + } + + req = NewEmptyRequest() + req.SetOption("beep", 5) + res = cmd.Call(req) + if res.Error() != nil { + t.Error(res.Error(), "Should have passed") + } + + req = NewEmptyRequest() + req.SetOption("beep", 5) + req.SetOption("boop", "test") + res = cmd.Call(req) + if res.Error() != nil { + t.Error("Should have passed") + } + + req = NewEmptyRequest() + req.SetOption("b", 5) + req.SetOption("B", "test") + res = cmd.Call(req) + if res.Error() != nil { + t.Error("Should have passed") + } + + req = NewEmptyRequest() + req.SetOption(EncShort, "json") + res = cmd.Call(req) + if res.Error() != nil { + t.Error("Should have passed") + } + + req = NewEmptyRequest() + req.SetOption("b", "100") + res = cmd.Call(req) + if res.Error() != nil { + t.Error("Should have passed") + } + + req = NewEmptyRequest() + req.SetOption("b", ":)") + res = cmd.Call(req) + if res.Error == nil { + t.Error(res.Error, "Should have failed (string value not convertible to int)") + } +} + +func TestRegistration(t *testing.T) { + noop := func(req Request, res Response) {} + + cmdA := &Command{ + Options: []Option{ + Option{[]string{"beep"}, Int}, + }, + Run: noop, + } + + cmdB := &Command{ + Options: []Option{ + Option{[]string{"beep"}, Int}, + }, + Run: noop, + Subcommands: map[string]*Command{ + "a": cmdA, + }, + } + + cmdC := &Command{ + Options: []Option{ + Option{[]string{"encoding"}, String}, + }, + Run: noop, + } + + res := cmdB.Call(NewRequest([]string{"a"}, nil, nil, nil)) + if res.Error() == nil { + t.Error("Should have failed (option name collision)") + } + + res = cmdC.Call(NewEmptyRequest()) + if res.Error() == nil { + t.Error("Should have failed (option name collision with global options)") + } +} + +func TestResolving(t *testing.T) { + cmdC := &Command{} + cmdB := &Command{ + Subcommands: map[string]*Command{ + "c": cmdC, + }, + } + cmdB2 := &Command{} + cmdA := &Command{ + Subcommands: map[string]*Command{ + "b": cmdB, + "B": cmdB2, + }, + } + cmd := &Command{ + Subcommands: map[string]*Command{ + "a": cmdA, + }, + } + + cmds, err := cmd.Resolve([]string{"a", "b", "c"}) + if err != nil { + t.Error(err) + } + if len(cmds) != 4 || cmds[0] != cmd || cmds[1] != cmdA || cmds[2] != cmdB || cmds[3] != cmdC { + t.Error("Returned command path is different than expected", cmds) + } +} diff --git a/commands/option.go b/commands/option.go new file mode 100644 index 00000000000..8dbf3c1bd54 --- /dev/null +++ b/commands/option.go @@ -0,0 +1,39 @@ +package commands + +import "reflect" + +// Types of Command options +const ( + Invalid = reflect.Invalid + Bool = reflect.Bool + Int = reflect.Int + Uint = reflect.Uint + Float = reflect.Float64 + String = reflect.String +) + +// Option is used to specify a field that will be provided by a consumer +type Option struct { + Names []string // a list of unique names to + Type reflect.Kind // value must be this type + + // TODO: add more features(?): + //Default interface{} // the default value (ignored if `Required` is true) + //Required bool // whether or not the option must be provided +} + +// Flag names +const ( + EncShort = "enc" + EncLong = "encoding" +) + +// options that are used by this package +var globalOptions = []Option{ + Option{[]string{EncShort, EncLong}, String}, +} + +// the above array of Options, wrapped in a Command +var globalCommand = &Command{ + Options: globalOptions, +} diff --git a/commands/request.go b/commands/request.go new file mode 100644 index 00000000000..ab6c0dc2b5b --- /dev/null +++ b/commands/request.go @@ -0,0 +1,143 @@ +package commands + +import ( + "fmt" + "io" + "reflect" + "strconv" +) + +type optMap map[string]interface{} + +// Request represents a call to a command from a consumer +type Request interface { + Path() []string + Option(name string) (interface{}, bool) + SetOption(name string, val interface{}) + Arguments() []string + Stream() io.Reader + SetStream(io.Reader) + + ConvertOptions(options map[string]Option) error +} + +type request struct { + path []string + options optMap + arguments []string + in io.Reader +} + +// Path returns the command path of this request +func (r *request) Path() []string { + return r.path +} + +// Option returns the value of the option for given name. +func (r *request) Option(name string) (interface{}, bool) { + val, err := r.options[name] + return val, err +} + +// SetOption sets the value of the option for given name. +func (r *request) SetOption(name string, val interface{}) { + r.options[name] = val +} + +// Arguments returns the arguments slice +func (r *request) Arguments() []string { + return r.arguments +} + +// Stream returns the input stream Reader +func (r *request) Stream() io.Reader { + return r.in +} + +// SetStream sets the value of the input stream Reader +func (r *request) SetStream(in io.Reader) { + r.in = in +} + +type converter func(string) (interface{}, error) + +var converters = map[reflect.Kind]converter{ + Bool: func(v string) (interface{}, error) { + if v == "" { + return true, nil + } + return strconv.ParseBool(v) + }, + Int: func(v string) (interface{}, error) { + return strconv.ParseInt(v, 0, 32) + }, + Uint: func(v string) (interface{}, error) { + return strconv.ParseInt(v, 0, 32) + }, + Float: func(v string) (interface{}, error) { + return strconv.ParseFloat(v, 64) + }, +} + +func (r *request) ConvertOptions(options map[string]Option) error { + converted := make(map[string]interface{}) + + for k, v := range r.options { + opt, ok := options[k] + if !ok { + return fmt.Errorf("Unrecognized option: '%s'", k) + } + + kind := reflect.TypeOf(v).Kind() + var value interface{} + + if kind != opt.Type { + if kind == String { + convert := converters[opt.Type] + val, err := convert(v.(string)) + if err != nil { + return fmt.Errorf("Could not convert string value '%s' to type '%s'", + v, opt.Type.String()) + } + value = val + + } else { + return fmt.Errorf("Option '%s' should be type '%s', but got type '%s'", + k, opt.Type.String(), kind.String()) + } + } else { + value = v + } + + for _, name := range opt.Names { + if _, ok := r.options[name]; name != k && ok { + return fmt.Errorf("Duplicate command options were provided ('%s' and '%s')", + k, name) + } + + converted[name] = value + } + } + + r.options = converted + return nil +} + +// NewEmptyRequest initializes an empty request +func NewEmptyRequest() Request { + return NewRequest(nil, nil, nil, nil) +} + +// NewRequest returns a request initialized with given arguments +func NewRequest(path []string, opts optMap, args []string, in io.Reader) Request { + if path == nil { + path = make([]string, 0) + } + if opts == nil { + opts = make(map[string]interface{}) + } + if args == nil { + args = make([]string, 0) + } + return &request{path, opts, args, in} +} diff --git a/commands/response.go b/commands/response.go new file mode 100644 index 00000000000..61dede6e2f2 --- /dev/null +++ b/commands/response.go @@ -0,0 +1,127 @@ +package commands + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "strings" +) + +// ErrorType signfies a category of errors +type ErrorType uint + +// ErrorTypes convey what category of error ocurred +const ( + ErrNormal ErrorType = iota // general errors + ErrClient // error was caused by the client, (e.g. invalid CLI usage) + // TODO: add more types of errors for better error-specific handling +) + +// Error is a struct for marshalling errors +type Error struct { + Message string + Code ErrorType +} + +func (e Error) Error() string { + return fmt.Sprintf("%d error: %s", e.Code, e.Message) +} + +// EncodingType defines a supported encoding +type EncodingType string + +// Supported EncodingType constants. +const ( + JSON = "json" + XML = "xml" + // TODO: support more encoding types +) + +// Marshaller is a function used by coding types. +// TODO this should just be a `coding.Codec` +type Marshaller func(v interface{}) ([]byte, error) + +var marshallers = map[EncodingType]Marshaller{ + JSON: json.Marshal, + XML: xml.Marshal, +} + +// Response is the result of a command request. Handlers write to the response, +// setting Error or Value. Response is returned to the client. +type Response interface { + Request() Request + + // Set/Return the response Error + SetError(err error, code ErrorType) + Error() error + + // Sets/Returns the response value + SetValue(interface{}) + Value() interface{} + + // Marshal marshals out the response into a buffer. It uses the EncodingType + // on the Request to chose a Marshaller (Codec). + Marshal() ([]byte, error) +} + +type response struct { + req Request + err *Error + value interface{} + out io.Writer +} + +func (r *response) Request() Request { + return r.req +} + +func (r *response) Value() interface{} { + return r.value +} + +func (r *response) SetValue(v interface{}) { + r.value = v +} + +func (r *response) Stream() io.Writer { + return r.out +} + +func (r *response) Error() error { + if r.err == nil { + return nil + } + return r.err +} + +func (r *response) SetError(err error, code ErrorType) { + r.err = &Error{Message: err.Error(), Code: code} +} + +func (r *response) Marshal() ([]byte, error) { + if r.err == nil && r.value == nil { + return nil, fmt.Errorf("No error or value set, there is nothing to marshal") + } + + enc, ok := r.req.Option(EncShort) + if !ok || enc.(string) == "" { + return nil, fmt.Errorf("No encoding type was specified") + } + encType := EncodingType(strings.ToLower(enc.(string))) + + marshaller, ok := marshallers[encType] + if !ok { + return nil, fmt.Errorf("No marshaller found for encoding type '%s'", enc) + } + + if r.err != nil { + return marshaller(r.err) + } + return marshaller(r.value) +} + +// NewResponse returns a response to match given Request +func NewResponse(req Request) Response { + return &response{req: req} +} diff --git a/commands/response_test.go b/commands/response_test.go new file mode 100644 index 00000000000..058cead0702 --- /dev/null +++ b/commands/response_test.go @@ -0,0 +1,52 @@ +package commands + +import ( + "fmt" + "testing" +) + +type TestOutput struct { + Foo, Bar string + Baz int +} + +func TestMarshalling(t *testing.T) { + req := NewEmptyRequest() + + res := NewResponse(req) + res.SetValue(TestOutput{"beep", "boop", 1337}) + + // get command global options so we can set the encoding option + cmd := Command{} + options, err := cmd.GetOptions(nil) + if err != nil { + t.Error(err) + } + + _, err = res.Marshal() + if err == nil { + t.Error("Should have failed (no encoding type specified in request)") + } + + req.SetOption(EncShort, JSON) + req.ConvertOptions(options) + + bytes, err := res.Marshal() + if err != nil { + t.Error(err, "Should have passed") + } + output := string(bytes) + if output != "{\"Foo\":\"beep\",\"Bar\":\"boop\",\"Baz\":1337}" { + t.Error("Incorrect JSON output") + } + + res.SetError(fmt.Errorf("You broke something!"), ErrClient) + bytes, err = res.Marshal() + if err != nil { + t.Error("Should have passed") + } + output = string(bytes) + if output != "{\"Message\":\"You broke something!\",\"Code\":1}" { + t.Error("Incorrect JSON output") + } +}