Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apitype/explicit_nullable.go
Original file line number Diff line number Diff line change
@@ -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
}
177 changes: 177 additions & 0 deletions apitype/explicit_nullable_test.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions apitype/validator.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +17 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brandur I realized NewValidator above was specifically registering the validator with only the string form of ExplicitNullable which isn't flexible enough. I extracted this helper to at least make it easier to register additional ones. Not set on the API but good enough to move forward on for now, lmk if you have better ideas!

val.RegisterCustomTypeFunc(ExtractExplicitNullableValueForValidation[T], ExplicitNullable[T]{})
return val
}
Loading