Skip to content

Commit f56d27d

Browse files
Prevent removing of (Required,Default) fields in HTTP PUT. Fixes rs#174
1 parent 9860858 commit f56d27d

File tree

3 files changed

+324
-21
lines changed

3 files changed

+324
-21
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ The REST Layer framework is composed of several sub-packages:
5454
- [Data Integrity & Concurrency Control](#data-integrity-and-concurrency-control)
5555
- [HTTP Headers Support](#http-headers-support)
5656
- [Prefer](#prefer)
57+
- [HTTP Methods](#http-methods)
58+
- [HEAD](#head)
59+
- [GET](#get)
60+
- [POST](#post)
61+
- [PUT](#put)
62+
- [PATCH](#patch)
63+
- [DELETE](#delete)
64+
- [OPTIONS](#options)
5765
- [Data Validation](#data-validation)
5866
- [Nullable Values](#nullable-values)
5967
- [Extensible Data Validation](#extensible-data-validation)
@@ -1179,6 +1187,32 @@ Prefer: return=minimal
11791187
HTTP/1.1 204 No Content
11801188
```
11811189
1190+
## HTTP Methods
1191+
1192+
Following HTTP Methods are supported currently.
1193+
1194+
### HEAD
1195+
The same as [GET](#get), except that it doesn't return any body.
1196+
1197+
### GET
1198+
Used to query a resource with its sub/embedded resources.
1199+
1200+
### POST
1201+
Used to create new resource document, where new `ID` is generated from the server.
1202+
1203+
### PUT
1204+
Used to create/update single resource document given its `ID`.\
1205+
Be aware when dealing with resource fields with `Default` set. Initial creation for such resources will set particular field to its default value if omitted, however on subsequent `PUT` calls this field will be deleted if omitted. If persistent `Default` field is needed use `{Required: true}` with it.
1206+
1207+
### PATCH
1208+
Used to update/patch single resource document given its `ID`.
1209+
1210+
### DELETE
1211+
Used to delete single resource document given its `ID`, or via [Query](#quering).
1212+
1213+
### OPTIONS
1214+
Used to tell the client, which HTTP Methods are supported on a given resource.
1215+
11821216
## Data Validation
11831217
11841218
Data validation is provided out-of-the-box. Your configuration includes a schema definition for every resource managed by the API. Data sent to the API to be inserted/updated will be validated against the schema, and a resource will only be updated if validation passes. See [Field Definition](#field-definition) section to know more about how to configure your validators.

rest/method_item_put_test.go

Lines changed: 287 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@ import (
1414
"github.com/rs/rest-layer/schema/query"
1515
)
1616

17+
func checkPayload(name string, id interface{}, payload map[string]interface{}) requestCheckerFunc {
18+
return func(t *testing.T, vars *requestTestVars) {
19+
var item *resource.Item
20+
21+
s := vars.Storers[name]
22+
q := query.Query{Predicate: query.Predicate{&query.Equal{Field: "id", Value: id}}, Window: &query.Window{Limit: 1}}
23+
if items, err := s.Find(context.Background(), &q); err != nil {
24+
t.Errorf("s.Find failed: %s", err)
25+
return
26+
} else if len(items.Items) != 1 {
27+
t.Errorf("item with ID %v not found", id)
28+
return
29+
} else {
30+
item = items.Items[0]
31+
}
32+
if !reflect.DeepEqual(payload, item.Payload) {
33+
t.Errorf("Unexpected stored payload for item %v:\nexpect: %#v\ngot: %#v", id, payload, item.Payload)
34+
}
35+
}
36+
}
37+
1738
func TestPutItem(t *testing.T) {
1839
now := time.Now()
1940
yesterday := now.Add(-24 * time.Hour)
@@ -50,26 +71,6 @@ func TestPutItem(t *testing.T) {
5071
Storers: map[string]resource.Storer{"foo": s1, "foo.sub": s2},
5172
}
5273
}
53-
checkPayload := func(name string, id interface{}, payload map[string]interface{}) requestCheckerFunc {
54-
return func(t *testing.T, vars *requestTestVars) {
55-
var item *resource.Item
56-
57-
s := vars.Storers[name]
58-
q := query.Query{Predicate: query.Predicate{&query.Equal{Field: "id", Value: id}}, Window: &query.Window{Limit: 1}}
59-
if items, err := s.Find(context.Background(), &q); err != nil {
60-
t.Errorf("s.Find failed: %s", err)
61-
return
62-
} else if len(items.Items) != 1 {
63-
t.Errorf("item with ID %v not found", id)
64-
return
65-
} else {
66-
item = items.Items[0]
67-
}
68-
if !reflect.DeepEqual(payload, item.Payload) {
69-
t.Errorf("Unexpected stored payload for item %v:\nexpect: %#v\ngot: %#v", id, payload, item.Payload)
70-
}
71-
}
72-
}
7374

7475
tests := map[string]requestTest{
7576
`NoStorage`: {
@@ -315,3 +316,269 @@ func TestPutItem(t *testing.T) {
315316
t.Run(n, tc.Test)
316317
}
317318
}
319+
320+
func TestPutItemDefault(t *testing.T) {
321+
now := time.Now()
322+
323+
sharedInit := func() *requestTestVars {
324+
s1 := mem.NewHandler()
325+
s1.Insert(context.Background(), []*resource.Item{
326+
{ID: "1", ETag: "a", Updated: now, Payload: map[string]interface{}{"id": "1", "foo": "odd"}},
327+
{ID: "2", ETag: "b", Updated: now, Payload: map[string]interface{}{"id": "2", "foo": "odd", "bar": "value"}},
328+
})
329+
idx := resource.NewIndex()
330+
idx.Bind("foo", schema.Schema{
331+
Fields: schema.Fields{
332+
"id": {Sortable: true, Filterable: true},
333+
"foo": {Filterable: true},
334+
"bar": {Filterable: true, Default: "default"},
335+
},
336+
}, s1, resource.DefaultConf)
337+
return &requestTestVars{
338+
Index: idx,
339+
Storers: map[string]resource.Storer{"foo": s1},
340+
}
341+
}
342+
343+
tests := map[string]requestTest{
344+
`pathID:not-found,body:valid,default:missing`: {
345+
Init: sharedInit,
346+
NewRequest: func() (*http.Request, error) {
347+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
348+
return http.NewRequest("PUT", "/foo/66", body)
349+
},
350+
ResponseCode: http.StatusCreated,
351+
ResponseBody: `{"id": "66", "foo": "baz", "bar": "default"}`,
352+
ExtraTest: checkPayload("foo", "66", map[string]interface{}{"id": "66", "foo": "baz", "bar": "default"}),
353+
},
354+
`pathID:not-found,body:valid,default:set`: {
355+
Init: sharedInit,
356+
NewRequest: func() (*http.Request, error) {
357+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value"}`))
358+
return http.NewRequest("PUT", "/foo/66", body)
359+
},
360+
ResponseCode: http.StatusCreated,
361+
ResponseBody: `{"id": "66", "foo": "baz", "bar": "value"}`,
362+
ExtraTest: checkPayload("foo", "66", map[string]interface{}{"id": "66", "foo": "baz", "bar": "value"}),
363+
},
364+
`pathID:found,body:valid,default:missing`: {
365+
Init: sharedInit,
366+
NewRequest: func() (*http.Request, error) {
367+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
368+
return http.NewRequest("PUT", "/foo/1", body)
369+
},
370+
ResponseCode: http.StatusOK,
371+
ResponseBody: `{"id": "1", "foo": "baz"}`,
372+
ExtraTest: checkPayload("foo", "1", map[string]interface{}{"id": "1", "foo": "baz"}),
373+
},
374+
`pathID:found,body:valid,default:set`: {
375+
Init: sharedInit,
376+
NewRequest: func() (*http.Request, error) {
377+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value"}`))
378+
return http.NewRequest("PUT", "/foo/1", body)
379+
},
380+
ResponseCode: http.StatusOK,
381+
ResponseBody: `{"id": "1", "foo": "baz", "bar": "value"}`,
382+
ExtraTest: checkPayload("foo", "1", map[string]interface{}{"id": "1", "foo": "baz", "bar": "value"}),
383+
},
384+
`pathID:found,body:valid,default:delete`: {
385+
Init: sharedInit,
386+
NewRequest: func() (*http.Request, error) {
387+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
388+
return http.NewRequest("PUT", "/foo/2", body)
389+
},
390+
ResponseCode: http.StatusOK,
391+
ResponseBody: `{"id": "2", "foo": "baz"}`,
392+
ExtraTest: checkPayload("foo", "2", map[string]interface{}{"id": "2", "foo": "baz"}),
393+
},
394+
}
395+
396+
for n, tc := range tests {
397+
tc := tc // capture range variable
398+
t.Run(n, tc.Test)
399+
}
400+
}
401+
402+
func TestPutItemRequired(t *testing.T) {
403+
now := time.Now()
404+
405+
sharedInit := func() *requestTestVars {
406+
s1 := mem.NewHandler()
407+
s1.Insert(context.Background(), []*resource.Item{
408+
{ID: "1", ETag: "a", Updated: now, Payload: map[string]interface{}{"id": "1", "foo": "odd"}},
409+
{ID: "2", ETag: "b", Updated: now, Payload: map[string]interface{}{"id": "2", "foo": "odd", "bar": "original"}},
410+
})
411+
idx := resource.NewIndex()
412+
idx.Bind("foo", schema.Schema{
413+
Fields: schema.Fields{
414+
"id": {Sortable: true, Filterable: true},
415+
"foo": {Filterable: true},
416+
"bar": {Filterable: true, Required: true},
417+
},
418+
}, s1, resource.DefaultConf)
419+
return &requestTestVars{
420+
Index: idx,
421+
Storers: map[string]resource.Storer{"foo": s1},
422+
}
423+
}
424+
425+
tests := map[string]requestTest{
426+
`pathID:not-found,body:valid,required:missing`: {
427+
Init: sharedInit,
428+
NewRequest: func() (*http.Request, error) {
429+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
430+
return http.NewRequest("PUT", "/foo/66", body)
431+
},
432+
ResponseCode: http.StatusUnprocessableEntity,
433+
ResponseBody: `{
434+
"code": 422,
435+
"message": "Document contains error(s)",
436+
"issues": {
437+
"bar": ["required"]
438+
}
439+
}`,
440+
},
441+
`pathID:not-found,body:valid,required:set`: {
442+
Init: sharedInit,
443+
NewRequest: func() (*http.Request, error) {
444+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value"}`))
445+
return http.NewRequest("PUT", "/foo/1", body)
446+
},
447+
ResponseCode: http.StatusOK,
448+
ResponseBody: `{"id": "1", "foo": "baz", "bar": "value"}`,
449+
ExtraTest: checkPayload("foo", "1", map[string]interface{}{"id": "1", "foo": "baz", "bar": "value"}),
450+
},
451+
`pathID:found,body:valid,required:missing`: {
452+
Init: sharedInit,
453+
NewRequest: func() (*http.Request, error) {
454+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
455+
return http.NewRequest("PUT", "/foo/1", body)
456+
},
457+
ResponseCode: http.StatusUnprocessableEntity,
458+
ResponseBody: `{
459+
"code": 422,
460+
"message": "Document contains error(s)",
461+
"issues": {
462+
"bar": ["required"]
463+
}
464+
}`,
465+
},
466+
`pathID:found,body:valid,required:change`: {
467+
Init: sharedInit,
468+
NewRequest: func() (*http.Request, error) {
469+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value1"}`))
470+
return http.NewRequest("PUT", "/foo/2", body)
471+
},
472+
ResponseCode: http.StatusOK,
473+
ResponseBody: `{"id": "2", "foo": "baz", "bar": "value1"}`,
474+
ExtraTest: checkPayload("foo", "2", map[string]interface{}{"id": "2", "foo": "baz", "bar": "value1"}),
475+
},
476+
`pathID:found,body:valid,required:delete`: {
477+
Init: sharedInit,
478+
NewRequest: func() (*http.Request, error) {
479+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
480+
return http.NewRequest("PUT", "/foo/2", body)
481+
},
482+
ResponseCode: http.StatusUnprocessableEntity,
483+
ResponseBody: `{
484+
"code": 422,
485+
"message": "Document contains error(s)",
486+
"issues": {
487+
"bar": ["required"]
488+
}
489+
}`,
490+
},
491+
}
492+
493+
for n, tc := range tests {
494+
tc := tc // capture range variable
495+
t.Run(n, tc.Test)
496+
}
497+
}
498+
499+
func TestPutItemRequiredDefault(t *testing.T) {
500+
now := time.Now()
501+
502+
sharedInit := func() *requestTestVars {
503+
s1 := mem.NewHandler()
504+
s1.Insert(context.Background(), []*resource.Item{
505+
{ID: "1", ETag: "a", Updated: now, Payload: map[string]interface{}{"id": "1", "foo": "odd"}},
506+
{ID: "2", ETag: "b", Updated: now, Payload: map[string]interface{}{"id": "2", "foo": "odd", "bar": "original"}},
507+
})
508+
idx := resource.NewIndex()
509+
idx.Bind("foo", schema.Schema{
510+
Fields: schema.Fields{
511+
"id": {Sortable: true, Filterable: true},
512+
"foo": {Filterable: true},
513+
"bar": {Filterable: true, Required: true, Default: "default"},
514+
},
515+
}, s1, resource.DefaultConf)
516+
return &requestTestVars{
517+
Index: idx,
518+
Storers: map[string]resource.Storer{"foo": s1},
519+
}
520+
}
521+
522+
tests := map[string]requestTest{
523+
`pathID:not-found,body:valid,required-default:missing`: {
524+
Init: sharedInit,
525+
NewRequest: func() (*http.Request, error) {
526+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
527+
return http.NewRequest("PUT", "/foo/66", body)
528+
},
529+
ResponseCode: http.StatusCreated,
530+
ResponseBody: `{"id": "66", "foo": "baz", "bar": "default"}`,
531+
ExtraTest: checkPayload("foo", "66", map[string]interface{}{"id": "66", "foo": "baz", "bar": "default"}),
532+
},
533+
`pathID:not-found,body:valid,required-default:set`: {
534+
Init: sharedInit,
535+
NewRequest: func() (*http.Request, error) {
536+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value"}`))
537+
return http.NewRequest("PUT", "/foo/1", body)
538+
},
539+
ResponseCode: http.StatusOK,
540+
ResponseBody: `{"id": "1", "foo": "baz", "bar": "value"}`,
541+
ExtraTest: checkPayload("foo", "1", map[string]interface{}{"id": "1", "foo": "baz", "bar": "value"}),
542+
},
543+
`pathID:found,body:valid,required-default:missing`: {
544+
Init: sharedInit,
545+
NewRequest: func() (*http.Request, error) {
546+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
547+
return http.NewRequest("PUT", "/foo/1", body)
548+
},
549+
ResponseCode: http.StatusUnprocessableEntity,
550+
ResponseBody: `{
551+
"code": 422,
552+
"message": "Document contains error(s)",
553+
"issues": {
554+
"bar": ["required"]
555+
}
556+
}`,
557+
},
558+
`pathID:found,body:valid,required-default:change`: {
559+
Init: sharedInit,
560+
NewRequest: func() (*http.Request, error) {
561+
body := bytes.NewReader([]byte(`{"foo": "baz", "bar": "value"}`))
562+
return http.NewRequest("PUT", "/foo/2", body)
563+
},
564+
ResponseCode: http.StatusOK,
565+
ResponseBody: `{"id": "2", "foo": "baz", "bar": "value"}`,
566+
ExtraTest: checkPayload("foo", "2", map[string]interface{}{"id": "2", "foo": "baz", "bar": "value"}),
567+
},
568+
`pathID:found,body:valid,required-default:delete`: {
569+
Init: sharedInit,
570+
NewRequest: func() (*http.Request, error) {
571+
body := bytes.NewReader([]byte(`{"foo": "baz"}`))
572+
return http.NewRequest("PUT", "/foo/2", body)
573+
},
574+
ResponseCode: http.StatusOK,
575+
ResponseBody: `{"id": "2", "foo": "baz", "bar": "default"}`,
576+
ExtraTest: checkPayload("foo", "2", map[string]interface{}{"id": "2", "foo": "baz", "bar": "default"}),
577+
},
578+
}
579+
580+
for n, tc := range tests {
581+
tc := tc // capture range variable
582+
t.Run(n, tc.Test)
583+
}
584+
}

schema/schema.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ func (s Schema) Prepare(ctx context.Context, payload map[string]interface{}, ori
132132
// previous value as the client would have no way to resubmit the stored value.
133133
if def.Hidden && !def.ReadOnly {
134134
changes[field] = oValue
135+
} else if def.Required && def.Default != nil {
136+
changes[field] = def.Default
135137
} else {
136138
changes[field] = Tombstone
137139
}
@@ -227,7 +229,7 @@ func (s Schema) validate(changes map[string]interface{}, base map[string]interfa
227229
}
228230
// Check required fields.
229231
if def.Required {
230-
if value, found := changes[field]; !found || value == nil {
232+
if value, found := changes[field]; !found || value == nil || value == Tombstone {
231233
if found {
232234
// If explicitly set to null, raise the required error.
233235
addFieldError(errs, field, "required")

0 commit comments

Comments
 (0)