Skip to content

Commit a21600b

Browse files
committed
import apitype.ExplicitNullable
1 parent 85aad90 commit a21600b

3 files changed

Lines changed: 245 additions & 0 deletions

File tree

apitype/explicit_nullable.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package apitype
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
)
7+
8+
// ExplicitNullable represents a nullable field that can distinguish between:
9+
//
10+
// - Field omitted from request (Set=false)
11+
// - Field explicitly set to null (Set=true, Value=nil)
12+
// - Field set to a value (Set=true, Value!=nil).
13+
type ExplicitNullable[T any] struct {
14+
// Set is true if the field was present in the request.
15+
Set bool
16+
// Value is the string value if provided.
17+
Value *T
18+
}
19+
20+
// UnmarshalJSON implements json.Unmarshaler to handle the three possible states
21+
// of an ExplicitNullable[T] field in a JSON payload.
22+
func (ps *ExplicitNullable[T]) UnmarshalJSON(data []byte) error {
23+
// Mark the field as present.
24+
ps.Set = true
25+
// If the JSON value is "null", mark as explicit null.
26+
if string(data) == "null" {
27+
return nil
28+
}
29+
// Otherwise, unmarshal into the value.
30+
return json.Unmarshal(data, &ps.Value)
31+
}
32+
33+
// ExtractExplicitNullableValueForValidation extracts a value suitable for
34+
// validation from an ExplicitNullable field. Returns nil if the field is
35+
// omitted or explicitly null, which causes validation to be skipped. Returns
36+
// the string value if the field was explicitly set, allowing validation of
37+
// empty strings.
38+
//
39+
// This function is designed to be used with validator.RegisterCustomTypeFunc.
40+
func ExtractExplicitNullableValueForValidation[T any](field reflect.Value) interface{} {
41+
ps, ok := field.Interface().(ExplicitNullable[T])
42+
if !ok || !ps.Set || ps.Value == nil {
43+
return nil
44+
}
45+
return ps.Value
46+
}

apitype/explicit_nullable_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package apitype
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestExplicitNullable_Validation(t *testing.T) {
13+
t.Parallel()
14+
15+
validate := NewValidator()
16+
17+
tests := []struct {
18+
name string
19+
json string
20+
wantValid bool
21+
}{
22+
{
23+
name: "FieldOmittedValid",
24+
json: "{}",
25+
wantValid: true,
26+
},
27+
{
28+
name: "ExplicitNullValid",
29+
json: `{"label":null}`,
30+
wantValid: true,
31+
},
32+
{
33+
name: "EmptyStringInvalid",
34+
json: `{"label":""}`,
35+
wantValid: false,
36+
},
37+
{
38+
name: "ValidShortString",
39+
json: `{"label":"a"}`,
40+
wantValid: true,
41+
},
42+
{
43+
name: "ValidString",
44+
json: `{"label":"test"}`,
45+
wantValid: true,
46+
},
47+
{
48+
name: "StringTooLongInvalid",
49+
json: `{"label":"` + strings.Repeat("a", 101) + `"}`,
50+
wantValid: false,
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
t.Parallel()
57+
58+
var payload testPayload
59+
err := json.Unmarshal([]byte(tt.json), &payload)
60+
require.NoError(t, err)
61+
62+
err = validate.Struct(payload)
63+
if tt.wantValid {
64+
require.NoError(t, err)
65+
} else {
66+
require.Error(t, err)
67+
}
68+
})
69+
}
70+
}
71+
72+
func TestExtractExplicitNullableValueForValidation(t *testing.T) {
73+
t.Parallel()
74+
75+
tests := []struct {
76+
name string
77+
input any
78+
wantVal any
79+
}{
80+
{
81+
name: "FieldNotSet",
82+
input: ExplicitNullable[string]{Set: false},
83+
wantVal: nil,
84+
},
85+
{
86+
name: "FieldExplicitlyNull",
87+
input: ExplicitNullable[string]{Set: true, Value: nil},
88+
wantVal: nil,
89+
},
90+
{
91+
name: "EmptyStringValue",
92+
input: ExplicitNullable[string]{Set: true, Value: ptr("")},
93+
wantVal: ptr(""),
94+
},
95+
{
96+
name: "NonEmptyStringValue",
97+
input: ExplicitNullable[string]{Set: true, Value: ptr("test")},
98+
wantVal: ptr("test"),
99+
},
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
t.Parallel()
105+
106+
val := reflect.ValueOf(tt.input)
107+
got := ExtractExplicitNullableValueForValidation[string](val)
108+
if tt.wantVal == nil {
109+
require.Nil(t, got)
110+
} else {
111+
require.Equal(t, tt.wantVal, got)
112+
}
113+
})
114+
}
115+
}
116+
117+
type testPayload struct {
118+
Label ExplicitNullable[string] `json:"label" validate:"omitempty,min=1,max=100"`
119+
}
120+
121+
func TestExplicitNullable_UnmarshalJSON(t *testing.T) {
122+
t.Parallel()
123+
124+
tests := []struct {
125+
name string
126+
json string
127+
want ExplicitNullable[string]
128+
wantErr bool
129+
}{
130+
{
131+
name: "FieldOmitted",
132+
json: "{}",
133+
want: ExplicitNullable[string]{Set: false, Value: nil},
134+
},
135+
{
136+
name: "ExplicitNull",
137+
json: `{"label":null}`,
138+
want: ExplicitNullable[string]{Set: true, Value: nil},
139+
},
140+
{
141+
name: "EmptyString",
142+
json: `{"label":""}`,
143+
want: ExplicitNullable[string]{Set: true, Value: ptr("")},
144+
},
145+
{
146+
name: "NonEmptyString",
147+
json: `{"label":"test"}`,
148+
want: ExplicitNullable[string]{Set: true, Value: ptr("test")},
149+
},
150+
{
151+
name: "InvalidJSON",
152+
json: `{"label":}`,
153+
wantErr: true,
154+
},
155+
}
156+
157+
for _, tt := range tests {
158+
t.Run(tt.name, func(t *testing.T) {
159+
t.Parallel()
160+
161+
var got testPayload
162+
err := json.Unmarshal([]byte(tt.json), &got)
163+
164+
if tt.wantErr {
165+
require.Error(t, err)
166+
return
167+
}
168+
169+
require.NoError(t, err)
170+
require.Equal(t, tt.want, got.Label)
171+
})
172+
}
173+
}
174+
175+
func ptr[T any](v T) *T {
176+
return &v
177+
}

apitype/validator.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package apitype
2+
3+
import (
4+
"github.com/go-playground/validator/v10"
5+
)
6+
7+
// NewValidator creates a new validator with ExplicitNullable validation configured.
8+
//
9+
// This function is exported so that it can be used by other packages that need
10+
// to validate Patch fields.
11+
func NewValidator() *validator.Validate {
12+
// WithRequiredStructEnabled can be removed once validator/v11 is released.
13+
val := validator.New(validator.WithRequiredStructEnabled())
14+
return WithExplicitNullableValidation[string](val)
15+
}
16+
17+
// WithExplicitNullableValidation registers the validator with the
18+
// ExplicitNullable type.
19+
func WithExplicitNullableValidation[T any](val *validator.Validate) *validator.Validate {
20+
val.RegisterCustomTypeFunc(ExtractExplicitNullableValueForValidation[T], ExplicitNullable[T]{})
21+
return val
22+
}

0 commit comments

Comments
 (0)