Skip to content

Commit

Permalink
Merge pull request #21 from hashicorp/feat/nullable
Browse files Browse the repository at this point in the history
Introduce nullable types
  • Loading branch information
ctrombley authored Jan 30, 2024
2 parents 4b7b22a + 2dbeecf commit 0ee74e5
Show file tree
Hide file tree
Showing 13 changed files with 531 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ '1.21', '1.20', '1.19', '1.18', '1.17', '1.11' ]
go: [ '1.21', '1.20', '1.19', '1.18']
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Expand Down
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,72 @@ func (post Post) JSONAPIRelationshipMeta(relation string) *Meta {
}
```

### Nullable attributes

Certain APIs may interpret the meaning of `null` attribute values as significantly
different from unspecified values (those that do not show up in the request).
The default use of the `omitempty` struct tag does not allow for sending
significant `null`s.

A type is provided for this purpose if needed: `NullableAttr[T]`. This type
provides an API for sending and receiving significant `null` values for
attribute values of any type.

In the example below, a payload is presented for a fictitious API that makes use
of significant `null` values. Once enabled, the `UnsettableTime` setting can
only be disabled by updating it to a `null` value.

The payload struct below makes use of a `NullableAttr` with an inner `time.Time`
to allow this behavior:

```go
type Settings struct {
ID int `jsonapi:"primary,videos"`
UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"`
}
```

To enable the setting as described above, an non-null `time.Time` value is
sent to the API. This is done by using the exported
`NewNullableAttrWithValue[T]()` method:

```go
s := Settings{
ID: 1,
UnsettableTime: jsonapi.NewNullableAttrWithValue[time.Time](time.Now()),
}
```

To disable the setting, a `null` value needs to be sent to the API. This is done
by using the exported `NewNullNullableAttr[T]()` method:

```go
s := Settings{
ID: 1,
UnsettableTime: jsonapi.NewNullNullableAttr[time.Time](),
}
```

Once a payload has been marshaled, the attribute value is flattened to a
primitive value:
```
"unsettable_time": "2021-01-01T02:07:14Z",
```

Significant nulls are also included and flattened, even when specifying `omitempty`:
```
"unsettable_time": null,
```

Once a payload is unmarshaled, the target attribute field is hydrated with
the value in the payload and can be retrieved with the `Get()` method:
```go
t, err := s.UnsettableTime.Get()
```

All other struct tags used in the attribute definition will be honored when
marshaling and unmarshaling non-null values for the inner type.

### Custom types

Custom types are supported for primitive types, only, as attributes. Examples,
Expand Down
22 changes: 22 additions & 0 deletions examples/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ func exerciseHandler() {
fmt.Println(buf.String())
fmt.Println("============== end raw jsonapi response =============")

// update
blog.UnsettableTime = jsonapi.NewNullableAttrWithValue[time.Time](time.Now())
in = bytes.NewBuffer(nil)
jsonapi.MarshalOnePayloadEmbedded(in, blog)

req, _ = http.NewRequest(http.MethodPatch, "/blogs", in)

req.Header.Set(headerAccept, jsonapi.MediaType)

w = httptest.NewRecorder()

fmt.Println("============ start update ===========")
http.DefaultServeMux.ServeHTTP(w, req)
fmt.Println("============ stop update ===========")

buf = bytes.NewBuffer(nil)
io.Copy(buf, w.Body)

fmt.Println("============ jsonapi response from update ===========")
fmt.Println(buf.String())
fmt.Println("============== end raw jsonapi response =============")

// echo
blogs := []interface{}{
fixtureBlogCreate(1),
Expand Down
4 changes: 3 additions & 1 deletion examples/fixtures.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import "time"
import (
"time"
)

func fixtureBlogCreate(i int) *Blog {
return &Blog{
Expand Down
25 changes: 25 additions & 0 deletions examples/handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"net/http"
"strconv"

Expand All @@ -25,6 +26,8 @@ func (h *ExampleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
methodHandler = h.createBlog
case http.MethodPatch:
methodHandler = h.updateBlog
case http.MethodPut:
methodHandler = h.echoBlogs
case http.MethodGet:
Expand Down Expand Up @@ -61,6 +64,28 @@ func (h *ExampleHandler) createBlog(w http.ResponseWriter, r *http.Request) {
}
}

func (h *ExampleHandler) updateBlog(w http.ResponseWriter, r *http.Request) {
jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.update")

blog := new(Blog)

if err := jsonapiRuntime.UnmarshalPayload(r.Body, blog); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

fmt.Println(blog)

// ...do stuff with your blog...

w.WriteHeader(http.StatusCreated)
w.Header().Set(headerContentType, jsonapi.MediaType)

if err := jsonapiRuntime.MarshalPayload(w, blog); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

func (h *ExampleHandler) echoBlogs(w http.ResponseWriter, r *http.Request) {
jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.list")
// ...fetch your blogs, filter, offset, limit, etc...
Expand Down
15 changes: 8 additions & 7 deletions examples/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import (

// Blog is a model representing a blog site
type Blog struct {
ID int `jsonapi:"primary,blogs"`
Title string `jsonapi:"attr,title"`
Posts []*Post `jsonapi:"relation,posts"`
CurrentPost *Post `jsonapi:"relation,current_post"`
CurrentPostID int `jsonapi:"attr,current_post_id"`
CreatedAt time.Time `jsonapi:"attr,created_at"`
ViewCount int `jsonapi:"attr,view_count"`
ID int `jsonapi:"primary,blogs"`
Title string `jsonapi:"attr,title"`
Posts []*Post `jsonapi:"relation,posts"`
CurrentPost *Post `jsonapi:"relation,current_post"`
CurrentPostID int `jsonapi:"attr,current_post_id"`
CreatedAt time.Time `jsonapi:"attr,created_at"`
UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"`
ViewCount int `jsonapi:"attr,view_count"`
}

