feat(command): adds code/test to parse Command strings
This is probably overkill but most of the code is needed to validate the command string. My thought was that this could then be used in the delegation and invocation structs to provide a stronger type. The Command struct itself is a bit more convenient with segments represented by []string but this might need to be changed to simply string to simplify IPLD encoding/decoding
This commit is contained in:
142
command.go
Normal file
142
command.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
separator = "/"
|
||||
)
|
||||
|
||||
// ErrNew indicates that the wrapped error was encountered while creating
|
||||
// a new Command.
|
||||
var ErrNew = errors.New("failed to create Command from elems")
|
||||
|
||||
// ErrParse indicates that the wrapped error was encountered while
|
||||
// attempting to parse a string as a Command.
|
||||
var ErrParse = errors.New("failed to parse Command")
|
||||
|
||||
// ErrorJoin indicates that the wrapped error was encountered while
|
||||
// attempting to join a new segment to a Command.
|
||||
var ErrJoin = errors.New("failed to join segments to Command")
|
||||
|
||||
// ErrRequiresLeadingSlash is returned when a parsing a string that
|
||||
// doesn't start with a [leading slash character].
|
||||
//
|
||||
// [leading slash character]: https://github.com/ucan-wg/spec#segment-structure
|
||||
var ErrRequiresLeadingSlash = parseError("a command requires a leading slash character")
|
||||
|
||||
// ErrDisallowsTrailingSlash is returned when parsing a string that [ends
|
||||
// with a trailing slash character].
|
||||
//
|
||||
// [ends with a trailing slash character]: https://github.com/ucan-wg/spec#segment-structure
|
||||
var ErrDisallowsTrailingSlash = parseError("a command must not include a trailing slash")
|
||||
|
||||
// ErrUCANNamespaceReserved is returned to indicate that a Command's
|
||||
// first segment would contain the [reserved "ucan" namespace].
|
||||
//
|
||||
// [reserved "ucan" namespace]: https://github.com/ucan-wg/spec#ucan-namespace
|
||||
var ErrUCANNamespaceReserved = errors.New("the UCAN namespace is reserved")
|
||||
|
||||
// ErrRequiresLowercase is returned if a Command contains, or would contain,
|
||||
// [uppercase unicode characters].
|
||||
//
|
||||
// [uppercase unicode characters]: https://github.com/ucan-wg/spec#segment-structure
|
||||
var ErrRequiresLowercase = parseError("UCAN path segments must must not contain upper-case characters")
|
||||
|
||||
func parseError(msg string) error {
|
||||
return fmt.Errorf("%w: %s", ErrParse, msg)
|
||||
}
|
||||
|
||||
var _ fmt.Stringer = (*Command)(nil)
|
||||
|
||||
// Command is a concrete messages (a "verb") that MUST be unambiguously
|
||||
// interpretable by the Subject of a UCAN.
|
||||
//
|
||||
// A [Command] is composed of a leading slash which is optionally followed
|
||||
// by one or more slash-separated Segments of lowercase characters.
|
||||
//
|
||||
// [Command]: https://github.com/ucan-wg/spec#command
|
||||
type Command struct {
|
||||
segments []string
|
||||
}
|
||||
|
||||
// New creates a validated command from the provided list of segment
|
||||
// strings. An error is returned if an invalid Command would be
|
||||
// formed
|
||||
func New(segments ...string) (*Command, error) {
|
||||
return newCommand(ErrNew, segments...)
|
||||
}
|
||||
|
||||
func newCommand(err error, segments ...string) (*Command, error) {
|
||||
if len(segments) > 0 && segments[0] == "ucan" {
|
||||
return nil, fmt.Errorf("%w: %w", err, ErrUCANNamespaceReserved)
|
||||
}
|
||||
|
||||
cmd := Command{segments}
|
||||
|
||||
return &cmd, nil
|
||||
}
|
||||
|
||||
// Parse verifies that the provided string contains the required
|
||||
// [segment structure] and, if valid, returns the resulting
|
||||
// Command.
|
||||
//
|
||||
// [segment structure]: https://github.com/ucan-wg/spec#segment-structure
|
||||
func Parse(s string) (*Command, error) {
|
||||
if !strings.HasPrefix(s, "/") {
|
||||
return nil, ErrRequiresLeadingSlash
|
||||
}
|
||||
|
||||
if len(s) > 1 && strings.HasSuffix(s, "/") {
|
||||
return nil, ErrDisallowsTrailingSlash
|
||||
}
|
||||
|
||||
if s != strings.ToLower(s) {
|
||||
return nil, ErrRequiresLowercase
|
||||
}
|
||||
|
||||
// The leading slash will result in the first element from strings.Split
|
||||
// being an empty string which is removed as strings.Join will ignore it.
|
||||
return newCommand(ErrParse, strings.Split(s, "/")[1:]...)
|
||||
}
|
||||
|
||||
// [Top] is the most powerful capability.
|
||||
//
|
||||
// This function returns a Command that is a wildcard and therefore represents the
|
||||
// most powerful abilily. As such it should be handle with care and used
|
||||
// sparingly.
|
||||
//
|
||||
// [Top]: https://github.com/ucan-wg/spec#-aka-top
|
||||
func Top() *Command {
|
||||
cmd, _ := New()
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// IsValid returns true if the provided string is a valid UCAN command.
|
||||
func IsValid(s string) bool {
|
||||
_, err := Parse(s)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Join appends segments to the end of this command using the required
|
||||
// segment separator.
|
||||
func (c *Command) Join(segments ...string) (*Command, error) {
|
||||
return newCommand(ErrJoin, append(c.segments, segments...)...)
|
||||
}
|
||||
|
||||
// Segments returns the ordered segments that comprise the Command as a
|
||||
// slice of strings.
|
||||
func (c *Command) Segments() []string {
|
||||
return c.segments
|
||||
}
|
||||
|
||||
// String returns the composed representation the command. This is also
|
||||
// the required wire representation (before IPLD encoding occurs.)
|
||||
func (c *Command) String() string {
|
||||
return "/" + strings.Join([]string(c.segments), "/")
|
||||
}
|
||||
159
command_test.go
Normal file
159
command_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/ucan-wg/go-ucan/pkg/command"
|
||||
)
|
||||
|
||||
func TestCommand_String(t *testing.T) {
|
||||
require.Equal(t, "/", command.Top().String())
|
||||
}
|
||||
|
||||
func TestIsValidCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("succeeds when", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, testcase := range validTestcases(t) {
|
||||
testcase := testcase
|
||||
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, command.IsValid(testcase.inp))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, testcase := range invalidTestcases(t) {
|
||||
testcase := testcase
|
||||
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.False(t, command.IsValid(testcase.inp))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("succeeds when", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, testcase := range validTestcases(t) {
|
||||
testcase := testcase
|
||||
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, err := command.Parse("/elem0/elem1/elem2")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, testcase := range invalidTestcases(t) {
|
||||
testcase := testcase
|
||||
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd, err := command.Parse(testcase.inp)
|
||||
assert.ErrorIs(t, err, command.ErrParse)
|
||||
assert.ErrorIs(t, err, testcase.err)
|
||||
assert.Nil(t, cmd)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
inp string
|
||||
}
|
||||
|
||||
func validTestcases(t *testing.T) []testcase {
|
||||
t.Helper()
|
||||
|
||||
cmds := []string{
|
||||
"/",
|
||||
"/crud",
|
||||
"/crud/create",
|
||||
"/stack/pop",
|
||||
"/crypto/sign",
|
||||
"/foo/bar/baz/qux/quux",
|
||||
"/ほげ/ふが",
|
||||
}
|
||||
|
||||
testcases := make([]testcase, len(cmds))
|
||||
|
||||
for i, inp := range cmds {
|
||||
testcases[i] = testcase{
|
||||
name: "Command is " + inp,
|
||||
inp: inp,
|
||||
}
|
||||
}
|
||||
|
||||
return testcases
|
||||
}
|
||||
|
||||
type errorTestcase struct {
|
||||
testcase
|
||||
err error
|
||||
}
|
||||
|
||||
func invalidTestcases(t *testing.T) []errorTestcase {
|
||||
t.Helper()
|
||||
|
||||
return []errorTestcase{
|
||||
{
|
||||
testcase: testcase{
|
||||
name: "leading slash is missing",
|
||||
inp: "elem0/elem1/elem2",
|
||||
},
|
||||
err: command.ErrRequiresLeadingSlash,
|
||||
},
|
||||
{
|
||||
testcase: testcase{
|
||||
name: "trailing slash is present",
|
||||
inp: "/elem0/elem1/elem2/",
|
||||
},
|
||||
err: command.ErrDisallowsTrailingSlash,
|
||||
},
|
||||
{
|
||||
testcase: testcase{
|
||||
name: "only reserved ucan namespace",
|
||||
inp: "/ucan",
|
||||
},
|
||||
err: command.ErrUCANNamespaceReserved,
|
||||
},
|
||||
{
|
||||
testcase: testcase{
|
||||
name: "reserved ucan namespace prefix",
|
||||
inp: "/ucan/elem0/elem1/elem2",
|
||||
},
|
||||
err: command.ErrUCANNamespaceReserved,
|
||||
},
|
||||
{
|
||||
testcase: testcase{
|
||||
name: "uppercase character are present",
|
||||
inp: "/elem0/Elem1/elem2",
|
||||
},
|
||||
err: command.ErrRequiresLowercase,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user