Skip to content

Commit

Permalink
chore(allsrv): refactor v2 tests with table tests
Browse files Browse the repository at this point in the history
There are a few things to note here:

  1. We are making use of table tests. With minimal "relearning" we can
     extend the usecases to accomodate the growing needs of the server.
  2. The test cases make use of some simple helper funcs to make the tests
     more readable. Tests like these act as documentation for future
     contributors. This should not be taken lightly... as the future you,
     will thank you :-).
  3. We make use of a _want func_ here. This might be new to some folks,
     but this is preferred when creating table tests for any UUT that
     returns more than a simple output (i.e. strings/ints/structs). With
     the _want func_, we  get much improved test error stack traces. The entire
     existence of the test is within the usecase. The common test bootstrapping
     is found within the `t.Run` function body, however, it is not a place we
     will find error traces as there is no where for it to fail `:thinker:`.

     We're able to run more assertions/check than what the server responds
     with. For example, checking that the database does not contain a record that should
     not exist.

     However, all that pales in comparison to how much this simplifies the
     logic of the test. You may have run into a situation where table tests
     are paired with a incredibly complex test setup, interleaving multiple
     codepaths for setup and assertions. This is a heavy burden for the next
     person to look at this code. That next person, may be you... look out
     for you.

With the improved test suite, we can make some foundational fixes to align
with JSON-API. The previous implementation did not use a `Data` type for the request body
but with some tests in place, we can validate the desired shape.

Now that we have a solid foundation to build from, we can extend our use case
further to support the read/update/delete APIs. Branch off of this commit
and attempt to add these new APIs.
  • Loading branch information
jsteenb2 committed Jul 10, 2024
1 parent 2bb0590 commit 99c341d
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 144 deletions.
6 changes: 6 additions & 0 deletions allsrv/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
errTypeInvalid
errTypeNotFound
errTypeUnAuthed
errTypeInternal
)

// Err provides a lightly structured error that we can attach behavior. Additionally,
Expand Down Expand Up @@ -39,3 +40,8 @@ func NotFoundErr(msg string, fields ...any) error {
Fields: fields,
}
}

func isErrType(err error, want int) bool {
e, _ := err.(Err)
return err != nil && e.Type == want
}
144 changes: 84 additions & 60 deletions allsrv/server_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ import (
"github.com/hashicorp/go-metrics"
)