// Post is a model representing a post on a blog
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
module github.com/hashicorp/jsonapi

go 1.18
9 changes: 9 additions & 0 deletions models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ type TimestampModel struct {
RFC3339P *time.Time `jsonapi:"attr,rfc3339p,rfc3339"`
}

type WithNullableAttrs struct {
ID int `jsonapi:"primary,with-nullables"`
Name string `jsonapi:"attr,name"`
IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"`
RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"`
ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"`
Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"`
}

type Car struct {
ID *string `jsonapi:"primary,cars"`
Make *string `jsonapi:"attr,make,omitempty"`
Expand Down
89 changes: 89 additions & 0 deletions nullable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package jsonapi

import (
"errors"
)

// NullableAttr is a generic type, which implements a field that can be one of three states:
//
// - field is not set in the request
// - field is explicitly set to `null` in the request
// - field is explicitly set to a valid value in the request
//
// NullableAttr is intended to be used with JSON marshalling and unmarshalling.
// This is generally useful for PATCH requests, where attributes with zero
// values are intentionally not marshaled into the request payload so that
// existing attribute values are not overwritten.
//
// Internal implementation details:
//
// - map[true]T means a value was provided
// - map[false]T means an explicit null was provided
// - nil or zero map means the field was not provided
//
// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableAttr`!
//
// Adapted from https://www.jvt.me/posts/2024/01/09/go-json-nullable/
type NullableAttr[T any] map[bool]T

// NewNullableAttrWithValue is a convenience helper to allow constructing a
// NullableAttr with a given value, for instance to construct a field inside a
// struct without introducing an intermediate variable.
func NewNullableAttrWithValue[T any](t T) NullableAttr[T] {
var n NullableAttr[T]
n.Set(t)
return n
}

// NewNullNullableAttr is a convenience helper to allow constructing a NullableAttr with
// an explicit `null`, for instance to construct a field inside a struct
// without introducing an intermediate variable
func NewNullNullableAttr[T any]() NullableAttr[T] {
var n NullableAttr[T]
n.SetNull()
return n
}

// Get retrieves the underlying value, if present, and returns an error if the value was not present
func (t NullableAttr[T]) Get() (T, error) {
var empty T
if t.IsNull() {
return empty, errors.New("value is null")
}
if !t.IsSpecified() {
return empty, errors.New("value is not specified")
}
return t[true], nil
}

// Set sets the underlying value to a given value
func (t *NullableAttr[T]) Set(value T) {
*t = map[bool]T{true: value}
}

// Set sets the underlying value to a given value
func (t *NullableAttr[T]) SetInterface(value interface{}) {
t.Set(value.(T))
}

// IsNull indicate whether the field was sent, and had a value of `null`
func (t NullableAttr[T]) IsNull() bool {
_, foundNull := t[false]
return foundNull
}

// SetNull sets the value to an explicit `null`
func (t *NullableAttr[T]) SetNull() {
var empty T
*t = map[bool]T{false: empty}
}

// IsSpecified indicates whether the field was sent
func (t NullableAttr[T]) IsSpecified() bool {
return len(t) != 0
}

// SetUnspecified sets the value to be absent from the serialized payload
func (t *NullableAttr[T]) SetUnspecified() {
*t = map[bool]T{}
}
30 changes: 30 additions & 0 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,12 @@ func unmarshalAttribute(
value = reflect.ValueOf(attribute)
fieldType := structField.Type

// Handle NullableAttr[T]
if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") {
value, err = handleNullable(attribute, args, structField, fieldValue)
return
}

// Handle field of type []string
if fieldValue.Type() == reflect.TypeOf([]string{}) {
value, err = handleStringSlice(attribute)
Expand Down Expand Up @@ -656,6 +662,30 @@ func handleStringSlice(attribute interface{}) (reflect.Value, error) {
return reflect.ValueOf(values), nil
}

func handleNullable(
attribute interface{},
args []string,
structField reflect.StructField,
fieldValue reflect.Value) (reflect.Value, error) {

if a, ok := attribute.(string); ok && a == "null" {
return reflect.ValueOf(nil), nil
}

innerType := fieldValue.Type().Elem()
zeroValue := reflect.Zero(innerType)

attrVal, err := unmarshalAttribute(attribute, args, structField, zeroValue)
if err != nil {
return reflect.ValueOf(nil), err
}

fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1))
fieldValue.SetMapIndex(reflect.ValueOf(true), attrVal)

return fieldValue, nil
}

func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) (reflect.Value, error) {
var isISO8601, isRFC3339 bool
v := reflect.ValueOf(attribute)
Expand Down
Loading

0 comments on commit 0ee74e5

Please sign in to comment.