Skip to content

Commit 6bdf97b

Browse files
committed
dynamic encoding
1 parent 295ba28 commit 6bdf97b

File tree

6 files changed

+300
-173
lines changed

6 files changed

+300
-173
lines changed

id/base62.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package id
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"math/big"
7+
)
8+
9+
type Base62Encoding struct{}
10+
11+
func (e *Base62Encoding) AppendEncode(dst, src []byte) []byte {
12+
if len(src) == 0 {
13+
return nil
14+
}
15+
16+
num := big.Int{}
17+
num.SetBytes(src)
18+
return num.Append(dst, 62)
19+
}
20+
21+
func base62Decode(b []byte) ([]byte, error) {
22+
num := big.Int{}
23+
n, ok := num.SetString(string(b), 62)
24+
if !ok {
25+
return nil, fmt.Errorf("tbd")
26+
}
27+
28+
return n.Bytes(), nil
29+
}
30+
31+
func base62EncodedLen(n int) int {
32+
// log2(62) ≈ 5.954196310386875
33+
return int(math.Ceil(float64(n) * 8 / 5.954196310386875))
34+
}
35+
36+
func base62DecodedLen(m int) int {
37+
// log2(62) ≈ 5.954196310386875
38+
return int(math.Floor(float64(m) * 5.954196310386875 / 8))
39+
}

id/base64.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package id
2+
3+
import (
4+
"encoding/base64"
5+
)
6+
7+
// In URL parameters, the following characters are considered safe and do not need encoding [rfc3986](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1):
8+
// Alphabetic characters: A-Z, a-z
9+
// Digits: 0-9
10+
// Hyphen: -
11+
// Underscore: _
12+
// Period: .
13+
// Tilde: ~
14+
15+
// stdBase64 is a [base64.Encoding] based on [base64.URLEncoding] without padding character.
16+
// alphabet of base64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
17+
var (
18+
stdBase64 = base64.URLEncoding.WithPadding(base64.NoPadding)
19+
stdBase64WithPadding = base64.URLEncoding.WithPadding('~')
20+
)
21+
22+
type Base64 struct{}
23+
24+
func (b Base64) encode(dst, src []byte) {
25+
stdBase64.Encode(dst, src)
26+
}
27+
28+
func (b Base64) appendEncode(dst, src []byte) []byte {
29+
return stdBase64.AppendEncode(dst, src)
30+
}
31+
32+
func (b Base64) encodeToString(src []byte) string {
33+
return stdBase64.EncodeToString(src)
34+
}
35+
36+
func (b Base64) encodedLen(n int) int {
37+
return stdBase64.EncodedLen(n)
38+
}
39+
40+
func (b Base64) appendDecode(dst, src []byte) ([]byte, error) {
41+
return stdBase64.AppendDecode(dst, src)
42+
}
43+
44+
func (b Base64) decodeString(s string) ([]byte, error) {
45+
return stdBase64.DecodeString(s)
46+
}
47+
48+
func (b Base64) decode(dst, src []byte) (n int, err error) {
49+
return stdBase64.Decode(dst, src)
50+
}
51+
52+
func (b Base64) decodedLen(n int) int {
53+
return stdBase64.DecodedLen(n)
54+
}
55+
56+
type Base64WithPadding struct{}
57+
58+
func (b Base64WithPadding) encode(dst, src []byte) {
59+
stdBase64WithPadding.Encode(dst, src)
60+
}
61+
62+
func (b Base64WithPadding) appendEncode(dst, src []byte) []byte {
63+
return stdBase64WithPadding.AppendEncode(dst, src)
64+
}
65+
66+
func (b Base64WithPadding) encodeToString(src []byte) string {
67+
return stdBase64WithPadding.EncodeToString(src)
68+
}
69+
70+
func (b Base64WithPadding) encodedLen(n int) int {
71+
return stdBase64WithPadding.EncodedLen(n)
72+
}
73+
74+
func (b Base64WithPadding) appendDecode(dst, src []byte) ([]byte, error) {
75+
return stdBase64WithPadding.AppendDecode(dst, src)
76+
}
77+
78+
func (b Base64WithPadding) decodeString(s string) ([]byte, error) {
79+
return stdBase64WithPadding.DecodeString(s)
80+
}
81+
82+
func (b Base64WithPadding) decode(dst, src []byte) (n int, err error) {
83+
return stdBase64WithPadding.Decode(dst, src)
84+
}
85+
86+
func (b Base64WithPadding) decodedLen(n int) int {
87+
return stdBase64WithPadding.DecodedLen(n)
88+
}
89+
90+
var (
91+
_ Encoding = Base64{}
92+
_ Encoding = Base64WithPadding{}
93+
)

