From 2b3b365d116da1f5040d5c2d1dee6f228dc1c11d Mon Sep 17 00:00:00 2001 From: Piljoong Kim Date: Sat, 14 Mar 2026 10:25:03 +0900 Subject: [PATCH] feat: export sentinel errors and support errors.Is --- construct.go | 9 +++++++-- orderlyid.go | 36 +++++++++++++++++++++++++++--------- orderlyid_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/construct.go b/construct.go index d1edfa4..a6ae68f 100644 --- a/construct.go +++ b/construct.go @@ -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 @@ -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 { @@ -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 } diff --git a/orderlyid.go b/orderlyid.go index 945e7b7..52ef107 100644 --- a/orderlyid.go +++ b/orderlyid.go @@ -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 @@ -183,6 +198,9 @@ 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 @@ -190,28 +208,28 @@ func Parse(s string) (*Parsed, error) { 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) @@ -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 @@ -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 @@ -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 } diff --git a/orderlyid_test.go b/orderlyid_test.go index 96dc2be..f8ff3b9 100644 --- a/orderlyid_test.go +++ b/orderlyid_test.go @@ -1,6 +1,7 @@ package orderlyid import ( + "errors" "strings" "testing" "time" @@ -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) } }