Skip to content

Commit eae9fb6

Browse files
committed
docs: document NullableAttr and provide example usage
1 parent 2acfcfe commit eae9fb6

File tree

5 files changed

+124
-8
lines changed

5 files changed

+124
-8
lines changed

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,72 @@ func (post Post) JSONAPIRelationshipMeta(relation string) *Meta {
409409
}
410410
```
411411

412+
### Nullable attributes
413+
414+
Certain APIs may interpret the meaning of `null` attribute values as significantly
415+
different from unspecified values (those that do not show up in the request).
416+
The default use of the `omitempty` struct tag does not allow for sending
417+
significant `null`s.
418+
419+
A type is provided for this purpose if needed: `NullableAttr[T]`. This type
420+
provides an API for sending and receiving significant `null` values for
421+
attribute values of any type.
422+
423+
In the example below, a payload is presented for a fictitious API that makes use
424+
of significant `null` values. Once enabled, the `UnsettableTime` setting can
425+
only be disabled by updating it to a `null` value.
426+
427+
The payload struct below makes use of a `NullableAttr` with an inner `time.Time`
428+
to allow this behavior:
429+
430+
```go
431+
type Settings struct {
432+
ID int `jsonapi:"primary,videos"`
433+
UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"`
434+
}
435+
```
436+
437+
To enable the setting as described above, an non-null `time.Time` value is
438+
sent to the API. This is done by using the exported
439+
`NewNullableAttrWithValue[T]()` method:
440+
441+
```go
442+
s := Settings{
443+
ID: 1,
444+
UnsettableTime: jsonapi.NewNullableAttrWithValue[time.Time](time.Now()),
445+
}
446+
```
447+
448+
To disable the setting, a `null` value needs to be sent to the API. This is done
449+
by using the exported `NewNullNullableAttr[T]()` method:
450+
451+
```go
452+
s := Settings{
453+
ID: 1,
454+
UnsettableTime: jsonapi.NewNullNullableAttr[time.Time](),
455+
}
456+
```
457+
458+
Once a payload has been marshaled, the attribute value is flattened to a
459+
primitive value:
460+
```
461+
"unsettable_time": "2021-01-01T02:07:14Z",
462+
```
463+
464+
Significant nulls are also included and flattened, even when specifying `omitempty`:
465+
```
466+
"unsettable_time": null,
467+
```
468+
469+
Once a payload is unmarshaled, the target attribute field is hydrated with
470+
the value in the payload and can be retrieved with the `Get()` method:
471+
```go
472+
t, err := s.UnsettableTime.Get()
473+
```
474+
475+
All other struct tags used in the attribute definition will be honored when
476+
marshaling and unmarshaling non-null values for the inner type.
477+
412478
### Custom types
413479

414480
Custom types are supported for primitive types, only, as attributes. Examples,

examples/app.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ func exerciseHandler() {
9696
fmt.Println(buf.String())
9797
fmt.Println("============== end raw jsonapi response =============")
9898

99+
// update
100+
blog.UnsettableTime = jsonapi.NewNullableAttrWithValue[time.Time](time.Now())
101+
in = bytes.NewBuffer(nil)
102+
jsonapi.MarshalOnePayloadEmbedded(in, blog)
103+
104+
req, _ = http.NewRequest(http.MethodPatch, "/blogs", in)
105+
106+
req.Header.Set(headerAccept, jsonapi.MediaType)
107+
108+
w = httptest.NewRecorder()
109+
110+
fmt.Println("============ start update ===========")
111+
http.DefaultServeMux.ServeHTTP(w, req)
112+
fmt.Println("============ stop update ===========")
113+
114+
buf = bytes.NewBuffer(nil)
115+
io.Copy(buf, w.Body)
116+
117+
fmt.Println("============ jsonapi response from update ===========")
118+
fmt.Println(buf.String())
119+
fmt.Println("============== end raw jsonapi response =============")
120+
99121
// echo
100122
blogs := []interface{}{
101123
fixtureBlogCreate(1),

examples/fixtures.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package main
22

3-
import "time"
3+
import (
4+
"time"
5+
)
46

57
func fixtureBlogCreate(i int) *Blog {
68
return &Blog{

examples/handler.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"fmt"
45
"net/http"
56
"strconv"
67

@@ -25,6 +26,8 @@ func (h *ExampleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2526
switch r.Method {
2627
case http.MethodPost:
2728
methodHandler = h.createBlog
29+
case http.MethodPatch:
30+
methodHandler = h.updateBlog
2831
case http.MethodPut:
2932
methodHandler = h.echoBlogs
3033
case http.MethodGet:
@@ -61,6 +64,28 @@ func (h *ExampleHandler) createBlog(w http.ResponseWriter, r *http.Request) {
6164
}
6265
}
6366

67+
func (h *ExampleHandler) updateBlog(w http.ResponseWriter, r *http.Request) {
68+
jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.update")
69+
70+
blog := new(Blog)
71+
72+
if err := jsonapiRuntime.UnmarshalPayload(r.Body, blog); err != nil {
73+
http.Error(w, err.Error(), http.StatusInternalServerError)
74+
return
75+
}
76+
77+
fmt.Println(blog)
78+
79+
// ...do stuff with your blog...
80+
81+
w.WriteHeader(http.StatusCreated)
82+
w.Header().Set(headerContentType, jsonapi.MediaType)
83+
84+
if err := jsonapiRuntime.MarshalPayload(w, blog); err != nil {
85+
http.Error(w, err.Error(), http.StatusInternalServerError)
86+
}
87+
}
88+
6489
func (h *ExampleHandler) echoBlogs(w http.ResponseWriter, r *http.Request) {
6590
jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.list")
6691
// ...fetch your blogs, filter, offset, limit, etc...

examples/models.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import (
99

1010
// Blog is a model representing a blog site
1111
type Blog struct {
12-
ID int `jsonapi:"primary,blogs"`
13-
Title string `jsonapi:"attr,title"`
14-
Posts []*Post `jsonapi:"relation,posts"`
15-
CurrentPost *Post `jsonapi:"relation,current_post"`
16-
CurrentPostID int `jsonapi:"attr,current_post_id"`
17-
CreatedAt time.Time `jsonapi:"attr,created_at"`
18-
ViewCount int `jsonapi:"attr,view_count"`
12+
ID int `jsonapi:"primary,blogs"`
13+
Title string `jsonapi:"attr,title"`
14+
Posts []*Post `jsonapi:"relation,posts"`
15+
CurrentPost *Post `jsonapi:"relation,current_post"`
16+
CurrentPostID int `jsonapi:"attr,current_post_id"`
17+
CreatedAt time.Time `jsonapi:"attr,created_at"`
18+
UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"`
19+
ViewCount int `jsonapi:"attr,view_count"`
1920
}
2021

2122
// Post is a model representing a post on a blog

0 commit comments

Comments
 (0)