├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── hash.go
├── hashid.go
├── hidegopher.png
├── id.go
├── id_test.go
├── init.go
├── util.go
└── util_test.go
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contribute
2 |
3 | Report bugs on GitHub and discuss your ideas and feature requests before you open a pull request. Pull requests must pass the test suite and add new tests for each new feature. Bugs should be validated by tests. The Go code must be formatted by gofmt.
4 |
5 | ## Test coverage
6 |
7 | New code must be tested to keep the test coverage above 80% at least.
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Emvi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hide IDs
2 |
3 | [](https://pkg.go.dev/github.com/emvi/hide?status)
4 | [](https://goreportcard.com/report/github.com/emvi/hide)
5 |
6 |
7 | Hide is a simple package to provide an ID type that is marshaled to/from a hash string.
8 | This prevents sending technical IDs to clients and converts them on the API layer.
9 | Hide uses [hashids](https://github.com/speps/go-hashids) as its default hash function.
10 | But you can provide your own by implementing the `Hash` interface and configuring it using `hide.UseHash`.
11 |
12 | [Read our full article on Medium.](https://medium.com/emvi/golang-transforming-ids-to-a-userfriendly-representation-in-web-applications-85bf2f7d71c5)
13 |
14 | ## Installation
15 |
16 | ```
17 | go get github.com/emvi/hide/v2
18 | ```
19 |
20 | ## Example
21 |
22 | Consider the following struct:
23 |
24 | ```
25 | type User struct {
26 | Id int64 `json:"id"`
27 | Username string `json:"username"`
28 | }
29 | ```
30 |
31 | When marshaling this struct to JSON, the ID will be represented by a number:
32 |
33 | ```
34 | {
35 | "id": 123,
36 | "username": "foobar"
37 | }
38 | ```
39 |
40 | In this case, you expose the technical user ID to your clients. By changing the type of the ID, you get a better result:
41 |
42 | ```
43 | type User struct {
44 | Id hide.ID `json:"id"`
45 | Username string `json:"username"`
46 | }
47 | ```
48 |
49 | Notice that the `int64` ID got replaced by the `hide.ID`, which internally is represented as an `int64` as well, but implements the marshal interface.
50 | This allows you to cast between them and use `hide.ID` as a replacement. The resulting JSON changes to the following:
51 |
52 | ```
53 | {
54 | "id": "beJarVNaQM",
55 | "username": "foobar"
56 | }
57 | ```
58 |
59 | If you send the new ID (which is a string now) back to the server and unmarshal it into the `hide.ID` type, you'll get the original technical ID back.
60 | It's also worth mentioning that a value of 0 is translated to `null` when an ID is marshaled to JSON or stored in a database.
61 |
62 | [View the full demo](https://github.com/emvi/hide-example)
63 |
64 | ## Contribute
65 |
66 | [See CONTRIBUTING.md](CONTRIBUTING.md)
67 |
68 | ## License
69 |
70 | MIT
71 |
72 |
73 |
74 |
75 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/emvi/hide
2 |
3 | go 1.17
4 |
5 | require github.com/speps/go-hashids v2.0.0+incompatible
6 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
2 | github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
3 |
--------------------------------------------------------------------------------
/hash.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | var hash Hash
4 |
5 | // Hash is used to marshal/unmarshal hide.ID to/from JSON.
6 | type Hash interface {
7 | Encode(ID) ([]byte, error)
8 | Decode([]byte) (ID, error)
9 | }
10 |
11 | // UseHash sets the hide.Hash used to marshal/unmarshal hide.ID to/from JSON.
12 | // hide.HashID is used by default.
13 | func UseHash(hashFunction Hash) {
14 | hash = hashFunction
15 | }
16 |
--------------------------------------------------------------------------------
/hashid.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | import (
4 | "errors"
5 | "github.com/speps/go-hashids"
6 | )
7 |
8 | // HashID implements the hide.Hash interface and uses github.com/speps/go-hashids to encode and decode hashes.
9 | type HashID struct {
10 | Salt string
11 | MinLength int
12 | }
13 |
14 | // NewHashID creates a new HashID with given salt and minimum hash length.
15 | func NewHashID(salt string, minlen int) *HashID {
16 | return &HashID{salt, minlen}
17 | }
18 |
19 | // Encode implements the hide.Hash interface.
20 | func (hasher *HashID) Encode(id ID) ([]byte, error) {
21 | hash, err := hasher.newHash()
22 |
23 | if err != nil {
24 | return nil, err
25 | }
26 |
27 | result, err := hash.EncodeInt64([]int64{int64(id)})
28 |
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | return []byte(result), nil
34 | }
35 |
36 | // Decode implements the hide.Hash interface.
37 | func (hasher *HashID) Decode(data []byte) (ID, error) {
38 | if len(data) == 0 {
39 | return 0, nil
40 | }
41 |
42 | hash, err := hasher.newHash()
43 |
44 | if err != nil {
45 | return 0, err
46 | }
47 |
48 | result, err := hash.DecodeInt64WithError(string(data))
49 |
50 | if err != nil {
51 | return 0, err
52 | }
53 |
54 | if len(result) != 1 {
55 | return 0, errors.New("input value too long")
56 | }
57 |
58 | return ID(result[0]), nil
59 | }
60 |
61 | // Creates a new hashids.HashID object to encode/decode IDs.
62 | func (hasher *HashID) newHash() (*hashids.HashID, error) {
63 | config := hashids.NewData()
64 | config.Salt = hasher.Salt
65 | config.MinLength = hasher.MinLength
66 | hash, err := hashids.NewWithData(config)
67 |
68 | if err != nil {
69 | return nil, err
70 | }
71 |
72 | return hash, nil
73 | }
74 |
--------------------------------------------------------------------------------
/hidegopher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emvi/hide/c54a91dbc46cbc1e296e4741aaefb36e706dd2c4/hidegopher.png
--------------------------------------------------------------------------------
/id.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | import (
4 | "database/sql/driver"
5 | "encoding/json"
6 | "errors"
7 | "strings"
8 | )
9 |
10 | // ID type that can be used as an replacement for int64.
11 | // It is converted to/from a hash value when marshalled to/from JSON.
12 | // Value 0 is considered null.
13 | type ID int64
14 |
15 | // Scan implements the Scanner interface.
16 | func (hide *ID) Scan(value interface{}) error {
17 | if value == nil {
18 | *hide = 0
19 | return nil
20 | }
21 |
22 | id, ok := value.(int64)
23 |
24 | if !ok {
25 | return errors.New("unexpected type")
26 | }
27 |
28 | *hide = ID(id)
29 | return nil
30 | }
31 |
32 | // Value implements the driver Valuer interface.
33 | func (hide ID) Value() (driver.Value, error) {
34 | if hide == 0 {
35 | return nil, nil
36 | }
37 |
38 | return int64(hide), nil
39 | }
40 |
41 | // MarshalJSON implements the encoding json interface.
42 | func (hide ID) MarshalJSON() ([]byte, error) {
43 | if hide == 0 {
44 | return json.Marshal(nil)
45 | }
46 |
47 | result, err := hash.Encode(hide)
48 |
49 | if err != nil {
50 | return nil, err
51 | }
52 |
53 | return json.Marshal(string(result))
54 | }
55 |
56 | // UnmarshalJSON implements the encoding json interface.
57 | func (hide *ID) UnmarshalJSON(data []byte) error {
58 | // convert null to 0
59 | if strings.TrimSpace(string(data)) == "null" {
60 | *hide = 0
61 | return nil
62 | }
63 |
64 | // remove quotes
65 | if len(data) >= 2 {
66 | data = data[1 : len(data)-1]
67 | }
68 |
69 | result, err := hash.Decode(data)
70 |
71 | if err != nil {
72 | return err
73 | }
74 |
75 | *hide = result
76 | return nil
77 | }
78 |
--------------------------------------------------------------------------------
/id_test.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | import (
4 | "database/sql/driver"
5 | "encoding/json"
6 | "testing"
7 | )
8 |
9 | type testStruct struct {
10 | Id ID `json:"id"`
11 | Test string `json:"test"`
12 | }
13 |
14 | func TestMarshalID(t *testing.T) {
15 | id := ID(123)
16 | expected := `"beJarVNaQM"`
17 | out, err := json.Marshal(id)
18 |
19 | if err != nil {
20 | t.Fatal(err)
21 | }
22 |
23 | if string(out) != expected {
24 | t.Fatalf("Expected marshalled ID to be %v, but was: %v", expected, string(out))
25 | }
26 | }
27 |
28 | func TestUnmarshalID(t *testing.T) {
29 | in := `"beJarVNaQM"`
30 | var id ID
31 | expected := ID(123)
32 | err := id.UnmarshalJSON([]byte(in))
33 |
34 | if err != nil {
35 | t.Fatal(err)
36 | }
37 |
38 | if id != expected {
39 | t.Fatalf("Expected unmarshalled ID to be %v, but was: %v", expected, id)
40 | }
41 | }
42 |
43 | func TestMarshalUnmarshalMatch(t *testing.T) {
44 | id := ID(123)
45 | marshalled, err := id.MarshalJSON()
46 |
47 | if err != nil {
48 | t.Fatal(err)
49 | }
50 |
51 | id = ID(0)
52 |
53 | if err := id.UnmarshalJSON(marshalled); err != nil {
54 | t.Fatal(err)
55 | }
56 |
57 | if id != ID(123) {
58 | t.Fatalf("Marshal unmarshal mismatch, expected %v but was: %v", ID(123), id)
59 | }
60 | }
61 |
62 | func TestMarshalIDStruct(t *testing.T) {
63 | in := testStruct{ID(123), "struct"}
64 | expected := `{"id":"beJarVNaQM","test":"struct"}`
65 | out, err := json.Marshal(in)
66 |
67 | if err != nil {
68 | t.Fatal(err)
69 | }
70 |
71 | if string(out) != expected {
72 | t.Fatalf("Expected marshalled struct to be %v, but was: %v", expected, string(out))
73 | }
74 | }
75 |
76 | func TestUnmarshalIDStruct(t *testing.T) {
77 | in := `{"id":"beJarVNaQM","test":"struct"}`
78 | var out testStruct
79 | expected := testStruct{123, "struct"}
80 | err := json.Unmarshal([]byte(in), &out)
81 |
82 | if err != nil {
83 | t.Fatal(err)
84 | }
85 |
86 | if out != expected {
87 | t.Fatalf("Expected unmarshalled struct to be %v, but was: %v", expected, out)
88 | }
89 | }
90 |
91 | func TestScan(t *testing.T) {
92 | var id ID
93 | value := int64(123)
94 |
95 | if err := id.Scan(value); err != nil {
96 | t.Fatal(err)
97 | }
98 |
99 | if id != 123 {
100 | t.Fatalf("ID must have been set to value by scan, but was: %v", id)
101 | }
102 | }
103 |
104 | func TestValue(t *testing.T) {
105 | id := ID(123)
106 | driverValue, err := id.Value()
107 |
108 | if err != nil {
109 | t.Fatal(err)
110 | }
111 |
112 | _, ok := driverValue.(int64)
113 |
114 | if !ok {
115 | t.Fatal("Driver value must be of type int64")
116 | }
117 | }
118 |
119 | func TestNull(t *testing.T) {
120 | var id ID
121 | out, _ := id.MarshalJSON()
122 | expected := "null"
123 |
124 | if string(out) != expected {
125 | t.Fatalf("Expected null ID to be '%v', but was: %v", expected, string(out))
126 | }
127 |
128 | value, _ := id.Value()
129 |
130 | if value != driver.Value(nil) {
131 | t.Fatalf("Expected null ID to be driver.Value nil, but was: %v", value)
132 | }
133 | }
134 |
135 | func TestUnmarshalNull(t *testing.T) {
136 | in := `{"id":null}`
137 | out := &struct {
138 | Id ID `json:"id"`
139 | }{}
140 |
141 | if err := json.Unmarshal([]byte(in), out); err != nil || out.Id != 0 {
142 | t.Fatalf("Expected null to be unmarshalled to 0, but was: %v %v", err, out.Id)
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/init.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | func init() {
4 | UseHash(NewHashID("hash", 10))
5 | }
6 |
--------------------------------------------------------------------------------
/util.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | // FromString returns a new ID from given hash by using the hasher or an error if it couldn't decode the hash.
4 | func FromString(id string) (ID, error) {
5 | return hash.Decode([]byte(id))
6 | }
7 |
8 | // ToString returns a new hash from given ID by using the hasher or an error if it couldn't encode the ID.
9 | // If ID is 0, "null" will be returned.
10 | func ToString(id ID) (string, error) {
11 | if id == 0 {
12 | return "null", nil
13 | }
14 |
15 | hash, err := hash.Encode(id)
16 |
17 | if err != nil {
18 | return "", err
19 | }
20 |
21 | return string(hash), nil
22 | }
23 |
--------------------------------------------------------------------------------
/util_test.go:
--------------------------------------------------------------------------------
1 | package hide
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestFromString(t *testing.T) {
8 | if id, err := FromString("invalid"); err == nil || id != 0 {
9 | t.Fatalf("Must not return ID from string, but was: %v %v", err, id)
10 | }
11 |
12 | if id, err := FromString("beJarVNaQM"); err != nil || id != 123 {
13 | t.Fatalf("Must return ID from string, but was: %v %v", err, id)
14 | }
15 | }
16 |
17 | func TestFromStringEmpty(t *testing.T) {
18 | if id, err := FromString(""); err != nil || id != 0 {
19 | t.Fatalf("Must return 0 on empty string, but was: %v %v", err, id)
20 | }
21 | }
22 |
23 | func TestToString(t *testing.T) {
24 | if hash, err := ToString(123); err != nil || hash != "beJarVNaQM" {
25 | t.Fatalf("Must return hash from ID, but was: %v %v", err, hash)
26 | }
27 | }
28 |
29 | func TestToStringNull(t *testing.T) {
30 | if hash, err := ToString(0); err != nil || hash != "null" {
31 | t.Fatalf("Must return 0 from null ID, but was: %v %v", err, hash)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------