├── .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 | [![Go Reference](https://pkg.go.dev/badge/github.com/emvi/hide?status.svg)](https://pkg.go.dev/github.com/emvi/hide?status) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/emvi/hide)](https://goreportcard.com/report/github.com/emvi/hide) 5 | Chat on Discord 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 | --------------------------------------------------------------------------------