id/base64_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package id
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/ifnotnil/x/tst"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
type id = [uuidSize]byte
13+
14+
func TestUUID_JSON(t *testing.T) {
15+
type Foo struct {
16+
ID ID[id, Base64] `json:"id"`
17+
}
18+
19+
type FooPointer struct {
20+
ID *ID[id, Base64] `json:"id"`
21+
}
22+
23+
type FooOZ struct {
24+
ID ID[id, Base64] `json:"id,omitzero"`
25+
}
26+
27+
type FooPointerOZ struct {
28+
ID *ID[id, Base64] `json:"id,omitzero"`
29+
}
30+
31+
const js = `{"id":"AZomiURKfF6MYQcDvjFM_A"}`
32+
uuid := id{0x01, 0x9a, 0x26, 0x89, 0x44, 0x4a, 0x7c, 0x5e, 0x8c, 0x61, 0x07, 0x03, 0xbe, 0x31, 0x4c, 0xfc}
33+
34+
unmarshalTests := []struct {
35+
input string
36+
destination any
37+
expected any
38+
errorAsserter tst.ErrorAssertionFunc
39+
}{
40+
{
41+
input: js,
42+
destination: &Foo{ID: ID[id, Base64]{Value: zeroUUID}},
43+
expected: &Foo{ID: ID[id, Base64]{Value: uuid}},
44+
errorAsserter: tst.NoError(),
45+
},
46+
{
47+
input: js,
48+
destination: &FooPointer{ID: nil},
49+
expected: &FooPointer{ID: &ID[id, Base64]{Value: uuid}},
50+
errorAsserter: tst.NoError(),
51+
},
52+
}
53+
54+
for i, tc := range unmarshalTests {
55+
t.Run(fmt.Sprintf("unmarshal_%d", i), func(t *testing.T) {
56+
gotErr := json.Unmarshal([]byte(tc.input), tc.destination)
57+
tc.errorAsserter(t, gotErr)
58+
assert.Equal(t, tc.expected, tc.destination)
59+
})
60+
}
61+
62+
marshalTests := []struct {
63+
input any
64+
expectedJSON string
65+
errorAsserter tst.ErrorAssertionFunc
66+
}{
67+
0: {
68+
input: Foo{ID: ID[id, Base64]{Value: uuid}},
69+
expectedJSON: js,
70+
errorAsserter: tst.NoError(),
71+
},
72+
1: {
73+
input: &Foo{ID: ID[id, Base64]{Value: uuid}},
74+
expectedJSON: js,
75+
errorAsserter: tst.NoError(),
76+
},
77+
2: {
78+
input: FooPointer{ID: &ID[id, Base64]{Value: uuid}},
79+
expectedJSON: js,
80+
errorAsserter: tst.NoError(),
81+
},
82+
3: {
83+
input: &FooPointer{ID: &ID[id, Base64]{Value: uuid}},
84+
expectedJSON: js,
85+
errorAsserter: tst.NoError(),
86+
},
87+
4: {
88+
input: FooOZ{ID: ID[id, Base64]{Value: zeroUUID}},
89+
expectedJSON: `{}`,
90+
errorAsserter: tst.NoError(),
91+
},
92+
5: {
93+
input: &FooOZ{ID: ID[id, Base64]{Value: zeroUUID}},
94+
expectedJSON: `{}`,
95+
errorAsserter: tst.NoError(),
96+
},
97+
6: {
98+
input: FooPointerOZ{ID: &ID[id, Base64]{Value: zeroUUID}},
99+
expectedJSON: `{}`,
100+
errorAsserter: tst.NoError(),
101+
},
102+
7: {
103+
input: &FooPointerOZ{ID: &ID[id, Base64]{Value: zeroUUID}},
104+
expectedJSON: `{}`,
105+
errorAsserter: tst.NoError(),
106+
},
107+
8: {
108+
input: &FooPointerOZ{ID: nil},
109+
expectedJSON: `{}`,
110+
errorAsserter: tst.NoError(),
111+
},
112+
}
113+
114+
for i, tc := range marshalTests {
115+
t.Run(fmt.Sprintf("marshal_%d", i), func(t *testing.T) {
116+
got, gotErr := json.Marshal(tc.input)
117+
tc.errorAsserter(t, gotErr)
118+
assert.Equal(t, tc.expectedJSON, string(got))
119+
})
120+
}
121+
}

id/encodings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package id

id/uuid.go

Lines changed: 46 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,58 @@ package id
22

33
import (
44
"encoding"
5-
"encoding/base64"
65
"encoding/json"
76
"errors"
87
"slices"
98
)
109

11-
// In URL parameters, the following characters are considered safe and do not need encoding [rfc3986](https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1):
12-
// Alphabetic characters: A-Z, a-z
13-
// Digits: 0-9
14-
// Hyphen: -
15-
// Underscore: _
16-
// Period: .
17-
// Tilde: ~
18-
19-
// Base64 is a [base64.Encoding] based on [base64.URLEncoding] without padding character.
20-
// alphabet of base64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
21-
var (
22-
Base64 = base64.URLEncoding.WithPadding(base64.NoPadding)
23-
Base64WithPadding = base64.URLEncoding.WithPadding('~')
24-
)
25-
26-
var (
27-
base64UUIDEncodedLen = Base64.EncodedLen(uuidSize)
28-
base64UUIDEncodedLenJSON = base64UUIDEncodedLen + 2
29-
zeroUUID = [uuidSize]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
30-
)
31-
3210
const uuidSize = 16
3311

