From 28b32e195633c3d9447cc5e129204cc4da6c56fe Mon Sep 17 00:00:00 2001 From: Johnny Steenbergen Date: Fri, 19 Jan 2024 03:30:03 -0600 Subject: [PATCH] chore(allsrv): extend ServerV2 API with read/update/delete With this little addition, we're at a place where we have a bit of comfort making changes to the `ServerV2` API. Now that we have a foundation for the `ServerV2` API, we can finally see how it all comes together in the server daemon. --- allsrv/db_inmem.go | 6 + allsrv/server.go | 1 + allsrv/server_v2.go | 178 ++++++++++----- allsrv/server_v2_test.go | 458 ++++++++++++++++++++++++++++++++++----- 4 files changed, 543 insertions(+), 100 deletions(-) diff --git a/allsrv/db_inmem.go b/allsrv/db_inmem.go index dcfce63..1b36f48 100644 --- a/allsrv/db_inmem.go +++ b/allsrv/db_inmem.go @@ -42,6 +42,12 @@ func (db *InmemDB) UpdateFoo(_ context.Context, f Foo) error { db.mu.Lock() defer db.mu.Unlock() + for _, existing := range db.m { + if existing.Name == f.Name && existing.ID != f.ID { + return ExistsErr("foo "+f.Name+" exists", "name", f.Name, "existing_foo_id", existing.ID) // 8) + } + } + for i, existing := range db.m { if f.ID == existing.ID { db.m[i] = f diff --git a/allsrv/server.go b/allsrv/server.go index aebd3f0..a64f7f6 100644 --- a/allsrv/server.go +++ b/allsrv/server.go @@ -143,6 +143,7 @@ type Foo struct { Name string `json:"name" gorm:"name"` Note string `json:"note" gorm:"note"` CreatedAt time.Time `json:"-" gorm:"created_at"` + UpdatedAt time.Time `json:"-" gorm:"updated_at"` } func (s *Server) createFoo(w http.ResponseWriter, r *http.Request) { diff --git a/allsrv/server_v2.go b/allsrv/server_v2.go index 19a60d1..9b8d65c 100644 --- a/allsrv/server_v2.go +++ b/allsrv/server_v2.go @@ -79,6 +79,9 @@ func (s *ServerV2) routes() { // 9) s.mux.Handle("POST /v1/foos", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusCreated, s.createFooV1))) + s.mux.Handle("GET /v1/foos/{id}", s.mw(read(s.readFooV1))) + s.mux.Handle("PATCH /v1/foos", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusOK, s.updateFooV1))) + s.mux.Handle("DELETE /v1/foos/{id}", s.mw(del(s.delFooV1))) } func (s *ServerV2) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -88,11 +91,11 @@ func (s *ServerV2) ServeHTTP(w http.ResponseWriter, r *http.Request) { // API envelope types type ( - // RespResourceBody represents a JSON-API response body. + // RespBody represents a JSON-API response body. // https://jsonapi.org/format/#document-top-level // // note: data can be either an array or a single resource object. This allows for both. - RespResourceBody[Attr Attrs] struct { + RespBody[Attr Attrs] struct { Meta RespMeta `json:"meta"` Errs []RespErr `json:"errors,omitempty"` Data *Data[Attr] `json:"data,omitempty"` @@ -130,6 +133,12 @@ type ( Parameter string `json:"parameter,omitempty"` Header string `json:"header,omitempty"` } + + // ReqBody represents a JSON-API request body. + // https://jsonapi.org/format/#crud-creating + ReqBody[Attr Attrs] struct { + Data Data[Attr] `json:"data"` + } ) // Data represents a JSON-API data response. @@ -151,40 +160,110 @@ const ( resourceTypeFoo = "foo" ) -type ReqCreateFooV1 = Data[FooAttrs] +type ( + ReqCreateFooV1 = ReqBody[FooCreateAttrs] -// FooAttrs are the attributes of a foo resource. -type FooAttrs struct { - Name string `json:"name"` - Note string `json:"note"` - CreatedAt string `json:"created_at"` -} + FooCreateAttrs struct { + Name string `json:"name"` + Note string `json:"note"` + } -func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (Data[FooAttrs], []RespErr) { + // ResourceFooAttrs are the attributes of a foo resource. + ResourceFooAttrs struct { + Name string `json:"name"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } +) + +func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (*Data[ResourceFooAttrs], []RespErr) { + now := s.nowFn() newFoo := Foo{ ID: s.idFn(), - Name: req.Attrs.Name, - Note: req.Attrs.Note, - CreatedAt: s.nowFn(), + Name: req.Data.Attrs.Name, + Note: req.Data.Attrs.Note, + CreatedAt: now, + UpdatedAt: now, } if err := s.db.CreateFoo(ctx, newFoo); err != nil { respErr := toRespErr(err) if isErrType(err, errTypeExists) { respErr.Source = &RespErrSource{Pointer: "/data/attributes/name"} } - return Data[FooAttrs]{}, []RespErr{respErr} + return nil, []RespErr{respErr} + } + + out := fooToData(newFoo) + return &out, nil +} + +func (s *ServerV2) readFooV1(ctx context.Context, r *http.Request) (*Data[ResourceFooAttrs], []RespErr) { + id := r.PathValue("id") + f, err := s.db.ReadFoo(ctx, id) + if err != nil { + return nil, []RespErr{toRespErr(err)} + } + + out := fooToData(f) + return &out, nil +} + +type ( + ReqUpdateFooV1 = ReqBody[FooUpdAttrs] + + FooUpdAttrs struct { + Name *string `json:"name"` + Note *string `json:"note"` + } +) + +func (s *ServerV2) updateFooV1(ctx context.Context, req ReqUpdateFooV1) (*Data[ResourceFooAttrs], []RespErr) { + existing, err := s.db.ReadFoo(ctx, req.Data.ID) + if err != nil { + return nil, []RespErr{toRespErr(err)} + } + + if newName := req.Data.Attrs.Name; newName != nil { + existing.Name = *newName + } + if newNote := req.Data.Attrs.Note; newNote != nil { + existing.Note = *newNote + } + existing.UpdatedAt = s.nowFn() + + err = s.db.UpdateFoo(ctx, existing) + if err != nil { + respErr := toRespErr(err) + if isErrType(err, errTypeExists) { + respErr.Source = &RespErrSource{Pointer: "/data/attributes/name"} + } + return nil, []RespErr{respErr} } - out := newFooData(newFoo.ID, FooAttrs{ - Name: newFoo.Name, - Note: newFoo.Note, - CreatedAt: toTimestamp(newFoo.CreatedAt), + out := fooToData(existing) + return &out, nil +} + +func (s *ServerV2) delFooV1(ctx context.Context, r *http.Request) []RespErr { + id := r.PathValue("id") + if err := s.db.DelFoo(ctx, id); err != nil { + return []RespErr{toRespErr(err)} + } + return nil +} + +func fooToData(f Foo) Data[ResourceFooAttrs] { + return toFooData(f.ID, ResourceFooAttrs{ + Name: f.Name, + Note: f.Note, + CreatedAt: toTimestamp(f.CreatedAt), + UpdatedAt: toTimestamp(f.UpdatedAt), }) - return out, nil } -func newFooData(id string, attrs FooAttrs) Data[FooAttrs] { - return Data[FooAttrs]{ +func toFooData(id string, attrs ResourceFooAttrs) Data[ResourceFooAttrs] { + return Data[ResourceFooAttrs]{ Type: resourceTypeFoo, ID: id, Attrs: attrs, @@ -195,42 +274,41 @@ func toTimestamp(t time.Time) string { return t.Format(time.RFC3339) } -func jsonIn[ - Attr Attrs, - ReqBody interface { - Data[Attr] - // this is limited by go's generics in a big way, which is very unfortunate :-( - // https://github.com/golang/go/issues/48522 - getType() string - }, -](resource string, successCode int, fn func(context.Context, ReqBody) (Data[Attr], []RespErr)) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var ( - reqBody ReqBody - errs []RespErr - out *Data[Attr] - ) +func jsonIn[ReqAttr, RespAttr Attrs](resource string, successCode int, fn func(context.Context, ReqBody[ReqAttr]) (*Data[RespAttr], []RespErr)) http.Handler { + return handler(successCode, func(ctx context.Context, r *http.Request) (*Data[RespAttr], []RespErr) { + var reqBody ReqBody[ReqAttr] if respErr := decodeReq(r, &reqBody); respErr != nil { - errs = append(errs, *respErr) + return nil, []RespErr{*respErr} } - if len(errs) == 0 && reqBody.getType() != resource { - errs = append(errs, RespErr{ + if reqBody.Data.Type != resource { + return nil, []RespErr{{ Status: http.StatusUnprocessableEntity, Code: errTypeInvalid, Msg: "type must be " + resource, Source: &RespErrSource{ Pointer: "/data/type", }, - }) - } - if len(errs) == 0 { - var data Data[Attr] - data, errs = fn(r.Context(), reqBody) - if len(errs) == 0 { - out = &data - } + }} } + return fn(r.Context(), reqBody) + }) +} + +func read[Attr any | []Attr](fn func(ctx context.Context, r *http.Request) (*Data[Attr], []RespErr)) http.Handler { + return handler(http.StatusOK, fn) +} + +func del(fn func(ctx context.Context, r *http.Request) []RespErr) http.Handler { + return handler(http.StatusOK, func(ctx context.Context, r *http.Request) (*Data[any], []RespErr) { + return nil, fn(ctx, r) + }) +} + +func handler[Attr Attrs](successCode int, fn func(ctx context.Context, req *http.Request) (*Data[Attr], []RespErr)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + out, errs := fn(r.Context(), r) + status := successCode for _, e := range errs { if e.Status > status { @@ -239,7 +317,7 @@ func jsonIn[ } w.WriteHeader(status) - json.NewEncoder(w).Encode(RespResourceBody[Attr]{ + json.NewEncoder(w).Encode(RespBody[Attr]{ Meta: getMeta(r.Context()), Errs: errs, Data: out, @@ -297,7 +375,7 @@ func WithBasicAuthV2(adminUser, adminPass string) func(*serverOpts) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if user, pass, ok := r.BasicAuth(); !(ok && user == adminUser && pass == adminPass) { w.WriteHeader(http.StatusUnauthorized) // 9) - json.NewEncoder(w).Encode(RespResourceBody[any]{ + json.NewEncoder(w).Encode(RespBody[any]{ Meta: getMeta(r.Context()), Errs: []RespErr{{ Status: http.StatusUnauthorized, @@ -321,7 +399,7 @@ func contentTypeJSON(next http.Handler) http.Handler { ct := r.Header.Get("Content-Type") if ct != "application/json" { w.WriteHeader(http.StatusUnsupportedMediaType) - json.NewEncoder(w).Encode(RespResourceBody[any]{ + json.NewEncoder(w).Encode(RespBody[any]{ Meta: getMeta(r.Context()), Errs: []RespErr{{ Code: http.StatusUnsupportedMediaType, diff --git a/allsrv/server_v2_test.go b/allsrv/server_v2_test.go index 986bf4e..b09fd51 100644 --- a/allsrv/server_v2_test.go +++ b/allsrv/server_v2_test.go @@ -16,34 +16,60 @@ import ( ) func TestServerV2(t *testing.T) { - start := time.Time{}.Add(time.Hour).UTC() - - t.Run("foo create", func(t *testing.T) { - type ( - inputs struct { - req *http.Request - } + type ( + inputs struct { + req *http.Request + } - wantFn func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) - ) + wantFn func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) - tests := []struct { + testCase struct { name string prepare func(t *testing.T, db allsrv.DB) svrOpts []allsrv.SvrOptFn inputs inputs want wantFn - }{ + } + ) + + start := time.Time{}.Add(time.Hour).UTC() + + testSvr := func(t *testing.T, tt testCase) { + db := new(allsrv.InmemDB) + + if tt.prepare != nil { + tt.prepare(t, db) + } + + defaultOpts := []allsrv.SvrOptFn{ + allsrv.WithIDFn(newIDGen(1, 1)), + allsrv.WithNowFn(nowFn(start, time.Hour)), + allsrv.WithMetrics(newTestMetrics(t)), + } + opts := append(defaultOpts, tt.svrOpts...) + + rec := httptest.NewRecorder() + + svr := allsrv.NewServerV2(db, opts...) + svr.ServeHTTP(rec, tt.inputs.req) + + tt.want(t, rec, db) + } + + t.Run("foo create", func(t *testing.T) { + tests := []testCase{ { name: "when provided a valid foo and authorized user should pass", svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd")}, inputs: inputs{ req: newJSONReq("POST", "/v1/foos", newJSONBody(t, allsrv.ReqCreateFooV1{ - Type: "foo", - Attrs: allsrv.FooAttrs{ - Name: "first-foo", - Note: "some note", + Data: allsrv.Data[allsrv.FooCreateAttrs]{ + Type: "foo", + Attrs: allsrv.FooCreateAttrs{ + Name: "first-foo", + Note: "some note", + }, }, }), withBasicAuth("dodgers@stink.com", "PaSsWoRd"), @@ -51,13 +77,14 @@ func TestServerV2(t *testing.T) { }, want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { assert.Equal(t, http.StatusCreated, rec.Code) - expectData[allsrv.FooAttrs](t, rec.Body, allsrv.Data[allsrv.FooAttrs]{ + expectData[allsrv.ResourceFooAttrs](t, rec.Body, allsrv.Data[allsrv.ResourceFooAttrs]{ Type: "foo", ID: "1", - Attrs: allsrv.FooAttrs{ + Attrs: allsrv.ResourceFooAttrs{ Name: "first-foo", Note: "some note", CreatedAt: start.Format(time.RFC3339), + UpdatedAt: start.Format(time.RFC3339), }, }) @@ -66,6 +93,7 @@ func TestServerV2(t *testing.T) { Name: "first-foo", Note: "some note", CreatedAt: start, + UpdatedAt: start, }) }, }, @@ -75,10 +103,12 @@ func TestServerV2(t *testing.T) { inputs: inputs{ req: newJSONReq("POST", "/v1/foos", newJSONBody(t, allsrv.ReqCreateFooV1{ - Type: "foo", - Attrs: allsrv.FooAttrs{ - Name: "first-foo", - Note: "some note", + Data: allsrv.Data[allsrv.FooCreateAttrs]{ + Type: "foo", + Attrs: allsrv.FooCreateAttrs{ + Name: "first-foo", + Note: "some note", + }, }, }), withBasicAuth("dodgers@stink.com", "WRONGO"), @@ -104,10 +134,12 @@ func TestServerV2(t *testing.T) { prepare: createFoos(allsrv.Foo{ID: "9000", Name: "existing-foo"}), inputs: inputs{ req: newJSONReq("POST", "/v1/foos", newJSONBody(t, allsrv.ReqCreateFooV1{ - Type: "foo", - Attrs: allsrv.FooAttrs{ - Name: "existing-foo", - Note: "some note", + Data: allsrv.Data[allsrv.FooCreateAttrs]{ + Type: "foo", + Attrs: allsrv.FooCreateAttrs{ + Name: "existing-foo", + Note: "some note", + }, }, })), }, @@ -130,10 +162,12 @@ func TestServerV2(t *testing.T) { name: "when creating foo with invalid resource type should fail", inputs: inputs{ req: newJSONReq("POST", "/v1/foos", newJSONBody(t, allsrv.ReqCreateFooV1{ - Type: "WRONGO", - Attrs: allsrv.FooAttrs{ - Name: "first-foo", - Note: "some note", + Data: allsrv.Data[allsrv.FooCreateAttrs]{ + Type: "WRONGO", + Attrs: allsrv.FooCreateAttrs{ + Name: "first-foo", + Note: "some note", + }, }, })), }, @@ -156,25 +190,327 @@ func TestServerV2(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - db := new(allsrv.InmemDB) + testSvr(t, tt) + }) + } + }) - if tt.prepare != nil { - tt.prepare(t, db) - } + t.Run("foo read", func(t *testing.T) { + tests := []testCase{ + { + name: "with authorized user for existing foo should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + UpdatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodogers@fire.dumpster", "truth")}, + inputs: inputs{ + req: get("/v1/foos/1", withBasicAuth("dodogers@fire.dumpster", "truth")), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, _ allsrv.DB) { + assert.Equal(t, http.StatusOK, rec.Code) + expectData[allsrv.ResourceFooAttrs](t, rec.Body, allsrv.Data[allsrv.ResourceFooAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.ResourceFooAttrs{ + Name: "first-foo", + Note: "some note", + CreatedAt: start.Format(time.RFC3339), + UpdatedAt: start.Format(time.RFC3339), + }, + }) + }, + }, + { + name: "with unauthorized user for existing foo should fail", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodogers@fire.dumpster", "truth")}, + inputs: inputs{ + req: get("/v1/foos/1", withBasicAuth("dodogers@are.exellence", "false")), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusUnauthorized, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusUnauthorized, + Code: 4, + Msg: "unauthorized access", + Source: &allsrv.RespErrSource{ + Header: "Authorization", + }, + }) + }, + }, + { + name: "with request for non-existent foo should fail", + inputs: inputs{ + req: get("/v1/foos/1"), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, _ allsrv.DB) { + assert.Equal(t, http.StatusNotFound, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusNotFound, + Code: 3, + Msg: "foo not found for id: 1", + }) + }, + }, + } - defaultOpts := []allsrv.SvrOptFn{ - allsrv.WithIDFn(newIDGen(1, 1)), - allsrv.WithNowFn(newNowFn(start, time.Hour)), - allsrv.WithMetrics(newTestMetrics(t)), - } - opts := append(defaultOpts, tt.svrOpts...) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testSvr(t, tt) + }) + } + }) - rec := httptest.NewRecorder() + t.Run("foo update", func(t *testing.T) { + tests := []testCase{ + { + name: "when provided a full valid update and authorized user should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{ + allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd"), + allsrv.WithNowFn(nowFn(start.Add(time.Hour), time.Hour)), + }, + inputs: inputs{ + req: newJSONReq("PATCH", "/v1/foos", + newJSONBody(t, allsrv.ReqUpdateFooV1{ + Data: allsrv.Data[allsrv.FooUpdAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.FooUpdAttrs{ + Name: ptr("new-name"), + Note: ptr("new note"), + }, + }, + }), + withBasicAuth("dodgers@stink.com", "PaSsWoRd"), + ), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusOK, rec.Code) + expectData[allsrv.ResourceFooAttrs](t, rec.Body, allsrv.Data[allsrv.ResourceFooAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.ResourceFooAttrs{ + Name: "new-name", + Note: "new note", + CreatedAt: start.Format(time.RFC3339), + UpdatedAt: start.Add(time.Hour).Format(time.RFC3339), + }, + }) - svr := allsrv.NewServerV2(db, opts...) - svr.ServeHTTP(rec, tt.inputs.req) + dbHasFoo(t, db, allsrv.Foo{ + ID: "1", + Name: "new-name", + Note: "new note", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + }) + }, + }, + { + name: "when provided a name only update should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-name", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithNowFn(nowFn(start.Add(time.Hour), time.Hour))}, + inputs: inputs{ + req: newJSONReq("PATCH", "/v1/foos", + newJSONBody(t, allsrv.ReqUpdateFooV1{ + Data: allsrv.Data[allsrv.FooUpdAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.FooUpdAttrs{ + Note: ptr("new note"), + }, + }, + }), + withBasicAuth("dodgers@stink.com", "PaSsWoRd"), + ), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusOK, rec.Code) + expectData[allsrv.ResourceFooAttrs](t, rec.Body, allsrv.Data[allsrv.ResourceFooAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.ResourceFooAttrs{ + Name: "first-name", + Note: "new note", + CreatedAt: start.Format(time.RFC3339), + UpdatedAt: start.Add(time.Hour).Format(time.RFC3339), + }, + }) - tt.want(t, rec, db) + dbHasFoo(t, db, allsrv.Foo{ + ID: "1", + Name: "first-name", + Note: "new note", + CreatedAt: start, + UpdatedAt: start.Add(time.Hour), + }) + }, + }, + { + name: "when missing required auth should fail", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodgers@stink.com", "PaSsWoRd")}, + inputs: inputs{ + req: newJSONReq("PATCH", "/v1/foos", + newJSONBody(t, allsrv.ReqUpdateFooV1{ + Data: allsrv.Data[allsrv.FooUpdAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.FooUpdAttrs{ + Note: ptr("new note"), + }, + }, + }), + withBasicAuth("dodgers@stink.com", "WRONGO"), + ), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusUnauthorized, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusUnauthorized, + Code: 4, + Msg: "unauthorized access", + Source: &allsrv.RespErrSource{ + Header: "Authorization", + }, + }) + }, + }, + { + name: "when updating foo too a name that collides with existing should fail", + prepare: createFoos(allsrv.Foo{ID: "1", Name: "start-foo"}, allsrv.Foo{ID: "9000", Name: "existing-foo"}), + inputs: inputs{ + req: newJSONReq("PATCH", "/v1/foos", newJSONBody(t, allsrv.ReqUpdateFooV1{ + Data: allsrv.Data[allsrv.FooUpdAttrs]{ + Type: "foo", + ID: "1", + Attrs: allsrv.FooUpdAttrs{ + Name: ptr("existing-foo"), + Note: ptr("some note"), + }, + }, + })), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusConflict, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusConflict, + Code: 1, + Msg: "foo existing-foo exists", + Source: &allsrv.RespErrSource{ + Pointer: "/data/attributes/name", + }, + }) + + dbHasFoo(t, db, allsrv.Foo{ + ID: "1", + Name: "start-foo", + }) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testSvr(t, tt) + }) + } + }) + + t.Run("foo delete", func(t *testing.T) { + tests := []testCase{ + { + name: "with authorized user for existing foo should pass", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodogers@fire.dumpster", "truth")}, + inputs: inputs{ + req: del("/v1/foos/1", withBasicAuth("dodogers@fire.dumpster", "truth")), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusOK, rec.Code) + expectJSONBody(t, rec.Body, func(t *testing.T, got allsrv.RespBody[any]) { + require.Nil(t, got.Data) + require.Nil(t, got.Errs) + require.NotZero(t, got.Meta.TraceID) + }) + + _, err := db.ReadFoo(context.TODO(), "1") + require.Error(t, err) + }, + }, + { + name: "with unauthorized user for existing foo should fail", + prepare: createFoos(allsrv.Foo{ + ID: "1", + Name: "first-foo", + Note: "some note", + CreatedAt: start, + }), + svrOpts: []allsrv.SvrOptFn{allsrv.WithBasicAuthV2("dodogers@fire.dumpster", "truth")}, + inputs: inputs{ + req: del("/v1/foos/1", withBasicAuth("dodogers@are.exellence", "false")), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, db allsrv.DB) { + assert.Equal(t, http.StatusUnauthorized, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusUnauthorized, + Code: 4, + Msg: "unauthorized access", + Source: &allsrv.RespErrSource{ + Header: "Authorization", + }, + }) + }, + }, + { + name: "with request for non-existent foo should fail", + inputs: inputs{ + req: del("/v1/foos/1"), + }, + want: func(t *testing.T, rec *httptest.ResponseRecorder, _ allsrv.DB) { + assert.Equal(t, http.StatusNotFound, rec.Code) + expectErrs(t, rec.Body, allsrv.RespErr{ + Status: http.StatusNotFound, + Code: 3, + Msg: "foo not found for id: 1", + }) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testSvr(t, tt) }) } }) @@ -183,7 +519,7 @@ func TestServerV2(t *testing.T) { func expectErrs(t *testing.T, r io.Reader, want ...allsrv.RespErr) { t.Helper() - expectJSONBody(t, r, func(t *testing.T, got allsrv.RespResourceBody[any]) { + expectJSONBody(t, r, func(t *testing.T, got allsrv.RespBody[any]) { t.Helper() require.Nil(t, got.Data) @@ -196,7 +532,7 @@ func expectErrs(t *testing.T, r io.Reader, want ...allsrv.RespErr) { func expectData[Attrs any | []any](t *testing.T, r io.Reader, want allsrv.Data[Attrs]) { t.Helper() - expectJSONBody(t, r, func(t *testing.T, got allsrv.RespResourceBody[Attrs]) { + expectJSONBody(t, r, func(t *testing.T, got allsrv.RespBody[Attrs]) { t.Helper() require.Empty(t, got.Errs) @@ -227,12 +563,30 @@ func createFoos(foos ...allsrv.Foo) func(t *testing.T, db allsrv.DB) { } func newJSONReq(method, target string, body io.Reader, opts ...func(*http.Request)) *http.Request { - req := httptest.NewRequest(method, target, body) - req.Header.Set("Content-Type", "application/json") + opts = append([]func(*http.Request){withContentType("application/json")}, opts...) + return newReq(method, target, body, opts...) +} + +func del(target string, opts ...func(*http.Request)) *http.Request { + return newReq("DELETE", target, nil, opts...) +} + +func get(target string, opts ...func(*http.Request)) *http.Request { + return newReq("GET", target, nil, opts...) +} + +func newReq(method, target string, body io.Reader, opts ...func(r *http.Request)) *http.Request { + r := httptest.NewRequest(method, target, body) for _, o := range opts { - o(req) + o(r) + } + return r +} + +func withContentType(ct string) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set("Content-Type", ct) } - return req } func withBasicAuth(user, pass string) func(*http.Request) { @@ -249,10 +603,14 @@ func newIDGen(start, incr int) func() string { } } -func newNowFn(start time.Time, incr time.Duration) func() time.Time { +func nowFn(start time.Time, incr time.Duration) func() time.Time { return func() time.Time { t := start start = start.Add(incr) return t } } + +func ptr[T any](v T) *T { + return &v +}