diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 839a5c8..1e66466 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -51,6 +51,20 @@ updates:
patterns:
- "*"
+ - package-ecosystem: "gomod"
+ directory: "/id"
+ schedule:
+ interval: "daily"
+ commit-message:
+ prefix: "[id]"
+ include: "scope"
+ allow:
+ - dependency-type: all
+ groups:
+ main:
+ patterns:
+ - "*"
+
- package-ecosystem: "gomod"
directory: "/tst"
schedule:
diff --git a/.github/workflows/sub_id.yml b/.github/workflows/sub_id.yml
new file mode 100644
index 0000000..b40abba
--- /dev/null
+++ b/.github/workflows/sub_id.yml
@@ -0,0 +1,26 @@
+# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
+name: id
+
+on:
+ workflow_dispatch: {}
+
+ push:
+ branches: [ main ]
+
+ pull_request:
+ branches: [ main ]
+ paths:
+ - .golangci.yml
+ - tools/**
+ - .github/workflows/ci.yml
+ - .github/workflows/sub_id.yml
+ - id/**
+
+jobs:
+
+ ci:
+ uses: ./.github/workflows/ci.yml
+ with:
+ mod_path: id
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index e773704..8996176 100644
--- a/README.md
+++ b/README.md
@@ -4,39 +4,12 @@
https://docs.codecov.com/docs/status-badges
-->
-## http
-[Readme](http/README.md)
-[](https://github.com/ifnotnil/x/actions/workflows/sub_http.yml)
-[](https://goreportcard.com/report/github.com/ifnotnil/x/http)
-[](https://pkg.go.dev/github.com/ifnotnil/x/http)
-[](https://pkg.go.dev/github.com/ifnotnil/x/http?tab=versions)
-[](https://codecov.io/gh/ifnotnil/x)
-Install: `go get -u github.com/ifnotnil/x/http`
+| Module | Description |
+| -------- | ------- |
+| `http` | [](https://github.com/ifnotnil/x/actions/workflows/sub_http.yml) [](https://goreportcard.com/report/github.com/ifnotnil/x/http) [](https://pkg.go.dev/github.com/ifnotnil/x/http) [](https://pkg.go.dev/github.com/ifnotnil/x/http?tab=versions) [](https://codecov.io/gh/ifnotnil/x)
Install: `go get -u github.com/ifnotnil/x/http`
[Readme](http/README.md)
http middlewares and inbound/outbound loggers |
+| `conf` | [](https://github.com/ifnotnil/x/actions/workflows/sub_conf.yml) [](https://goreportcard.com/report/github.com/ifnotnil/x/conf) [](https://pkg.go.dev/github.com/ifnotnil/x/conf) [](https://pkg.go.dev/github.com/ifnotnil/x/http?conf=versions) [](https://codecov.io/gh/ifnotnil/x)
Install: `go get -u github.com/ifnotnil/x/conf`
[Readme](conf/README.md)
A [knadh/koanf](github.com/knadh/koanf) boilerplate with some helpers |
+| `tst` | [](https://github.com/ifnotnil/x/actions/workflows/sub_tst.yml) [](https://goreportcard.com/report/github.com/ifnotnil/x/tst) [](https://pkg.go.dev/github.com/ifnotnil/x/tst) [](https://pkg.go.dev/github.com/ifnotnil/x/tst?tab=versions) [](https://codecov.io/gh/ifnotnil/x)
Install: `go get -u github.com/ifnotnil/x/tst`
[Readme](tst/README.md)
A test helper package providing error assertion test functions. |
+| `id` | [](https://github.com/ifnotnil/x/actions/workflows/sub_id.yml) [](https://goreportcard.com/report/github.com/ifnotnil/x/id) [](https://pkg.go.dev/github.com/ifnotnil/x/id) [](https://pkg.go.dev/github.com/ifnotnil/x/id?tab=versions) [](https://codecov.io/gh/ifnotnil/x)
Install: `go get -u github.com/ifnotnil/x/id`
[Readme](id/README.md)
URL Safe base64 and base 62 wrappers of uuid for text representation. |
-## conf
-
-A [knadh/koanf](github.com/knadh/koanf) boilerplate with some helpers.
-
-[Readme](conf/README.md)
-[](https://github.com/ifnotnil/x/actions/workflows/sub_conf.yml)
-[](https://goreportcard.com/report/github.com/ifnotnil/x/conf)
-[](https://pkg.go.dev/github.com/ifnotnil/x/conf)
-[](https://pkg.go.dev/github.com/ifnotnil/x/conf?tab=versions)
-[](https://codecov.io/gh/ifnotnil/x)
-
-Install: `go get -u github.com/ifnotnil/x/conf`
-
-## tst
-
-A test helper package providing error assertion test functions.
-
-[Readme](tst/README.md)
-[](https://github.com/ifnotnil/x/actions/workflows/sub_tst.yml)
-[](https://goreportcard.com/report/github.com/ifnotnil/x/tst)
-[](https://pkg.go.dev/github.com/ifnotnil/x/tst)
-[](https://pkg.go.dev/github.com/ifnotnil/x/tst?tab=versions)
-[](https://codecov.io/gh/ifnotnil/x)
-
-Install: `go get -u github.com/ifnotnil/x/tst`
diff --git a/codecov.yml b/codecov.yml
index f6ba961..67f4cef 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -34,6 +34,10 @@ component_management:
name: tst
paths:
- "tst/"
+ - component_id: id
+ name: id
+ paths:
+ - "id/"
ignore:
- "http/internal/testingx" # test helpers
diff --git a/http/encoding/encoding.go b/http/encoding/encoding.go
index 585fb13..139ee3e 100644
--- a/http/encoding/encoding.go
+++ b/http/encoding/encoding.go
@@ -1,7 +1,6 @@
package encoding
import (
- "encoding/base64"
"errors"
"net/url"
"strings"
@@ -16,16 +15,6 @@ import (
"golang.org/x/text/encoding/unicode"
)
-// URLSafeBase64 returns a [base64.Encoding] based on [base64.URLEncoding] replacing the default padding character ('=') padding character to a url safe one ('~').
-// 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):
-// Alphabetic characters: A-Z, a-z
-// Digits: 0-9
-// Hyphen: -
-// Underscore: _
-// Period: .
-// Tilde: ~
-var URLSafeBase64 = base64.URLEncoding.WithPadding('~')
-
// RFC5987ExtendedNotationParameterValue decodes RFC 5987 encoded filenames expecting the extended notation
// (charset "'" [ language ] "'" value-chars)
// example: UTF-8'en'file%20name.jpg
diff --git a/id/.mockery.yml b/id/.mockery.yml
new file mode 100644
index 0000000..8163587
--- /dev/null
+++ b/id/.mockery.yml
@@ -0,0 +1,23 @@
+# https://vektra.github.io/mockery/v3.5/configuration/
+
+log-level: info
+formatter: goimports
+force-file-write: true
+require-template-schema-exists: true
+
+all: true
+recursive: false
+dir: '{{.InterfaceDir}}'
+filename: mocks_test.go
+pkgname: '{{.SrcPackageName}}'
+structname: '{{.Mock}}{{.InterfaceName}}'
+
+# https://vektra.github.io/mockery/v3.5/template/
+template: testify
+template-schema: '{{.Template}}.schema.json'
+
+packages:
+ github.com/ifnotnil/x/id:
+ config:
+ all: true
+ recursive: true
diff --git a/id/Makefile b/id/Makefile
new file mode 100644
index 0000000..b4b668b
--- /dev/null
+++ b/id/Makefile
@@ -0,0 +1,8 @@
+SHELL := /usr/bin/env bash
+
+REPO_ROOT = $(shell cd .. && pwd)
+
+include $(REPO_ROOT)/scripts/go.mk
+include $(REPO_ROOT)/tools/tools.mk
+include $(REPO_ROOT)/scripts/lib.mk
+
diff --git a/id/README.md b/id/README.md
new file mode 100644
index 0000000..03de4df
--- /dev/null
+++ b/id/README.md
@@ -0,0 +1,10 @@
+# id
+[](https://github.com/ifnotnil/x/actions/workflows/sub_id.yml)
+[](https://goreportcard.com/report/github.com/ifnotnil/x/id)
+[](https://pkg.go.dev/github.com/ifnotnil/x/id)
+[](https://pkg.go.dev/github.com/ifnotnil/x/id?tab=versions)
+[](https://codecov.io/gh/ifnotnil/x)
+
+URL Safe base64 and base 62 wrappers of uuid for text representation.
+
+The wrappers work with generics of `~[16]byte` so both `gofrs` and `google` uuid can be wrapped.
diff --git a/id/base62.go b/id/base62.go
new file mode 100644
index 0000000..ef4a952
--- /dev/null
+++ b/id/base62.go
@@ -0,0 +1,55 @@
+package id
+
+import (
+ "fmt"
+ "math"
+ "math/big"
+)
+
+type Base62 struct{}
+
+func (e Base62) appendEncode(dst, src []byte) []byte {
+ if len(src) == 0 {
+ return nil
+ }
+
+ num := big.Int{}
+ num.SetBytes(src)
+ return num.Append(dst, 62)
+}
+
+func (e Base62) encodedLen(n int) int {
+ return base62EncodedLen(n)
+}
+
+func (e Base62) decode(dst, src []byte) (n int, err error) {
+ if len(src) == 0 {
+ return 0, nil
+ }
+
+ num := big.Int{}
+ nn, ok := num.SetString(string(src), 62)
+ if !ok {
+ return 0, fmt.Errorf("base62 error while parsing")
+ }
+
+ bb := nn.Bytes()
+ // if len(bb) != uuidSize {
+ // return 0, fmt.Errorf("base62 error while parsing")
+ // }
+
+ return copy(dst, bb), nil
+}
+
+// lg262 : log2(62) ≈ 5.954196310386875
+const lg262 = 5.954196310386875
+
+func base62EncodedLen(n int) int {
+ return int(math.Ceil(float64(n) * 8 / lg262))
+}
+
+func base62DecodedLen(m int) int {
+ return int(math.Floor(float64(m) * lg262 / 8))
+}
+
+var _ encoding = Base62{}
diff --git a/id/base64.go b/id/base64.go
new file mode 100644
index 0000000..1fcf1f6
--- /dev/null
+++ b/id/base64.go
@@ -0,0 +1,93 @@
+package id
+
+import (
+ "encoding/base64"
+)
+
+// 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):
+// Alphabetic characters: A-Z, a-z
+// Digits: 0-9
+// Hyphen: -
+// Underscore: _
+// Period: .
+// Tilde: ~
+
+// stdBase64 is a [base64.Encoding] based on [base64.URLEncoding] without padding character.
+// alphabet of base64: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
+var (
+ stdBase64 = base64.URLEncoding.WithPadding(base64.NoPadding)
+ stdBase64WithPadding = base64.URLEncoding.WithPadding('~')
+)
+
+type Base64 struct{}
+
+func (b Base64) encode(dst, src []byte) {
+ stdBase64.Encode(dst, src)
+}
+
+func (b Base64) appendEncode(dst, src []byte) []byte {
+ return stdBase64.AppendEncode(dst, src)
+}
+
+func (b Base64) encodeToString(src []byte) string {
+ return stdBase64.EncodeToString(src)
+}
+
+func (b Base64) encodedLen(n int) int {
+ return stdBase64.EncodedLen(n)
+}
+
+func (b Base64) appendDecode(dst, src []byte) ([]byte, error) {
+ return stdBase64.AppendDecode(dst, src)
+}
+
+func (b Base64) decodeString(s string) ([]byte, error) {
+ return stdBase64.DecodeString(s)
+}
+
+func (b Base64) decode(dst, src []byte) (n int, err error) {
+ return stdBase64.Decode(dst, src)
+}
+
+func (b Base64) decodedLen(n int) int {
+ return stdBase64.DecodedLen(n)
+}
+
+type Base64WithPadding struct{}
+
+func (b Base64WithPadding) encode(dst, src []byte) {
+ stdBase64WithPadding.Encode(dst, src)
+}
+
+func (b Base64WithPadding) appendEncode(dst, src []byte) []byte {
+ return stdBase64WithPadding.AppendEncode(dst, src)
+}
+
+func (b Base64WithPadding) encodeToString(src []byte) string {
+ return stdBase64WithPadding.EncodeToString(src)
+}
+
+func (b Base64WithPadding) encodedLen(n int) int {
+ return stdBase64WithPadding.EncodedLen(n)
+}
+
+func (b Base64WithPadding) appendDecode(dst, src []byte) ([]byte, error) {
+ return stdBase64WithPadding.AppendDecode(dst, src)
+}
+
+func (b Base64WithPadding) decodeString(s string) ([]byte, error) {
+ return stdBase64WithPadding.DecodeString(s)
+}
+
+func (b Base64WithPadding) decode(dst, src []byte) (n int, err error) {
+ return stdBase64WithPadding.Decode(dst, src)
+}
+
+func (b Base64WithPadding) decodedLen(n int) int {
+ return stdBase64WithPadding.DecodedLen(n)
+}
+
+var (
+ _ encoding = Base64{}
+ _ encoding = Base64WithPadding{}
+)
diff --git a/id/go.mod b/id/go.mod
new file mode 100644
index 0000000..24b7595
--- /dev/null
+++ b/id/go.mod
@@ -0,0 +1,15 @@
+module github.com/ifnotnil/x/id
+
+go 1.24.0
+
+require (
+ github.com/ifnotnil/x/tst v0.0.2
+ github.com/stretchr/testify v1.11.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/id/go.sum b/id/go.sum
new file mode 100644
index 0000000..eeaccad
--- /dev/null
+++ b/id/go.sum
@@ -0,0 +1,14 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ifnotnil/x/tst v0.0.2 h1:6ydceMwj3uiKFu1B+TTQJcGd1KZtDOAdyy95kTgtCe4=
+github.com/ifnotnil/x/tst v0.0.2/go.mod h1:TFSDsUOkXhDw6k2+vxuypmPXhJzuQ3U+qWHFc4KiMEo=
+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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/id/mocks_test.go b/id/mocks_test.go
new file mode 100644
index 0000000..8fb0a3e
--- /dev/null
+++ b/id/mocks_test.go
@@ -0,0 +1,212 @@
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package id
+
+import (
+ mock "github.com/stretchr/testify/mock"
+)
+
+// newMockencoding creates a new instance of mockencoding. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockencoding(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *mockencoding {
+ mock := &mockencoding{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
+
+// mockencoding is an autogenerated mock type for the encoding type
+type mockencoding struct {
+ mock.Mock
+}
+
+type mockencoding_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *mockencoding) EXPECT() *mockencoding_Expecter {
+ return &mockencoding_Expecter{mock: &_m.Mock}
+}
+
+// appendEncode provides a mock function for the type mockencoding
+func (_mock *mockencoding) appendEncode(dst []byte, src []byte) []byte {
+ ret := _mock.Called(dst, src)
+
+ if len(ret) == 0 {
+ panic("no return value specified for appendEncode")
+ }
+
+ var r0 []byte
+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) []byte); ok {
+ r0 = returnFunc(dst, src)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]byte)
+ }
+ }
+ return r0
+}
+
+// mockencoding_appendEncode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'appendEncode'
+type mockencoding_appendEncode_Call struct {
+ *mock.Call
+}
+
+// appendEncode is a helper method to define mock.On call
+// - dst []byte
+// - src []byte
+func (_e *mockencoding_Expecter) appendEncode(dst interface{}, src interface{}) *mockencoding_appendEncode_Call {
+ return &mockencoding_appendEncode_Call{Call: _e.mock.On("appendEncode", dst, src)}
+}
+
+func (_c *mockencoding_appendEncode_Call) Run(run func(dst []byte, src []byte)) *mockencoding_appendEncode_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 []byte
+ if args[0] != nil {
+ arg0 = args[0].([]byte)
+ }
+ var arg1 []byte
+ if args[1] != nil {
+ arg1 = args[1].([]byte)
+ }
+ run(
+ arg0,
+ arg1,
+ )
+ })
+ return _c
+}
+
+func (_c *mockencoding_appendEncode_Call) Return(bytes []byte) *mockencoding_appendEncode_Call {
+ _c.Call.Return(bytes)
+ return _c
+}
+
+func (_c *mockencoding_appendEncode_Call) RunAndReturn(run func(dst []byte, src []byte) []byte) *mockencoding_appendEncode_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// decode provides a mock function for the type mockencoding
+func (_mock *mockencoding) decode(dst []byte, src []byte) (int, error) {
+ ret := _mock.Called(dst, src)
+
+ if len(ret) == 0 {
+ panic("no return value specified for decode")
+ }
+
+ var r0 int
+ var r1 error
+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) (int, error)); ok {
+ return returnFunc(dst, src)
+ }
+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) int); ok {
+ r0 = returnFunc(dst, src)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+ if returnFunc, ok := ret.Get(1).(func([]byte, []byte) error); ok {
+ r1 = returnFunc(dst, src)
+ } else {
+ r1 = ret.Error(1)
+ }
+ return r0, r1
+}
+
+// mockencoding_decode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'decode'
+type mockencoding_decode_Call struct {
+ *mock.Call
+}
+
+// decode is a helper method to define mock.On call
+// - dst []byte
+// - src []byte
+func (_e *mockencoding_Expecter) decode(dst interface{}, src interface{}) *mockencoding_decode_Call {
+ return &mockencoding_decode_Call{Call: _e.mock.On("decode", dst, src)}
+}
+
+func (_c *mockencoding_decode_Call) Run(run func(dst []byte, src []byte)) *mockencoding_decode_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 []byte
+ if args[0] != nil {
+ arg0 = args[0].([]byte)
+ }
+ var arg1 []byte
+ if args[1] != nil {
+ arg1 = args[1].([]byte)
+ }
+ run(
+ arg0,
+ arg1,
+ )
+ })
+ return _c
+}
+
+func (_c *mockencoding_decode_Call) Return(n int, err error) *mockencoding_decode_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *mockencoding_decode_Call) RunAndReturn(run func(dst []byte, src []byte) (int, error)) *mockencoding_decode_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// encodedLen provides a mock function for the type mockencoding
+func (_mock *mockencoding) encodedLen(n int) int {
+ ret := _mock.Called(n)
+
+ if len(ret) == 0 {
+ panic("no return value specified for encodedLen")
+ }
+
+ var r0 int
+ if returnFunc, ok := ret.Get(0).(func(int) int); ok {
+ r0 = returnFunc(n)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+ return r0
+}
+
+// mockencoding_encodedLen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'encodedLen'
+type mockencoding_encodedLen_Call struct {
+ *mock.Call
+}
+
+// encodedLen is a helper method to define mock.On call
+// - n int
+func (_e *mockencoding_Expecter) encodedLen(n interface{}) *mockencoding_encodedLen_Call {
+ return &mockencoding_encodedLen_Call{Call: _e.mock.On("encodedLen", n)}
+}
+
+func (_c *mockencoding_encodedLen_Call) Run(run func(n int)) *mockencoding_encodedLen_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 int
+ if args[0] != nil {
+ arg0 = args[0].(int)
+ }
+ run(
+ arg0,
+ )
+ })
+ return _c
+}
+
+func (_c *mockencoding_encodedLen_Call) Return(n1 int) *mockencoding_encodedLen_Call {
+ _c.Call.Return(n1)
+ return _c
+}
+
+func (_c *mockencoding_encodedLen_Call) RunAndReturn(run func(n int) int) *mockencoding_encodedLen_Call {
+ _c.Call.Return(run)
+ return _c
+}
diff --git a/id/uuid.go b/id/uuid.go
new file mode 100644
index 0000000..967723f
--- /dev/null
+++ b/id/uuid.go
@@ -0,0 +1,90 @@
+package id
+
+import (
+ eng "encoding"
+ "encoding/json"
+ "errors"
+)
+
+const uuidSize = 16
+
+type encoding interface {
+ // encode(dst, src []byte)
+ appendEncode(dst, src []byte) []byte
+ // encodeToString(src []byte) string
+ encodedLen(n int) int
+ // appendDecode(dst, src []byte) ([]byte, error)
+ // decodeString(s string) ([]byte, error)
+ decode(dst, src []byte) (n int, err error)
+ // decodedLen(n int) int
+}
+
+type ID[UUID ~[uuidSize]byte, Enc encoding] struct {
+ Value UUID
+}
+
+func (u ID[UUID, Enc]) IsZero() bool {
+ return u.Value == zeroUUID
+}
+
+func (u ID[UUID, Enc]) MarshalJSON() ([]byte, error) {
+ var enc Enc
+ ln := enc.encodedLen(uuidSize)
+ b := make([]byte, 0, ln+2)
+ b = append(b, '"')
+
+ b = enc.appendEncode(b, u.Value[:])
+
+ b = append(b, '"')
+
+ return b, nil
+}
+
+func (u ID[UUID, Enc]) AppendText(b []byte) ([]byte, error) {
+ var enc Enc
+ return enc.appendEncode(b, u.Value[:]), nil
+}
+
+func (u ID[UUID, Enc]) MarshalText() ([]byte, error) {
+ return u.AppendText(nil)
+}
+
+func (u *ID[UUID, Enc]) UnmarshalJSON(b []byte) error {
+ var s string
+ err := json.Unmarshal(b, &s)
+ if err != nil {
+ return err
+ }
+
+ return u.UnmarshalText([]byte(s))
+}
+
+func (u *ID[UUID, Enc]) UnmarshalText(text []byte) error {
+ var enc Enc
+ dec := [uuidSize]byte{}
+
+ n, err := enc.decode(dec[:], text)
+ if err != nil {
+ return err
+ }
+
+ if n != uuidSize {
+ return ErrMalformedUUID
+ }
+
+ u.Value = dec
+
+ return nil
+}
+
+var (
+ _ json.Marshaler = (*ID[[uuidSize]byte, Base64])(nil)
+ _ eng.TextAppender = (*ID[[uuidSize]byte, Base64])(nil)
+ _ eng.TextMarshaler = (*ID[[uuidSize]byte, Base64])(nil)
+ _ json.Unmarshaler = (*ID[[uuidSize]byte, Base64])(nil)
+ _ eng.TextUnmarshaler = (*ID[[uuidSize]byte, Base64])(nil)
+)
+
+var ErrMalformedUUID = errors.New("malformed uuid")
+
+var zeroUUID = [uuidSize]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
diff --git a/id/uuid_test.go b/id/uuid_test.go
new file mode 100644
index 0000000..fdbccfe
--- /dev/null
+++ b/id/uuid_test.go
@@ -0,0 +1,199 @@
+package id
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/ifnotnil/x/tst"
+ "github.com/stretchr/testify/assert"
+)
+
+type id = [uuidSize]byte
+
+type unmarshalTest struct {
+ name string
+ input string
+ destination any
+ expected any
+ errorAsserter tst.ErrorAssertionFunc
+}
+
+func (tc unmarshalTest) Test(t *testing.T) {
+ gotErr := json.Unmarshal([]byte(tc.input), tc.destination)
+ tc.errorAsserter(t, gotErr)
+ assert.Equal(t, tc.expected, tc.destination)
+}
+
+func TestJSONUnmarshal(t *testing.T) {
+ type Foo[Enc encoding] struct {
+ ID ID[id, Enc] `json:"id"`
+ }
+
+ type FooPointer[Enc encoding] struct {
+ ID *ID[id, Enc] `json:"id"`
+ }
+
+ uuid := id{0x01, 0x9a, 0x26, 0x89, 0x44, 0x4a, 0x7c, 0x5e, 0x8c, 0x61, 0x07, 0x03, 0xbe, 0x31, 0x4c, 0xfc}
+
+ unmarshalTests := []unmarshalTest{
+ {
+ name: "Foo[Base64]",
+ input: `{"id":"AZomiURKfF6MYQcDvjFM_A"}`,
+ destination: &Foo[Base64]{ID: ID[id, Base64]{Value: zeroUUID}},
+ expected: &Foo[Base64]{ID: ID[id, Base64]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointer[Base64]",
+ input: `{"id":"AZomiURKfF6MYQcDvjFM_A"}`,
+ destination: &FooPointer[Base64]{ID: nil},
+ expected: &FooPointer[Base64]{ID: &ID[id, Base64]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "Foo[Base64WithPadding]",
+ input: `{"id":"AZomiURKfF6MYQcDvjFM_A~~"}`,
+ destination: &Foo[Base64WithPadding]{ID: ID[id, Base64WithPadding]{Value: zeroUUID}},
+ expected: &Foo[Base64WithPadding]{ID: ID[id, Base64WithPadding]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointer[Base64WithPadding]",
+ input: `{"id":"AZomiURKfF6MYQcDvjFM_A~~"}`,
+ destination: &FooPointer[Base64WithPadding]{ID: nil},
+ expected: &FooPointer[Base64WithPadding]{ID: &ID[id, Base64WithPadding]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "Foo[Base62]",
+ input: `{"id":"31reJxR0z0LERESS86rDe"}`,
+ destination: &Foo[Base62]{ID: ID[id, Base62]{Value: zeroUUID}},
+ expected: &Foo[Base62]{ID: ID[id, Base62]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointer[Base62]",
+ input: `{"id":"31reJxR0z0LERESS86rDe"}`,
+ destination: &FooPointer[Base62]{ID: nil},
+ expected: &FooPointer[Base62]{ID: &ID[id, Base62]{Value: uuid}},
+ errorAsserter: tst.NoError(),
+ },
+ }
+
+ for _, tc := range unmarshalTests {
+ t.Run(tc.name, tc.Test)
+ }
+}
+
+type marshalTestCase struct {
+ name string
+ input any
+ expectedJSON string
+ errorAsserter tst.ErrorAssertionFunc
+}
+
+func (tc marshalTestCase) Test(t *testing.T) {
+ got, gotErr := json.Marshal(tc.input)
+ tc.errorAsserter(t, gotErr)
+ assert.Equal(t, tc.expectedJSON, string(got))
+}
+
+func TestJSONMarshal(t *testing.T) {
+ type Foo[enc encoding] struct {
+ ID ID[id, enc] `json:"id"`
+ }
+
+ type FooPointer[enc encoding] struct {
+ ID *ID[id, enc] `json:"id"`
+ }
+
+ type FooOZ[enc encoding] struct {
+ ID ID[id, enc] `json:"id,omitzero"`
+ }
+
+ type FooPointerOZ[enc encoding] struct {
+ ID *ID[id, enc] `json:"id,omitzero"`
+ }
+
+ const jsBase64 = `{"id":"AZomiURKfF6MYQcDvjFM_A"}`
+ uuid := id{0x01, 0x9a, 0x26, 0x89, 0x44, 0x4a, 0x7c, 0x5e, 0x8c, 0x61, 0x07, 0x03, 0xbe, 0x31, 0x4c, 0xfc}
+
+ marshalTests := []marshalTestCase{
+ {
+ name: "FooOZ[Base64] zero",
+ input: FooOZ[Base64]{ID: ID[id, Base64]{Value: zeroUUID}},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "*FooOZ[Base64] zero",
+ input: &FooOZ[Base64]{ID: ID[id, Base64]{Value: zeroUUID}},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointerOZ[Base64] zero",
+ input: FooPointerOZ[Base64]{ID: &ID[id, Base64]{Value: zeroUUID}},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "*FooPointerOZ[Base64] zero",
+ input: &FooPointerOZ[Base64]{ID: &ID[id, Base64]{Value: zeroUUID}},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointerOZ[Base64] nil",
+ input: FooPointerOZ[Base64]{ID: nil},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "*FooPointerOZ[Base64] nil",
+ input: &FooPointerOZ[Base64]{ID: nil},
+ expectedJSON: `{}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "Foo[Base64] uuid",
+ input: Foo[Base64]{ID: ID[id, Base64]{Value: uuid}},
+ expectedJSON: jsBase64,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "Foo[Base62] uuid",
+ input: Foo[Base62]{ID: ID[id, Base62]{Value: uuid}},
+ expectedJSON: `{"id":"31reJxR0z0LERESS86rDe"}`,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "*Foo[Base64] uuid",
+ input: &Foo[Base64]{ID: ID[id, Base64]{Value: uuid}},
+ expectedJSON: jsBase64,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "FooPointerOZ[Base64] uuid",
+ input: FooPointerOZ[Base64]{ID: &ID[id, Base64]{Value: uuid}},
+ expectedJSON: jsBase64,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "*FooPointerOZ[Base64] uuid",
+ input: &FooPointerOZ[Base64]{ID: &ID[id, Base64]{Value: uuid}},
+ expectedJSON: jsBase64,
+ errorAsserter: tst.NoError(),
+ },
+ {
+ name: "Foo[Base64WithPadding] uuid",
+ input: Foo[Base64WithPadding]{ID: ID[id, Base64WithPadding]{Value: uuid}},
+ expectedJSON: `{"id":"AZomiURKfF6MYQcDvjFM_A~~"}`,
+ errorAsserter: tst.NoError(),
+ },
+ }
+
+ for _, tc := range marshalTests {
+ t.Run(tc.name, tc.Test)
+ }
+}
diff --git a/tools/gen.go b/tools/gen.go
new file mode 100644
index 0000000..244eaac
--- /dev/null
+++ b/tools/gen.go
@@ -0,0 +1,5 @@
+//go:build tools
+
+package tools
+
+