34-
type Base64UUID[U ~[uuidSize]byte] struct {
35-
Value U
12+
type Encoding interface {
13+
// encode(dst, src []byte)
14+
appendEncode(dst, src []byte) []byte
15+
// encodeToString(src []byte) string
16+
encodedLen(n int) int
17+
// appendDecode(dst, src []byte) ([]byte, error)
18+
// decodeString(s string) ([]byte, error)
19+
decode(dst, src []byte) (n int, err error)
20+
decodedLen(n int) int
3621
}
3722

38-
func (u Base64UUID[U]) IsZero() bool {
23+
type ID[UUID ~[uuidSize]byte, Enc Encoding] struct {
24+
Value UUID
25+
}
26+
27+
func (u ID[UUID, Enc]) IsZero() bool {
3928
return u.Value == zeroUUID
4029
}
4130

42-
func (u Base64UUID[U]) MarshalJSON() ([]byte, error) {
43-
b := make([]byte, 1, base64UUIDEncodedLenJSON)
31+
func (u ID[UUID, Enc]) MarshalJSON() ([]byte, error) {
32+
var enc Enc
33+
ln := enc.encodedLen(uuidSize)
34+
b := make([]byte, 1, ln+2)
4435
b[0] = '"'
45-
if sub, err := u.AppendText(b[1:]); err != nil {
46-
return nil, err
47-
} else {
48-
b = b[:len(sub)+1] // the appended text plus the " character.
49-
}
36+
37+
b = enc.appendEncode(b, u.Value[:])
38+
5039
b = append(b, '"')
5140

5241
return b, nil
5342
}
5443

55-
func (u *Base64UUID[U]) UnmarshalJSON(b []byte) error {
44+
func (u ID[UUID, Enc]) AppendText(b []byte) ([]byte, error) {
45+
var enc Enc
46+
ln := enc.encodedLen(uuidSize)
47+
48+
b = slices.Grow(b, ln)
49+
return enc.appendEncode(b, u.Value[:]), nil
50+
}
51+
52+
func (u ID[UUID, Enc]) MarshalText() ([]byte, error) {
53+
return u.AppendText(nil)
54+
}
55+
56+
func (u *ID[UUID, Enc]) UnmarshalJSON(b []byte) error {
5657
var s string
5758
err := json.Unmarshal(b, &s)
5859
if err != nil {
@@ -62,21 +63,11 @@ func (u *Base64UUID[U]) UnmarshalJSON(b []byte) error {
6263
return u.UnmarshalText([]byte(s))
6364
}
6465

65-
func (u Base64UUID[U]) AppendText(b []byte) ([]byte, error) {
66-
b = slices.Grow(b, base64UUIDEncodedLen)
67-
b = b[:len(b)+base64UUIDEncodedLen] // safe since we already grew the buffer.
68-
Base64.Encode(b, u.Value[:])
69-
return b, nil
70-
}
71-
72-
func (u Base64UUID[U]) MarshalText() ([]byte, error) {
73-
return u.AppendText(nil)
74-
}
75-
76-
func (u *Base64UUID[U]) UnmarshalText(text []byte) error {
66+
func (u *ID[UUID, Enc]) UnmarshalText(text []byte) error {
67+
var enc Enc
7768
dec := [uuidSize]byte{}
7869

79-
n, err := Base64.Decode(dec[:], text)
70+
n, err := enc.decode(dec[:], text)
8071
if err != nil {
8172
return err
8273
}
@@ -90,12 +81,14 @@ func (u *Base64UUID[U]) UnmarshalText(text []byte) error {
9081
return nil
9182
}
9283

93-
var ErrMalformedUUID = errors.New("malformed uuid")
94-
9584
var (
96-
_ json.Marshaler = (*Base64UUID[[uuidSize]byte])(nil)
97-
_ json.Unmarshaler = (*Base64UUID[[uuidSize]byte])(nil)
98-
_ encoding.TextAppender = (*Base64UUID[[uuidSize]byte])(nil)
99-
_ encoding.TextMarshaler = (*Base64UUID[[uuidSize]byte])(nil)
100-
_ encoding.TextUnmarshaler = (*Base64UUID[[uuidSize]byte])(nil)
85+
_ json.Marshaler = (*ID[[uuidSize]byte, Base64])(nil)
86+
_ json.Unmarshaler = (*ID[[uuidSize]byte, Base64])(nil)
87+
_ encoding.TextAppender = (*ID[[uuidSize]byte, Base64])(nil)
88+
_ encoding.TextMarshaler = (*ID[[uuidSize]byte, Base64])(nil)
89+
_ encoding.TextUnmarshaler = (*ID[[uuidSize]byte, Base64])(nil)
10190
)
91+
92+
var ErrMalformedUUID = errors.New("malformed uuid")
93+
94+
var zeroUUID = [uuidSize]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}

0 commit comments

Comments
 (0)