diff --git a/.github/workflows/ci-go-cover.yml b/.github/workflows/ci-go-cover.yml index ad14fdd..3343b7f 100644 --- a/.github/workflows/ci-go-cover.yml +++ b/.github/workflows/ci-go-cover.yml @@ -14,7 +14,7 @@ # 1. Change workflow name from "cover 100%" to "cover ≥92.5%". Script will automatically use 92.5%. # 2. Update README.md to use the new path to badge.svg because the path includes the workflow name. -name: cover ≥76% +name: cover ≥78% on: [push, pull_request] jobs: diff --git a/go.mod b/go.mod index 28ee196..f1bab5a 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,6 @@ go 1.15 require ( github.com/fxamacker/cbor/v2 v2.3.0 + github.com/google/uuid v1.3.0 github.com/stretchr/testify v1.6.1 ) diff --git a/go.sum b/go.sum index b8c277a..67f55ab 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik= github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/tagid.go b/tagid.go index 3654048..488ad31 100644 --- a/tagid.go +++ b/tagid.go @@ -4,105 +4,125 @@ package swid import ( - "encoding/hex" "encoding/json" "encoding/xml" "errors" "fmt" "github.com/fxamacker/cbor/v2" + "github.com/google/uuid" ) -// TagID is the type of a tag identifier. Allowed formats (enforced via -// checkTagID) are string or [16]byte +// TagID is the type of a tag identifier. Allowed formats are string or +// a valid universally unique identifier (UUID) as defined by RFC4122. type TagID struct { val interface{} } -// NewTagID returns a TagID initialized with the supplied value v -// v is either a string or a [16]byte func NewTagID(v interface{}) *TagID { - if checkTagID(v) != nil { + switch t := v.(type) { + case string: + tagID, _ := string2TagID(t) + return tagID + case []byte: + tagID, _ := NewTagIDFromUUIDBytes(t) + return tagID + default: return nil } - return &TagID{v} } -// String returns the value of the TagID as string. If the TagID has type -// [16]byte the Base 16 encoding is returned -func (t TagID) String() string { - switch v := t.val.(type) { - case string: - return v - case []byte: - return hex.EncodeToString(v) - default: - return "unknown type for tag-id" +func string2TagID(s string) (*TagID, error) { + if tagID, err := NewTagIDFromUUIDString(s); err == nil { + return tagID, nil + } + + if tagID, err := NewTagIDFromString(s); err == nil { + return tagID, nil } + + return nil, errors.New("tag-id is neither a UUID nor a valid string") } -func checkTagID(v interface{}) error { - switch t := v.(type) { - case string: - case []byte: - if len(t) != 16 { - return errors.New("binary tag-id MUST be 16 bytes") - } - default: - return fmt.Errorf("tag-id MUST be []byte or string; got %T", v) +func NewTagIDFromString(s string) (*TagID, error) { + if s == "" { + return nil, errors.New("empty string") } + return &TagID{s}, nil +} - return nil +func NewTagIDFromUUIDString(s string) (*TagID, error) { + u, err := uuid.Parse(s) + if err != nil { + return nil, err + } + + return &TagID{u}, nil +} + +func NewTagIDFromUUIDBytes(b []byte) (*TagID, error) { + u, err := uuid.FromBytes(b) + if err != nil { + return nil, err + } + + return &TagID{u}, nil } -func (t TagID) isString() bool { - switch t.val.(type) { +// String returns the value of the TagID as string. If the TagID has type UUID, +// the string form of uuid, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, is returned +func (t TagID) String() string { + switch v := t.val.(type) { case string: - return true + return v + case uuid.UUID: + return v.String() + default: + return "unknown type for tag-id" } - return false } // MarshalXMLAttr encodes the TagID receiver as XML attribute func (t TagID) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { - if !t.isString() { - return xml.Attr{}, errors.New("only tag-id of type string can be serialized to XML") - } return xml.Attr{Name: name, Value: t.String()}, nil } // UnmarshalXMLAttr decodes the supplied XML attribute into a TagID // Note that this can only unmarshal to string. func (t *TagID) UnmarshalXMLAttr(attr xml.Attr) error { - t.val = attr.Value + tagID, err := string2TagID(attr.Value) + if err != nil { + return fmt.Errorf("error unmarshaling tag-id %q: %w", attr.Value, err) + } + + *t = *tagID + return nil } // MarshalJSON encodes the TagID receiver as JSON string func (t TagID) MarshalJSON() ([]byte, error) { - if !t.isString() { - return nil, errors.New("only tag-id of type string can be serialized to JSON") - } - - return json.Marshal(t.val) + return json.Marshal(t.String()) } -// UnmarshalJSON decodes the supplied JSON data into a TagID -// Note that this can only unmarshal to string. +// UnmarshalJSON decodes the supplied JSON data into a TagID. If TagID is of +// type UUID, the string form, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, is +// expected. func (t *TagID) UnmarshalJSON(data []byte) error { - var v interface{} + var s string - if err := json.Unmarshal(data, &v); err != nil { - return err + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("error unmarshaling tag-id: %w", err) } - switch s := v.(type) { - case string: - t.val = s - return nil - default: - return fmt.Errorf("expecting string, found %T instead", s) + tagID, err := string2TagID(s) + if err != nil { + return fmt.Errorf("error unmarshaling tag-id %q: %w", s, err) } + + *t = *tagID + + return nil } // MarshalCBOR encodes the TagID receiver to CBOR @@ -112,17 +132,30 @@ func (t TagID) MarshalCBOR() ([]byte, error) { // UnmarshalCBOR decodes the supplied data into a TagID func (t *TagID) UnmarshalCBOR(data []byte) error { - var v interface{} + var ( + v interface{} + err error + tagID *TagID + ) - if err := cbor.Unmarshal(data, &v); err != nil { + if err = cbor.Unmarshal(data, &v); err != nil { return err } - if err := checkTagID(v); err != nil { - return err + switch typ := v.(type) { + case string: + tagID, err = NewTagIDFromString(typ) + case []byte: + tagID, err = NewTagIDFromUUIDBytes(typ) + default: + tagID, err = nil, fmt.Errorf("tag-id MUST be []byte or string; got %T", typ) + } + + if err != nil { + return fmt.Errorf("error unmarshaling tag-id: %w", err) } - t.val = v + *t = *tagID return nil } diff --git a/tagid_test.go b/tagid_test.go index 34e1a02..721b7a3 100644 --- a/tagid_test.go +++ b/tagid_test.go @@ -8,15 +8,35 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestTagID_NewFromUUIDString(t *testing.T) { + tv := "00010001-0001-0001-0001-000100010001" + + expected := "00010001-0001-0001-0001-000100010001" + + actual := NewTagID(tv) + + assert.NotNil(t, actual) + assert.Equal(t, expected, actual.String()) +} + +func TestTagID_NewTagID_empty(t *testing.T) { + tv := "" + + actual := NewTagID(tv) + + assert.Nil(t, actual) +} + func TestTagID_16Bytes(t *testing.T) { tv := []byte{ 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, } - expected := "00010001000100010001000100010001" + expected := "00010001-0001-0001-0001-000100010001" actual := NewTagID(tv) @@ -30,9 +50,9 @@ func TestTagID_15Bytes(t *testing.T) { 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, } - err := checkTagID(tv) + _, err := NewTagIDFromUUIDBytes(tv) - assert.EqualError(t, err, "binary tag-id MUST be 16 bytes") + assert.EqualError(t, err, "invalid UUID (got 15 bytes)") } func TestTagID_17Bytes(t *testing.T) { @@ -42,9 +62,9 @@ func TestTagID_17Bytes(t *testing.T) { 0x00, } - err := checkTagID(tv) + _, err := NewTagIDFromUUIDBytes(tv) - assert.EqualError(t, err, "binary tag-id MUST be 16 bytes") + assert.EqualError(t, err, "invalid UUID (got 17 bytes)") } func TestTagID_String(t *testing.T) { @@ -65,9 +85,26 @@ func TestTagID_UnhandledType(t *testing.T) { b: "one", } - err := checkTagID(tv) + actual := NewTagID(tv) + + assert.Nil(t, actual) +} + +func TestTagID_UnmarshalXMLAttrString_empty(t *testing.T) { + v := "" + + tv := xml.Attr{ + Name: xml.Name{Local: "tagId"}, + Value: v, + } + + expectedErr := `error unmarshaling tag-id "": tag-id is neither a UUID nor a valid string` + + var actual TagID + + err := actual.UnmarshalXMLAttr(tv) - assert.EqualError(t, err, "tag-id MUST be []byte or string; got struct { a int; b string }") + assert.EqualError(t, err, expectedErr) } func TestTagID_UnmarshalXMLAttrString(t *testing.T) { @@ -78,20 +115,22 @@ func TestTagID_UnmarshalXMLAttrString(t *testing.T) { Value: v, } - expected := *NewTagID(v) + expected := NewTagID(v) + require.NotNil(t, expected) var actual TagID err := actual.UnmarshalXMLAttr(tv) assert.Nil(t, err) - assert.Equal(t, expected, actual) + assert.Equal(t, *expected, actual) } func TestTagID_MarshalXMLAttrString(t *testing.T) { v := "example.acme.roadrunner-sw-v1-0-0" - tv := *NewTagID(v) + tv := NewTagID(v) + require.NotNil(t, tv) expected := xml.Attr{ Name: xml.Name{Local: "tagId"}, @@ -110,11 +149,15 @@ func TestTagID_MarshalXMLAttrBytes(t *testing.T) { 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, } - tv := *NewTagID(v) + tv := NewTagID(v) + require.NotNil(t, tv) - _, err := tv.MarshalXMLAttr(xml.Name{Local: "tagId"}) + expected := "00010001-0001-0001-0001-000100010001" - assert.EqualError(t, err, "only tag-id of type string can be serialized to XML") + actual, err := tv.MarshalXMLAttr(xml.Name{Local: "tagId"}) + + assert.Nil(t, err) + assert.Equal(t, expected, actual.Value) } func TestTagID_MarshalJSONBytes(t *testing.T) { @@ -123,19 +166,73 @@ func TestTagID_MarshalJSONBytes(t *testing.T) { 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, } - tv := *NewTagID(v) + tv := NewTagID(v) + require.NotNil(t, tv) + + expected := `"00010001-0001-0001-0001-000100010001"` - _, err := tv.MarshalJSON() + actual, err := tv.MarshalJSON() - assert.EqualError(t, err, "only tag-id of type string can be serialized to JSON") + assert.Nil(t, err) + assert.Equal(t, expected, string(actual)) } -func TestTagID_UnMarshalJSONUnhandled(t *testing.T) { +func TestTagID_UnmarshalJSONUnhandled(t *testing.T) { tv := []byte(`{ "k": "0" }`) var actual TagID + expectedErr := "error unmarshaling tag-id: json: cannot unmarshal object into Go value of type string" + + err := actual.UnmarshalJSON(tv) + + assert.EqualError(t, err, expectedErr) +} + +func TestTagID_UnmarshalJSON_empty(t *testing.T) { + tv := []byte(`""`) + + expectedErr := `error unmarshaling tag-id "": tag-id is neither a UUID nor a valid string` + + var actual TagID + err := actual.UnmarshalJSON(tv) - assert.EqualError(t, err, "expecting string, found map[string]interface {} instead") + assert.EqualError(t, err, expectedErr) +} + +func TestTagID_UnmarshalCBOR_EOF(t *testing.T) { + tv := []byte{} + + expectedErr := `EOF` + + var actual TagID + + err := actual.UnmarshalCBOR(tv) + + assert.EqualError(t, err, expectedErr) +} + +func TestTagID_UnmarshalCBOR_unhandled_type(t *testing.T) { + tv := []byte{0xf6} // null + + expectedErr := `error unmarshaling tag-id: tag-id MUST be []byte or string; got ` + + var actual TagID + + err := actual.UnmarshalCBOR(tv) + + assert.EqualError(t, err, expectedErr) +} + +func TestTagID_UnmarshalCBOR_empty_bytes(t *testing.T) { + tv := []byte{0x40} // bytes(0) + + expectedErr := `error unmarshaling tag-id: invalid UUID (got 0 bytes)` + + var actual TagID + + err := actual.UnmarshalCBOR(tv) + + assert.EqualError(t, err, expectedErr) }