Skip to content
Merged
Show file tree
Hide file tree
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
9 changes: 7 additions & 2 deletions construct.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Components struct {
}

// NewFromParts builds an OrderlyID from explicit component values.
//
// NewFromParts may return an error wrapping ErrInvalidPrefix.
func NewFromParts(c Components, withChecksum bool) (string, error) {
if err := validatePrefix(c.Prefix); err != nil {
return "", err
Expand Down Expand Up @@ -51,10 +53,13 @@ func NewFromParts(c Components, withChecksum bool) (string, error) {

// NewFromPartsHex builds an OrderlyID from explicit component values and a
// big-endian random value encoded as hex.
//
// NewFromPartsHex may return an error wrapping ErrInvalidPrefix or
// ErrInvalidRandomHex.
func NewFromPartsHex(c Components, randomHex string, withChecksum bool) (string, error) {
rb, err := hex.DecodeString(randomHex)
if err != nil {
return "", fmt.Errorf("random_hex: %w", err)
return "", fmt.Errorf("%w: %v", ErrInvalidRandomHex, err)
}
var u uint64
for _, b := range rb {
Expand All @@ -67,7 +72,7 @@ func NewFromPartsHex(c Components, randomHex string, withChecksum bool) (string,
// validatePrefix mirrors your existing prefix regex check.
func validatePrefix(p string) error {
if !prefixRe.MatchString(p) {
return fmt.Errorf("invalid prefix %q", p)
return fmt.Errorf("%w: %q must match [a-z][a-z0-9]{1,30}", ErrInvalidPrefix, p)
}
return nil
}
36 changes: 27 additions & 9 deletions orderlyid.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,21 @@ var (
prefixRe = regexp.MustCompile(`^[a-z][a-z0-9]{1,30}$`)
)

var (
// ErrInvalidFormat reports malformed OrderlyID strings.
ErrInvalidFormat = errors.New("orderlyid: invalid format")
// ErrInvalidPrefix reports prefixes that do not satisfy the public prefix rule.
ErrInvalidPrefix = errors.New("orderlyid: invalid prefix")
// ErrInvalidChecksum reports checksum length or checksum validation failures.
ErrInvalidChecksum = errors.New("orderlyid: invalid checksum")
// ErrInvalidPayloadLength reports payloads that are not the required 32 characters.
ErrInvalidPayloadLength = errors.New("orderlyid: invalid payload length")
// ErrInvalidBase32 reports payloads that are not valid Crockford Base32.
ErrInvalidBase32 = errors.New("orderlyid: invalid base32")
// ErrInvalidRandomHex reports invalid random hex input passed to NewFromPartsHex.
ErrInvalidRandomHex = errors.New("orderlyid: invalid random hex")
)

func init() {
for i := range alphaRev {
alphaRev[i] = 0xFF
Expand Down Expand Up @@ -183,35 +198,38 @@ type Parsed struct {
}

// Parse decodes an OrderlyID string and returns its components.
//
// Parse may return errors wrapping ErrInvalidFormat, ErrInvalidPrefix,
// ErrInvalidChecksum, ErrInvalidPayloadLength, or ErrInvalidBase32.
func Parse(s string) (*Parsed, error) {
s = strings.TrimSpace(s)
base := s
if i := strings.LastIndexByte(s, '-'); i >= 0 {
base = s[:i]
csGiven := s[i+1:]
if len(csGiven) != 4 {
return nil, errors.New("checksum must be 4 chars")
return nil, fmt.Errorf("%w: must be 4 chars", ErrInvalidChecksum)
}
expected := checksum4Base(base)
if !strings.EqualFold(csGiven, expected) {
return nil, errors.New("checksum mismatch")
return nil, fmt.Errorf("%w: checksum mismatch", ErrInvalidChecksum)
}
}
i := strings.IndexByte(base, '_')
if i <= 0 {
return nil, errors.New("missing prefix separator")
return nil, fmt.Errorf("%w: missing prefix separator", ErrInvalidFormat)
}
prefix := base[:i]
if !prefixRe.MatchString(prefix) {
return nil, errors.New("invalid prefix")
return nil, fmt.Errorf("%w: must match [a-z][a-z0-9]{1,30}", ErrInvalidPrefix)
}
payload := base[i+1:]
if len(payload) != 32 {
return nil, errors.New("payload must be 32 chars")
return nil, fmt.Errorf("%w: must be 32 chars", ErrInvalidPayloadLength)
}
for j := 0; j < 32; j++ {
if alphaRev[payload[j]] == 0xFF {
return nil, fmt.Errorf("invalid base32 at pos %d", j)
return nil, fmt.Errorf("%w: invalid character at pos %d", ErrInvalidBase32, j)
}
}
buf, err := b32decode(payload)
Expand Down Expand Up @@ -305,7 +323,7 @@ func b32encode(src []byte) string {

func b32decode(s string) ([]byte, error) {
if len(s) != 32 {
return nil, errors.New("base32 length must be 32")
return nil, fmt.Errorf("%w: must be 32 chars", ErrInvalidPayloadLength)
}
out := make([]byte, 20)
var acc uint32
Expand All @@ -314,7 +332,7 @@ func b32decode(s string) ([]byte, error) {
for i := 0; i < len(s); i++ {
v := alphaRev[s[i]]
if v == 0xFF {
return nil, fmt.Errorf("invalid base32 at %d", i)
return nil, fmt.Errorf("%w: invalid character at pos %d", ErrInvalidBase32, i)
}
acc = (acc << 5) | uint32(v)
bits += 5
Expand All @@ -325,7 +343,7 @@ func b32decode(s string) ([]byte, error) {
}
}
if j != 20 || bits != 0 {
return nil, errors.New("invalid base32 payload")
return nil, fmt.Errorf("%w: invalid payload", ErrInvalidBase32)
}
return out, nil
}
Expand Down
43 changes: 43 additions & 0 deletions orderlyid_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package orderlyid

import (
"errors"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -56,5 +57,47 @@ func TestChecksumRoundTrip(t *testing.T) {
bad := id[:len(id)-1] + "0"
if _, err := Parse(bad); err == nil {
t.Fatalf("expected checksum mismatch")
} else if !errors.Is(err, ErrInvalidChecksum) {
t.Fatalf("expected ErrInvalidChecksum, got %v", err)
}
}

func TestParseErrorsSupportErrorsIs(t *testing.T) {
tests := []struct {
name string
id string
want error
}{
{name: "format", id: "order", want: ErrInvalidFormat},
{name: "prefix", id: "Order_00000000000000000000000000000000", want: ErrInvalidPrefix},
{name: "payload length", id: "order_123", want: ErrInvalidPayloadLength},
{name: "base32", id: "order_0000000000000000000000000000000!", want: ErrInvalidBase32},
{name: "checksum length", id: "order_00000000000000000000000000000000-123", want: ErrInvalidChecksum},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.id)
if err == nil {
t.Fatalf("expected error")
}
if !errors.Is(err, tt.want) {
t.Fatalf("expected %v, got %v", tt.want, err)
}
})
}
}

func TestNewFromPartsErrorsSupportErrorsIs(t *testing.T) {
if _, err := NewFromParts(Components{Prefix: "Bad!"}, false); err == nil {
t.Fatalf("expected invalid prefix error")
} else if !errors.Is(err, ErrInvalidPrefix) {
t.Fatalf("expected ErrInvalidPrefix, got %v", err)
}

if _, err := NewFromPartsHex(Components{Prefix: "order"}, "zz", false); err == nil {
t.Fatalf("expected invalid random hex error")
} else if !errors.Is(err, ErrInvalidRandomHex) {
t.Fatalf("expected ErrInvalidRandomHex, got %v", err)
}
}
Loading