Skip to content

Commit 4634691

Browse files
authored
feat: register custom sanitizers (#16)
* feat: option for custom sanitizers * docs: update docs with custom sanitizer option * refactor: code review recommendations
1 parent be8352e commit 4634691

File tree

5 files changed

+269
-4
lines changed

5 files changed

+269
-4
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,64 @@ s := sanitizer.New(sanitizer.OptionDateFormat{
8282
})
8383
```
8484

85+
### Custom Sanitizers
86+
87+
Use this option to register a custom sanitizer function. The sanitizer function is responsible for determining if the field's type is supported for that sanitizer.
88+
89+
The `Name` field tells us what tag name corresponds to the sanitizer.
90+
91+
The `Sanitizer` field tells us which sanitizer function to call when this tag is used. All sanitizers must have this signature: `func(s Sanitizer, structValue reflect.Value, idx int) error`.
92+
93+
```go
94+
// exclaim adds punctuated enthusiasm to a string.
95+
func exclaim(s Sanitizer, structValue reflect.Value, idx int) error {
96+
fieldValue := structValue.Field(idx)
97+
if fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() {
98+
fieldValue = fieldValue.Elem()
99+
}
100+
101+
if fieldValue.Kind() != reflect.String {
102+
return fmt.Errorf("exclaim: invalid type %q", fieldValue.Kind().String())
103+
}
104+
105+
v := fieldValue.Interface().(string)
106+
if strings.HasSuffix(v, "...") {
107+
v = v[:len(v)-3] + "!"
108+
} else if strings.HasSuffix(v, ".") {
109+
v = v[:len(v)-1] + "!"
110+
} else {
111+
v += "!"
112+
}
113+
114+
fieldValue.SetString(v)
115+
116+
return nil
117+
}
118+
119+
type BlogPost struct {
120+
Title string `san:"title"`
121+
Byline string `san:"cap,exclaim"`
122+
Contents []byte
123+
}
124+
125+
func main() {
126+
bp := BlogPost{
127+
Title: "sanitizing go structs"
128+
Byline: "clean up you structs"
129+
}
130+
131+
s := sanitizer.New(
132+
sanitizer.OptionSanitizerFunc{
133+
Name: "exclaim",
134+
Sanitizer: exclaim,
135+
},
136+
)
137+
s.Sanitize(&bp)
138+
139+
fmt.Printf("Title:%q Byline:%q", bp.Title, bp.Byline)
140+
// Title: "Sanitizing Go Structs" Byline:"Clean up your structs!"
141+
}
142+
```
85143

86144
## Available tags
87145

options.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,21 @@ func (o OptionDateFormat) id() string {
4343
func (o OptionDateFormat) value() interface{} {
4444
return o
4545
}
46+
47+
// OptionSanitizerFunc allows users to use custom sanitizer functions
48+
type OptionSanitizerFunc struct {
49+
Name string
50+
Sanitizer SanitizerFunc
51+
}
52+
53+
var _ Option = OptionSanitizerFunc{}
54+
55+
const optionSanitizerFuncID = "sanitizer-func"
56+
57+
func (o OptionSanitizerFunc) id() string {
58+
return optionSanitizerFuncID
59+
}
60+
61+
func (o OptionSanitizerFunc) value() interface{} {
62+
return o.Sanitizer
63+
}

options_test.go

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sanitize
33
import (
44
"reflect"
55
"testing"
6+
"unsafe"
67
)
78

89
type unknownOption struct{}
@@ -17,6 +18,72 @@ func (o unknownOption) value() interface{} {
1718
return "very strange indeed"
1819
}
1920

21+
// sanitizersEqual checks for equality between two sanitizers in a manner similar to reflect.DeepEqual.
22+
//
23+
// We have to check equality manually due to sanitizer functions.
24+
//
25+
// From reflect docs: "Func values are deeply equal if both are nil; otherwise they are not deeply equal."
26+
func sanitizersEqual(s *Sanitizer, o *Sanitizer) bool {
27+
if s == nil && o == nil {
28+
return true
29+
} else if (s != nil && o == nil) || (s == nil && o != nil) {
30+
return false
31+
}
32+
33+
if !reflect.DeepEqual(s.tagName, o.tagName) {
34+
return false
35+
}
36+
37+
if !reflect.DeepEqual(s.dateInput, o.dateInput) {
38+
return false
39+
}
40+
41+
if !reflect.DeepEqual(s.dateKeepFormat, o.dateKeepFormat) {
42+
return false
43+
}
44+
45+
if !reflect.DeepEqual(s.dateOutput, o.dateOutput) {
46+
return false
47+
}
48+
49+
if s.sanitizersByName == nil && o.sanitizersByName == nil {
50+
return true
51+
} else if (s.sanitizersByName != nil && o.sanitizersByName == nil) || (s.sanitizersByName == nil && o.sanitizersByName != nil) {
52+
return false
53+
}
54+
55+
var sKeys []string
56+
for k, _ := range s.sanitizersByName {
57+
sKeys = append(sKeys, k)
58+
}
59+
60+
var oKeys []string
61+
for k, _ := range o.sanitizersByName {
62+
oKeys = append(oKeys, k)
63+
}
64+
65+
if !reflect.DeepEqual(sKeys, oKeys) {
66+
return false
67+
}
68+
69+
for _, k := range sKeys {
70+
psf := s.sanitizersByName[k]
71+
osf := o.sanitizersByName[k]
72+
upsf := *(*unsafe.Pointer)(unsafe.Pointer(&psf))
73+
upof := *(*unsafe.Pointer)(unsafe.Pointer(&osf))
74+
75+
if !reflect.DeepEqual(upsf, upof) {
76+
return false
77+
}
78+
}
79+
80+
return true
81+
}
82+
83+
func doNothing(s Sanitizer, structValue reflect.Value, idx int) error {
84+
return nil
85+
}
86+
2087
func Test_New(t *testing.T) {
2188
type args struct {
2289
options []Option
@@ -89,6 +156,32 @@ func Test_New(t *testing.T) {
89156
want: nil,
90157
wantErr: true,
91158
},
159+
{
160+
name: "valid sanitizer func option",
161+
args: args{
162+
options: []Option{
163+
OptionSanitizerFunc{Name: "capfirst", Sanitizer: capFirst},
164+
},
165+
},
166+
want: &Sanitizer{
167+
tagName: DefaultTagName,
168+
sanitizersByName: map[string]SanitizerFunc{
169+
"capfirst": capFirst,
170+
},
171+
},
172+
wantErr: false,
173+
},
174+
{
175+
name: "duplicate sanitizer func option",
176+
args: args{
177+
options: []Option{
178+
OptionSanitizerFunc{Name: "capfirst", Sanitizer: capFirst},
179+
OptionSanitizerFunc{Name: "capfirst", Sanitizer: doNothing},
180+
},
181+
},
182+
want: nil,
183+
wantErr: true,
184+
},
92185
}
93186
for _, tt := range tests {
94187
t.Run(tt.name, func(t *testing.T) {
@@ -97,7 +190,8 @@ func Test_New(t *testing.T) {
97190
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
98191
return
99192
}
100-
if !reflect.DeepEqual(got, tt.want) {
193+
194+
if !sanitizersEqual(got, tt.want) {
101195
t.Errorf("New() = %v, want %v", got, tt.want)
102196
}
103197
})

sanitize.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ import (
77
"reflect"
88
)
99

10-
// DefaultTagName intance is the name of the tag that must be present on the string
10+
// DefaultTagName instance is the name of the tag that must be present on the string
1111
// fields of the structs to be sanitized. Defaults to "san".
1212
const DefaultTagName = "san"
1313

14-
// Sanitizer intance
14+
type SanitizerFunc func(s Sanitizer, structValue reflect.Value, idx int) error
15+
16+
// Sanitizer instance
1517
type Sanitizer struct {
1618
tagName string
1719
dateInput []string
1820
dateKeepFormat bool
1921
dateOutput string
22+
23+
sanitizersByName map[string]SanitizerFunc
2024
}
2125

2226
// New sanitizer instance
@@ -37,6 +41,16 @@ func New(options ...Option) (*Sanitizer, error) {
3741
s.dateInput = v.Input
3842
s.dateKeepFormat = v.KeepFormat
3943
s.dateOutput = v.Output
44+
case optionSanitizerFuncID:
45+
if s.sanitizersByName == nil {
46+
s.sanitizersByName = make(map[string]SanitizerFunc)
47+
}
48+
49+
v := o.(OptionSanitizerFunc)
50+
if _, ok := s.sanitizersByName[v.Name]; ok {
51+
return nil, fmt.Errorf("sanitizer already registered with name %q", o.id())
52+
}
53+
s.sanitizersByName[v.Name] = o.value().(SanitizerFunc)
4054
default:
4155
return nil, fmt.Errorf("option %q is not valid", o.id())
4256
}
@@ -161,6 +175,19 @@ func (s Sanitizer) sanitizeRec(v reflect.Value) error {
161175
field := v.Field(i)
162176
fkind := field.Kind()
163177

178+
// Prioritize custom sanitizers; tag's value can be re-resolved inside the sanitizer
179+
tags := s.fieldTags(v.Type().Field(i).Tag)
180+
for tag := range tags {
181+
sanitizerFunc, ok := s.sanitizersByName[tag]
182+
if !ok {
183+
continue
184+
}
185+
186+
if err := sanitizerFunc(s, v, i); err != nil {
187+
return err
188+
}
189+
}
190+
164191
// If the field is a slice, sanitize it first
165192
isPtrToSlice := fkind == reflect.Ptr && field.Elem().Kind() == reflect.Slice
166193
isSlice := fkind == reflect.Slice

sanitize_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
package sanitize
22

33
import (
4+
"fmt"
45
"reflect"
6+
"strings"
57
"testing"
68
"time"
79
)
810

11+
func capFirst(s Sanitizer, structValue reflect.Value, idx int) error {
12+
fieldValue := structValue.Field(idx)
13+
14+
if fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() {
15+
fieldValue = fieldValue.Elem()
16+
}
17+
18+
if fieldValue.Kind() != reflect.String {
19+
return fmt.Errorf("capfirst: invalid type %q", fieldValue.Kind().String())
20+
}
21+
22+
v := fieldValue.Interface().(string)
23+
24+
first := string(v[0])
25+
first = strings.ToUpper(first)
26+
v = first + v[1:]
27+
28+
fieldValue.SetString(v)
29+
30+
return nil
31+
}
32+
933
func Test_Sanitize_CodeSample(t *testing.T) {
1034
type Dog struct {
1135
Name string `san:"max=5,trim,lower"`
@@ -50,6 +74,7 @@ func Test_Sanitize_Options(t *testing.T) {
5074
Name string `abcde:"max=5,trim,lower"`
5175
Birthday string `abcde:"date"`
5276
PersonalWebsite string `abcde:"xss,trim"`
77+
Breed string `abcde:"capfirst"`
5378
}
5479

5580
now := time.Now()
@@ -58,12 +83,14 @@ func Test_Sanitize_Options(t *testing.T) {
5883
Name: "Borky Borkins",
5984
Birthday: now.Format(time.RFC3339),
6085
PersonalWebsite: "<html>[head]1=1?;{/head}(/html)",
86+
Breed: "frenchBulldog",
6187
}
6288

6389
expected := Dog{
6490
Name: "borky",
6591
Birthday: now.Format(time.RFC850),
6692
PersonalWebsite: "html head 1 1 /head /html",
93+
Breed: "FrenchBulldog",
6794
}
6895

6996
s, _ := New(
@@ -72,8 +99,49 @@ func Test_Sanitize_Options(t *testing.T) {
7299
Input: []string{time.RFC3339},
73100
Output: time.RFC850,
74101
},
102+
OptionSanitizerFunc{
103+
Name: "capfirst",
104+
Sanitizer: capFirst,
105+
},
75106
)
76-
s.Sanitize(&d)
107+
108+
if err := s.Sanitize(&d); err != nil {
109+
t.Fatalf("Sanitize() - got unexpected error %v", err)
110+
}
111+
112+
if !reflect.DeepEqual(d, expected) {
113+
t.Errorf("Sanitize() - got %+v but wanted %+v", d, expected)
114+
}
115+
}
116+
117+
func Test_Sanitize_InvalidCustomSanitizerType(t *testing.T) {
118+
type Dog struct {
119+
Name string `san:"max=5,trim,lower"`
120+
Adopted bool `san:"capfirst"`
121+
}
122+
123+
d := Dog{
124+
Name: "Borky Borkins",
125+
Adopted: true,
126+
}
127+
128+
expected := Dog{
129+
Name: "borky",
130+
Adopted: true,
131+
}
132+
133+
s, _ := New(
134+
OptionSanitizerFunc{
135+
Name: "capfirst",
136+
Sanitizer: capFirst,
137+
},
138+
)
139+
140+
if err := s.Sanitize(&d); err == nil {
141+
t.Fatal("Sanitize() - did not receive expected error")
142+
} else if !strings.Contains(err.Error(), "capfirst: invalid type") {
143+
t.Fatalf("Sanitize() - received unexpected error %v", err)
144+
}
77145

78146
if !reflect.DeepEqual(d, expected) {
79147
t.Errorf("Sanitize() - got %+v but wanted %+v", d, expected)

0 commit comments

Comments
 (0)