func WithMetrics(mets *metrics.Metrics) func(*serverOpts) {
type SvrOptFn func(o *serverOpts)

func WithMetrics(mets *metrics.Metrics) SvrOptFn {
return func(o *serverOpts) {
o.met = mets
}
}

func WithMux(mux *http.ServeMux) func(*serverOpts) {
func WithMux(mux *http.ServeMux) SvrOptFn {
return func(o *serverOpts) {
o.mux = mux
}
}

func WithNowFn(fn func() time.Time) func(*serverOpts) {
func WithNowFn(fn func() time.Time) SvrOptFn {
return func(o *serverOpts) {
o.nowFn = fn
}
Expand All @@ -38,7 +40,7 @@ type ServerV2 struct {
nowFn func() time.Time
}

func NewServerV2(db DB, opts ...func(*serverOpts)) *ServerV2 {
func NewServerV2(db DB, opts ...SvrOptFn) *ServerV2 {
opt := serverOpts{
mux: http.NewServeMux(),
idFn: func() string { return uuid.Must(uuid.NewV4()).String() },
Expand Down Expand Up @@ -76,7 +78,7 @@ func (s *ServerV2) routes() {
withContentTypeJSON := applyMW(contentTypeJSON, s.mw)

// 9)
s.mux.Handle("POST /v1/foos", withContentTypeJSON(jsonIn(http.StatusCreated, s.createFooV1)))
s.mux.Handle("POST /v1/foos", withContentTypeJSON(jsonIn(resourceTypeFoo, http.StatusCreated, s.createFooV1)))
}

func (s *ServerV2) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -90,20 +92,15 @@ type (
// https://jsonapi.org/format/#document-top-level
//
// note: data can be either an array or a single resource object. This allows for both.
RespResourceBody[Attrs any | []any] struct {
Meta RespMeta `json:"meta"`
Errs []RespErr `json:"errors,omitempty"`
Data *RespData[Attrs] `json:"data,omitempty"`
RespResourceBody[Attr Attrs] struct {
Meta RespMeta `json:"meta"`
Errs []RespErr `json:"errors,omitempty"`
Data *Data[Attr] `json:"data,omitempty"`
}

// RespData represents a JSON-API data response.
// https://jsonapi.org/format/#document-top-level
RespData[Attr any | []Attr] struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes Attr `json:"attributes"`

// omitting the relationships here for brevity not at lvl 3 RMM
// Attrs can be either a document or a collection of documents.
Attrs interface {
any | []Attrs
}

// RespMeta represents a JSON-API meta object. The data here is
Expand Down Expand Up @@ -135,30 +132,47 @@ type (
}
)

type (
// ReqCreateFooV1 represents the request body for the create foo API.
ReqCreateFooV1 struct {
Name string `json:"name"`
Note string `json:"note"`
}
// Data represents a JSON-API data response.
//
// https://jsonapi.org/format/#document-top-level
type Data[Attr Attrs] struct {
Type string `json:"type"`
ID string `json:"id"`
Attrs Attr `json:"attributes"`

// FooAttrs are the attributes for foo data.
FooAttrs struct {
Name string `json:"name"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
}
// omitting the relationships here for brevity not at lvl 3 RMM
}

func (d Data[Attr]) getType() string {
return d.Type
}

const (
resourceTypeFoo = "foo"
)

func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (RespData[FooAttrs], []RespErr) {
type ReqCreateFooV1 = Data[FooAttrs]

// FooAttrs are the attributes of a foo resource.
type FooAttrs struct {
Name string `json:"name"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
}

func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (Data[FooAttrs], []RespErr) {
newFoo := Foo{
ID: s.idFn(),
Name: req.Name,
Note: req.Note,
Name: req.Attrs.Name,
Note: req.Attrs.Note,
CreatedAt: s.nowFn(),
}
if err := s.db.CreateFoo(ctx, newFoo); err != nil {
return RespData[FooAttrs]{}, toRespErrs(err)
respErr := toRespErr(err)
if isErrType(err, errTypeExists) {
respErr.Source = &RespErrSource{Pointer: "/data/attributes/name"}
}
return Data[FooAttrs]{}, []RespErr{respErr}
}

out := newFooData(newFoo.ID, FooAttrs{
Expand All @@ -169,29 +183,48 @@ func (s *ServerV2) createFooV1(ctx context.Context, req ReqCreateFooV1) (RespDat
return out, nil
}

func newFooData(id string, attrs FooAttrs) RespData[FooAttrs] {
return RespData[FooAttrs]{
Type: "foo",
ID: id,
Attributes: attrs,
func newFooData(id string, attrs FooAttrs) Data[FooAttrs] {
return Data[FooAttrs]{
Type: resourceTypeFoo,
ID: id,
Attrs: attrs,
}
}

func toTimestamp(t time.Time) string {
return t.Format(time.RFC3339)
}

func jsonIn[ReqBody, Attr any](successCode int, fn func(context.Context, ReqBody) (RespData[Attr], []RespErr)) http.Handler {
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 *RespData[Attr]
out *Data[Attr]
)
if respErr := decodeReq(r, &reqBody); respErr != nil {
errs = append(errs, *respErr)
} else {
var data RespData[Attr]
}
if len(errs) == 0 && reqBody.getType() != resource {
errs = append(errs, 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
Expand Down Expand Up @@ -225,36 +258,27 @@ func decodeReq(r *http.Request, v any) *RespErr {
Code: errTypeInvalid,
}
if unmarshErr := new(json.UnmarshalTypeError); errors.As(err, &unmarshErr) {
respErr.Source.Pointer += "/attributes/" + unmarshErr.Field
respErr.Source.Pointer += "/data"
}
return &respErr
}

return nil
}

func toRespErrs(err error) []RespErr {
if e := new(Err); errors.As(err, e) {
return []RespErr{{
Code: errCode(e),
Msg: e.Msg,
}}
func toRespErr(err error) RespErr {
out := RespErr{
Status: http.StatusInternalServerError,
Code: errTypeInternal,
Msg: err.Error(),
}

errs, ok := err.(interface{ Unwrap() []error })
if !ok {
return nil
}

var out []RespErr
for _, e := range errs.Unwrap() {
out = append(out, toRespErrs(e)...)
if e := new(Err); errors.As(err, e) {
out.Status, out.Code = errStatus(e), e.Type
}

return out
}

func errCode(err *Err) int {
func errStatus(err *Err) int {
switch err.Type {
case errTypeExists:
return http.StatusConflict
Expand Down
Loading

0 comments on commit 99c341d

Please sign in to comment.