diff --git a/apitype/explicit_nullable.go b/apitype/explicit_nullable.go new file mode 100644 index 0000000..074dee2 --- /dev/null +++ b/apitype/explicit_nullable.go @@ -0,0 +1,46 @@ +package apitype + +import ( + "encoding/json" + "reflect" +) + +// ExplicitNullable represents a nullable field that can distinguish between: +// +// - Field omitted from request (Set=false) +// - Field explicitly set to null (Set=true, Value=nil) +// - Field set to a value (Set=true, Value!=nil). +type ExplicitNullable[T any] struct { + // Set is true if the field was present in the request. + Set bool + // Value is the string value if provided. + Value *T +} + +// UnmarshalJSON implements json.Unmarshaler to handle the three possible states +// of an ExplicitNullable[T] field in a JSON payload. +func (ps *ExplicitNullable[T]) UnmarshalJSON(data []byte) error { + // Mark the field as present. + ps.Set = true + // If the JSON value is "null", mark as explicit null. + if string(data) == "null" { + return nil + } + // Otherwise, unmarshal into the value. + return json.Unmarshal(data, &ps.Value) +} + +// ExtractExplicitNullableValueForValidation extracts a value suitable for +// validation from an ExplicitNullable field. Returns nil if the field is +// omitted or explicitly null, which causes validation to be skipped. Returns +// the string value if the field was explicitly set, allowing validation of +// empty strings. +// +// This function is designed to be used with validator.RegisterCustomTypeFunc. +func ExtractExplicitNullableValueForValidation[T any](field reflect.Value) interface{} { + ps, ok := field.Interface().(ExplicitNullable[T]) + if !ok || !ps.Set || ps.Value == nil { + return nil + } + return ps.Value +} diff --git a/apitype/explicit_nullable_test.go b/apitype/explicit_nullable_test.go new file mode 100644 index 0000000..bf2288f --- /dev/null +++ b/apitype/explicit_nullable_test.go @@ -0,0 +1,177 @@ +package apitype + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExplicitNullable_Validation(t *testing.T) { + t.Parallel() + + validate := NewValidator() + + tests := []struct { + name string + json string + wantValid bool + }{ + { + name: "FieldOmittedValid", + json: "{}", + wantValid: true, + }, + { + name: "ExplicitNullValid", + json: `{"label":null}`, + wantValid: true, + }, + { + name: "EmptyStringInvalid", + json: `{"label":""}`, + wantValid: false, + }, + { + name: "ValidShortString", + json: `{"label":"a"}`, + wantValid: true, + }, + { + name: "ValidString", + json: `{"label":"test"}`, + wantValid: true, + }, + { + name: "StringTooLongInvalid", + json: `{"label":"` + strings.Repeat("a", 101) + `"}`, + wantValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var payload testPayload + err := json.Unmarshal([]byte(tt.json), &payload) + require.NoError(t, err) + + err = validate.Struct(payload) + if tt.wantValid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestExtractExplicitNullableValueForValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input any + wantVal any + }{ + { + name: "FieldNotSet", + input: ExplicitNullable[string]{Set: false}, + wantVal: nil, + }, + { + name: "FieldExplicitlyNull", + input: ExplicitNullable[string]{Set: true, Value: nil}, + wantVal: nil, + }, + { + name: "EmptyStringValue", + input: ExplicitNullable[string]{Set: true, Value: ptr("")}, + wantVal: ptr(""), + }, + { + name: "NonEmptyStringValue", + input: ExplicitNullable[string]{Set: true, Value: ptr("test")}, + wantVal: ptr("test"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + val := reflect.ValueOf(tt.input) + got := ExtractExplicitNullableValueForValidation[string](val) + if tt.wantVal == nil { + require.Nil(t, got) + } else { + require.Equal(t, tt.wantVal, got) + } + }) + } +} + +type testPayload struct { + Label ExplicitNullable[string] `json:"label" validate:"omitempty,min=1,max=100"` +} + +func TestExplicitNullable_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + want ExplicitNullable[string] + wantErr bool + }{ + { + name: "FieldOmitted", + json: "{}", + want: ExplicitNullable[string]{Set: false, Value: nil}, + }, + { + name: "ExplicitNull", + json: `{"label":null}`, + want: ExplicitNullable[string]{Set: true, Value: nil}, + }, + { + name: "EmptyString", + json: `{"label":""}`, + want: ExplicitNullable[string]{Set: true, Value: ptr("")}, + }, + { + name: "NonEmptyString", + json: `{"label":"test"}`, + want: ExplicitNullable[string]{Set: true, Value: ptr("test")}, + }, + { + name: "InvalidJSON", + json: `{"label":}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got testPayload + err := json.Unmarshal([]byte(tt.json), &got) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got.Label) + }) + } +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/apitype/validator.go b/apitype/validator.go new file mode 100644 index 0000000..0d5af5f --- /dev/null +++ b/apitype/validator.go @@ -0,0 +1,22 @@ +package apitype + +import ( + "github.com/go-playground/validator/v10" +) + +// NewValidator creates a new validator with ExplicitNullable validation configured. +// +// This function is exported so that it can be used by other packages that need +// to validate Patch fields. +func NewValidator() *validator.Validate { + // WithRequiredStructEnabled can be removed once validator/v11 is released. + val := validator.New(validator.WithRequiredStructEnabled()) + return WithExplicitNullableValidation[string](val) +} + +// WithExplicitNullableValidation registers the validator with the +// ExplicitNullable type. +func WithExplicitNullableValidation[T any](val *validator.Validate) *validator.Validate { + val.RegisterCustomTypeFunc(ExtractExplicitNullableValueForValidation[T], ExplicitNullable[T]{}) + return val +}