From c531257922ee24e8e1a1dbf3d0ee4e9edb70c56c Mon Sep 17 00:00:00 2001 From: alexmedeiros Date: Sun, 29 Sep 2019 20:30:41 +1000 Subject: [PATCH 1/2] [WIP] Add lab 10, Puppy REST --- 10_rest/alextmz/README.md | 148 ++++++++++++++++++ 10_rest/alextmz/cmd/puppy-server/main.go | 128 +++++++++++++++ 10_rest/alextmz/cmd/puppy-server/main_test.go | 132 ++++++++++++++++ 10_rest/alextmz/pkg/puppy/errors.go | 50 ++++++ 10_rest/alextmz/pkg/puppy/errors_test.go | 35 +++++ 10_rest/alextmz/pkg/puppy/rest.go | 98 ++++++++++++ 10_rest/alextmz/pkg/puppy/rest_test.go | 9 ++ 10_rest/alextmz/pkg/puppy/store/mapstore.go | 57 +++++++ .../alextmz/pkg/puppy/store/storer_test.go | 132 ++++++++++++++++ 10_rest/alextmz/pkg/puppy/store/syncstore.go | 79 ++++++++++ 10_rest/alextmz/pkg/puppy/types.go | 28 ++++ 10_rest/alextmz/pkg/puppy/types_test.go | 59 +++++++ 10_rest/alextmz/test/invalid-format.json | 1 + 10_rest/alextmz/test/invalid-ids.json | 18 +++ .../alextmz/test/valid-formatted-json.json | 17 ++ 15 files changed, 991 insertions(+) create mode 100644 10_rest/alextmz/README.md create mode 100644 10_rest/alextmz/cmd/puppy-server/main.go create mode 100644 10_rest/alextmz/cmd/puppy-server/main_test.go create mode 100644 10_rest/alextmz/pkg/puppy/errors.go create mode 100644 10_rest/alextmz/pkg/puppy/errors_test.go create mode 100644 10_rest/alextmz/pkg/puppy/rest.go create mode 100644 10_rest/alextmz/pkg/puppy/rest_test.go create mode 100644 10_rest/alextmz/pkg/puppy/store/mapstore.go create mode 100644 10_rest/alextmz/pkg/puppy/store/storer_test.go create mode 100644 10_rest/alextmz/pkg/puppy/store/syncstore.go create mode 100644 10_rest/alextmz/pkg/puppy/types.go create mode 100644 10_rest/alextmz/pkg/puppy/types_test.go create mode 100644 10_rest/alextmz/test/invalid-format.json create mode 100644 10_rest/alextmz/test/invalid-ids.json create mode 100644 10_rest/alextmz/test/valid-formatted-json.json diff --git a/10_rest/alextmz/README.md b/10_rest/alextmz/README.md new file mode 100644 index 000000000..20abf2103 --- /dev/null +++ b/10_rest/alextmz/README.md @@ -0,0 +1,148 @@ +# Table of contents + +- [Introduction](#introduction) +- [API documentation](#api-documentation) + * [Object specification](#object-specification) + * [API requests](#api-requests) + + [```POST /api/puppy/```](#---post--api-puppy----) + + [```GET /api/puppy/{id}```](#---get--api-puppy--id----) + + [```PUT /api/puppy/{id}```](#---put--api-puppy--id----) + + [```DELETE /api/puppy/{id}```](#---delete--api-puppy--id----) +- [Running it](#running-it) + * [Prerequisites](#prerequisites) + * [Build, install, execute](#build--install--execute) + + [Short version](#short-version) + + [Long version](#long-version) + * [Lint, test, coverage](#lint--test--coverage) + +# Introduction + +This is part of the [Go](https://golang.org/) [Course](https://github.com/anz-bank/go-course) done by [ANZ Bank](https://www.anz.com.au) during 2019. + +It contains code to create a basic webserver that serves an REST API over HTTP allowing POST/PUT/GET/DELETE operations on simple objects that can be backed by different storage methods. + +The project is organized as follows: +- `cmd/puppy-server` contains the code for the server executable itself. +- `pkg/puppy` contains the type definitions, interfaces and error values for the package +- `pkg/puppy/store` contains the bulk of the code, 2 separate store backends based on the native Golang map and on sync.map. + +# API documentation +## Object specification +The object Puppy is represented on the API requests by a JSON containing pre-defined fields, of which all are optional when sent by the client, and aways contain at least a valid `id` when sent by the server. Any alien field is ignored. + +In case a pre-defined field is ommitted, it defaults to "" (empty string) for strings, and 0 (zero) for numbers. + +Below, `{id}` means the object identifier on the URL called, and `id` means the object identifier on the JSON. Both values are, or should be, the same. + +The JSON field `id` is special: its value is always supplied by the server. Any value passed on it by the client on any request is either ignored or causes an error, depending on the request type. + +The URL field `{id}` should be a non-zero, positive integer. + +Valid JSON fields are: +> - **id**: Numeric positive integer. +> - **breed**: String +> - **colour**: String +> - **value**: Numeric positive integer + +Example valid JSON: + +```json +{ + "id": 290, + "breed": "Chihuahua", + "colour": "Cream", + "value": 300 +} +``` +## API requests +### ```POST /api/puppy/``` +Creates an object. + +**Input**: Valid JSON on body. If `id` is supplied on the JSON, it is an error for it to be different of 0 (zero). + +**Output** is one of: + + Type | Header | Body | Meaning + ------| -------- | ------ | ------- + Valid | `201 Created` | Puppy JSON | Object created successfully. Returned JSON contains the full object, including the `id` value assigned by the API that can be used to `GET` it. Currently `id` values start at 1 and increment by 1 for each new object created; however, do not rely on this as it may change without warning. + Error | `400 Bad Request` | `400 Bad Request` | `id` value is invalid and/or invalid JSON. No object was created. + +### ```GET /api/puppy/{id}``` +Returns a JSON object specified by `{id}`, representing a valid object existing in storage. + +**Input**: `{id}` on URL only. Request body is ignored. + +**Output** is one of: + + Type | Header | Body | Meaning + ------| -------- | ------ | ------- + Valid | `200 OK` | Valid JSON | Object read successfully. Returned JSON contains the full object. + Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. + Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. + +### ```PUT /api/puppy/{id}``` +Updates an existing object identified by `{id}`. + +**Input**: `{id}` on URL, valid JSON on body. JSON field `id` is ignored if supplied. + +**Output** is one of: + + Type | Header | Body | Meaning + ------| -------- | ------ | ------- + Valid | `200 OK` | `200 OK` | Object updated successfully. + Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was updated. + Error | `400 Bad Request`| `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was updated. + +### ```DELETE /api/puppy/{id}``` +Deletes an existing object identified by `{id}`. + +**Input**: `{id}` on URL. Request body is ignored. + +**Output** is one of: + + Type | Header | Body | Meaning + ------| -------- | ------ | ------- + Valid | `200 OK`| `200 OK` | Object deleted successfully. + Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was deleted. + Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was deleted. + + +# Running it + +## Prerequisites +- Install [`go`](https://golang.org/doc/install) and alternatively [`golangci-lint`](https://github.com/golangci/golangci-lint#local-installation) if you want to run tests or lint +- Clone this project outside your `$GOPATH` to enable [Go Modules](https://github.com/golang/go/wiki/Modules) + +All directory paths mentioned are relative to the root of the project. + +## Build, install, execute + +### Short version +For the anxious, you can just run the main executable quickly doing + + go run ./cmd/puppy-server/main.go + +### Long version +Alternatively, you can build, install and run from your `$GOPATH` with + + go install ./... + puppy-server + +Or yet build and run from the project's root directory with + + go build -o puppy-server cmd/puppy-server/main.go + ./puppy-server + +## Lint, test, coverage + +You can be sure the code adheres to (at least some) good practices by running the linter (alternatively, using -v): + + golangci-lint run + +You can also run the built-in tests with + + go test ./... + +And review the test coverage using the nice Go builtin tool with: + + go test -coverprofile=cover.out ./... && go tool cover -html=cover.out diff --git a/10_rest/alextmz/cmd/puppy-server/main.go b/10_rest/alextmz/cmd/puppy-server/main.go new file mode 100644 index 000000000..f24fc2e88 --- /dev/null +++ b/10_rest/alextmz/cmd/puppy-server/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy/store" + "github.com/go-chi/chi" + "gopkg.in/alecthomas/kingpin.v2" +) + +var ( + args = os.Args[1:] + // Default("./test/valid-formatted-json.json") + flagfile = kingpin.Flag("data", "JSON file to read").Short('d').Required().String() + flagport = kingpin.Flag("port", "TCP port to listen on").Short('p').Required().Uint16() + flagstore = kingpin.Flag("store", "Backing store to use").Short('s').Required().Enum("map", "sync") +) + +var out io.Writer = os.Stdout + +func main() { + + _, err := kingpin.CommandLine.Parse(args) + if err != nil { + fmt.Fprintf(out, "error %s", err.Error()) + kingpin.Usage() + return + } + + // var err error + // defer func() { + // if err != nil { + // log.Fatalln(err) + // } + // }() + + _, err = kingpin.CommandLine.Parse(args) + if err != nil { + // fmt.Fprintf(out, "error!") + // return + // log.Fatalf("Failed to parse args.\n%v", err) + kingpin.FatalUsage("failed to parse arguments: %s, received arguments: %#v", err.Error(), args) + // return + } + + puppies, err := readfile(*flagfile, false) + if err != nil { + fmt.Fprintf(out, "failed to read file %s: %v\n", *flagfile, err) + return + } + + var puppystore puppy.Storer + switch *flagstore { + case "map": + puppystore = store.NewmapStore() + default: + puppystore = store.NewSyncStore() + } + + n, err := storepuppies(puppystore, puppies) + if err != nil { + fmt.Fprintf(out, "%v\n", err) + return + } + fmt.Fprintf(out, "Starting puppyserver with options:\n") + fmt.Fprintf(out, "file = %s\nport = %d\nstore = %s\n", *flagfile, *flagport, *flagstore) + fmt.Fprintf(out, "Loaded %d puppies.\n", n) + //printpuppies(puppystore, n) + h := puppy.HTTPHandler{Store: puppystore} + r := chi.NewRouter() + + puppy.SetupRoutes(r, h) + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(int(*flagport)), r)) +} + +func printpuppies(s puppy.Storer, n int) { + for i := 1; i <= n; i++ { + p, err := s.ReadPuppy(i) + if err != nil { + fmt.Fprintf(out, "%v\n", err) + return + } + fmt.Fprintf(out, "Printing puppy id %d: %#v\n", i, p) + } +} + +func storepuppies(store puppy.Storer, puppies []puppy.Puppy) (int, error) { + n := 0 + for _, v1 := range puppies { + n++ + v := v1 + err := store.CreatePuppy(&v) + if err != nil { + return 0, err + } + } + return n, nil +} + +func readfile(filename string, testing bool) ([]puppy.Puppy, error) { + jsonfile, err := os.Open(filename) + if err != nil { + return []puppy.Puppy{}, err + } + defer jsonfile.Close() + bytes, err := ioutil.ReadAll(jsonfile) + if testing { + err = errors.New("mock error") + } + if err != nil { + return []puppy.Puppy{}, err + } + var puppies []puppy.Puppy + err = json.Unmarshal(bytes, &puppies) + if err != nil { + return []puppy.Puppy{}, err + } + return puppies, nil +} diff --git a/10_rest/alextmz/cmd/puppy-server/main_test.go b/10_rest/alextmz/cmd/puppy-server/main_test.go new file mode 100644 index 000000000..299c6dcc6 --- /dev/null +++ b/10_rest/alextmz/cmd/puppy-server/main_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + args = []string{"-d", "../../test/valid-formatted-json.json", "-p", "7735", "-s", "map"} + // args = []string{""} + os.Exit(m.Run()) +} + +func TestBadArguments(t *testing.T) { + args = []string{"some", "invalid", "arguments"} + // cmd := exec.Command(os.Args[0]) + assert.NotPanics(t, main) + // os.Exit(0) +} + +// cmd.Env = append(os.Environ(), "TEST_MAIN=crasher") +// err := cmd.Run() +// if e, ok := err.(*exec.ExitError); ok && !e.Success() { +// return +// } +// t.Fatalf("process err %v, want exit status 1", err) + +// func TestBadArgs(t *testing.T) { +// var err error +// //os.Exit(0) +// cmd := exec.Command(os.Args[0]) +// out, err := cmd.CombinedOutput() +// sout := string(out) // because out is []byte +// if err != nil && !strings.Contains(sout, "somefunc failed") { +// fmt.Println(sout) // so we can see the full output +// t.Errorf("%v", err) +// } +// } + +// func Test_main(t *testing.M) { +// var tests = map[string]struct { +// params string +// expected string +// }{ +// "Map store": { +// params: "-d ../.../test/valid-formatted-json.json -p 7735 -s map", +// expected: `Starting puppyserver with options: +// file = ./test/valid-formatted-json.json +// port = 7735 +// store = map +// Loaded 3 puppies. +// `}, +// "Sync store": { +// params: "-d ../.../test/valid-formatted-json.json -p 7735 -s sync", +// expected: `Starting puppyserver with options: +// file = ./test/valid-formatted-json.json +// port = 7735 +// store = sync +// Loaded 3 puppies. +// `}, +// } + +// var buf bytes.Buffer +// out = &buf +// for k, v := range tests { +// args = []string{v.params} +// os.Exit(t.Run()) +// actual := buf.String() +// assert. +// assert.Equal(t, tests[k].expected, actual) +// } +// } + +// func Test_nofilegiven(t *testing.T) { +// var buf bytes.Buffer +// out = &buf +// args = []string{"-d", " "} +// main() +// actual := buf.String() +// assert.Equal(t, "open : no such file or directory\n", actual) +// } + +// func Test_invalidflag(t *testing.T) { +// var buf bytes.Buffer +// out = &buf +// args = []string{"-#"} +// main() +// actual := buf.String() +// assert.Equal(t, "unknown short flag '-#'", actual) +// } + +// func Test_invalidpuppyID(t *testing.T) { +// var buf bytes.Buffer +// out = &buf +// args = []string{"-d", "../../test/invalid-ids.json"} +// main() +// actual := buf.String() +// assert.Equal(t, "400 Bad Request\n", actual) +// } + +// func Test_invalidJSON(t *testing.T) { +// var buf bytes.Buffer +// out = &buf +// args = []string{"-d", "../../test/invalid-format.json"} +// main() +// actual := buf.String() +// assert.Equal(t, "json: cannot unmarshal object into Go value of type []puppy.Puppy\n", actual) +// } + +// func Test_printpuppieserr(t *testing.T) { + +// // puppies := []puppy.Puppy{{ID: 10}} +// puppystore := store.NewmapStore() +// printpuppies(puppystore, 1) + +// var buf bytes.Buffer +// out = &buf +// args = []string{"-d", "../../test/invalid-format.json"} +// main() +// actual := buf.String() +// assert.Equal(t, "json: cannot unmarshal object into Go value of type []puppy.Puppy\n", actual) +// } + +// func Test_readerror(t *testing.T) { +// var buf bytes.Buffer +// out = &buf +// args := "../../test/invalid-format.json" +// _, err := readfile(args, true) +// assert.Error(t, err) +// } diff --git a/10_rest/alextmz/pkg/puppy/errors.go b/10_rest/alextmz/pkg/puppy/errors.go new file mode 100644 index 000000000..d823cbdc4 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/errors.go @@ -0,0 +1,50 @@ +package puppy + +type Error struct { + Message string + Code int + CausedBy string +} + +// Not being able to do map consts really trips me. +// There must be a better way. +const ( + Err400BadRequest = 400 + Err404NotFound = 404 + Err409Conflict = 409 + Err500InternalError = 500 + Err501NotImplemented = 501 +) + +const ( + Err400BadRequestS = "400 Bad Request" + Err404NotFoundS = "404 Not Found" + Err409ConflictS = "409 Conflict" + Err500InternalErrorS = "500 Internal Server Error" + Err501NotImplementedS = "501 Not Implemented" +) + +func (e Error) Error() string { + switch e.Code { + case Err400BadRequest: + e.Message = Err400BadRequestS + case Err404NotFound: + e.Message = Err404NotFoundS + case Err409Conflict: + e.Message = Err409ConflictS + case Err500InternalError: + e.Message = Err500InternalErrorS + default: + e.Code = Err501NotImplemented + e.Message = Err501NotImplementedS + } + return e.Message +} + +func NewError(i int) error { + var e Error + e.Code = i + e.CausedBy = "" + e.Message = e.Error() + return e +} diff --git a/10_rest/alextmz/pkg/puppy/errors_test.go b/10_rest/alextmz/pkg/puppy/errors_test.go new file mode 100644 index 000000000..b748e01d5 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/errors_test.go @@ -0,0 +1,35 @@ +package puppy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestError_Error(t *testing.T) { + var tests = []struct { + arg int + want Error + }{ + {arg: 400, want: Error{Message: "400 Bad Request", Code: 400}}, + {arg: Err400BadRequest, want: Error{Message: "400 Bad Request", Code: 400}}, + {arg: 404, want: Error{Message: "404 Not Found", Code: 404}}, + {arg: Err404NotFound, want: Error{Message: "404 Not Found", Code: 404}}, + {arg: 409, want: Error{Message: "409 Conflict", Code: 409}}, + {arg: Err409Conflict, want: Error{Message: "409 Conflict", Code: 409}}, + {arg: 500, want: Error{Message: "500 Internal Server Error", Code: 500}}, + {arg: Err500InternalError, want: Error{Message: "500 Internal Server Error", Code: 500}}, + {arg: 501, want: Error{Message: "501 Not Implemented", Code: 501}}, + {arg: Err501NotImplemented, want: Error{Message: "501 Not Implemented", Code: 501}}, + {arg: 171, want: Error{Message: "501 Not Implemented", Code: 171}}, + {arg: 666, want: Error{Message: "501 Not Implemented", Code: 666}}, + } + + for _, tt := range tests { + test := tt + t.Run(test.want.Message, func(t *testing.T) { + got := NewError(test.arg) + assert.Equal(t, test.want, got) + }) + } +} diff --git a/10_rest/alextmz/pkg/puppy/rest.go b/10_rest/alextmz/pkg/puppy/rest.go new file mode 100644 index 000000000..cd0540ccf --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/rest.go @@ -0,0 +1,98 @@ +package puppy + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi" + "github.com/go-chi/render" +) + +type HTTPHandler struct { + Store Storer +} + +// func NewHTTPHandler(store Storer) HTTPHandler { +// return HTTPHandler{store: store} +// } + +func SetupRoutes(r chi.Router, h HTTPHandler) { + r.Put("/api/puppy/{id}", h.handlePut) + r.Post("/api/puppy/", h.handlePost) + r.Get("/api/puppy/{id}", h.handleGet) + r.Delete("/api/puppy/{id}", h.handleDelete) +} + +func httpWriteStatus(w http.ResponseWriter, c int) { + http.Error(w, strconv.Itoa(c)+" "+http.StatusText(c), c) +} + +// httpWriteIfErr writes the error to w in the format 'code + string' +// and return true. If err is nil, return false. +func httpWriteIfErr(w http.ResponseWriter, err error) bool { + if err != nil { + if v, ok := err.(Error); ok { + http.Error(w, v.Message, v.Code) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } + return true + } + return false +} + +func (ht *HTTPHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if id == 0 || err != nil { + httpWriteStatus(w, http.StatusOK) + return + } + err = ht.Store.DeletePuppy(id) + if !httpWriteIfErr(w, err) { + httpWriteStatus(w, http.StatusOK) + } +} + +func (ht *HTTPHandler) handlePut(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if id == 0 || err != nil { + httpWriteStatus(w, http.StatusOK) + return + } + var puppy Puppy + err = render.DecodeJSON(r.Body, &puppy) + if err != nil { + httpWriteStatus(w, http.StatusOK) + return + } + puppy.ID = id + err = ht.Store.UpdatePuppy(puppy) + if !httpWriteIfErr(w, err) { + httpWriteStatus(w, http.StatusOK) + } +} + +func (ht *HTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) { + var puppy Puppy + if err := render.DecodeJSON(r.Body, &puppy); err != nil { + httpWriteStatus(w, http.StatusCreated) + return + } + err := ht.Store.CreatePuppy(&puppy) + if !httpWriteIfErr(w, err) { + render.JSON(w, r, puppy) + } + +} + +func (ht *HTTPHandler) handleGet(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if id == 0 || err != nil { + httpWriteStatus(w, http.StatusOK) + return + } + puppy, err := ht.Store.ReadPuppy(id) + if !httpWriteIfErr(w, err) { + render.JSON(w, r, puppy) + } +} diff --git a/10_rest/alextmz/pkg/puppy/rest_test.go b/10_rest/alextmz/pkg/puppy/rest_test.go new file mode 100644 index 000000000..be7bf739f --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/rest_test.go @@ -0,0 +1,9 @@ +package puppy + +// func TestSetupRoutes(t *testing.T) { +// var r = new(chi.Router) +// var h = new(HTTPHandler) +// t.Run("SetupRoutes", func(t *testing.T) { +// SetupRoutes(r, h) +// }) +// } diff --git a/10_rest/alextmz/pkg/puppy/store/mapstore.go b/10_rest/alextmz/pkg/puppy/store/mapstore.go new file mode 100644 index 000000000..ede998daa --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/mapstore.go @@ -0,0 +1,57 @@ +package store + +import ( + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" +) + +type MapStore map[int]puppy.Puppy + +func NewmapStore() MapStore { + a := MapStore{} + return a +} + +func (m MapStore) CreatePuppy(p *puppy.Puppy) error { + switch { + case p == nil: + return puppy.NewError(puppy.Err400BadRequest) + case p.ID == 0: + p.ID = len(m) + 1 + m[p.ID] = *p + return nil + default: + return puppy.NewError(puppy.Err400BadRequest) + } +} + +func (m MapStore) ReadPuppy(id int) (puppy.Puppy, error) { + if id < 0 { + return puppy.Puppy{}, puppy.NewError(puppy.Err400BadRequest) + } + if v, ok := m[id]; ok { + return v, nil + } + return puppy.Puppy{}, puppy.NewError(puppy.Err404NotFound) +} + +func (m MapStore) UpdatePuppy(p puppy.Puppy) error { + if p.ID < 0 { + return puppy.NewError(puppy.Err400BadRequest) + } + if _, ok := m[p.ID]; ok { + m[p.ID] = p + return nil + } + return puppy.NewError(puppy.Err404NotFound) +} + +func (m MapStore) DeletePuppy(id int) error { + if id < 0 { + return puppy.NewError(puppy.Err400BadRequest) + } + if _, ok := m[id]; ok { + delete(m, id) + return nil + } + return puppy.NewError(puppy.Err404NotFound) +} diff --git a/10_rest/alextmz/pkg/puppy/store/storer_test.go b/10_rest/alextmz/pkg/puppy/store/storer_test.go new file mode 100644 index 000000000..a514ea7af --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/storer_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "testing" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" + "github.com/stretchr/testify/suite" +) + +type storerSuite struct { + suite.Suite + st puppy.Storer +} + +const ( + IDPuppyDoesNotExist = 99999 + IDPuppyInvalidNegative = -1 +) + +func (su *storerSuite) TestCreatePuppy() { + // can we create without error? + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + // this value copy is here, not below, to guarantee that it is not going + // to be modified before being used on the relevant test + expected := p1 + err := su.st.CreatePuppy(&p1) + su.NoError(err) + // do we error when creating something that already exists? + err = su.st.CreatePuppy(&p1) + su.NotNil(err) + // what we create and what we read back match? + actual, _ := su.st.ReadPuppy(p1.ID) + actual.ID = 0 + su.Equal(expected, actual) + // do we error when trying to create a puppy from a nil pointer? + var p4 *puppy.Puppy + err = su.st.CreatePuppy(p4) + su.NotNil(err) + // do we error when trying to create an already identified Puppy? + p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} + p2.ID = IDPuppyDoesNotExist + err = su.st.CreatePuppy(&p2) + su.NotNil(err) + // do we error when trying to create a puppy that has ID < 0? + p3 := puppy.Puppy{ID: -1, Breed: "Fila", Colour: "Golden", Value: 900} + err = su.st.CreatePuppy(&p3) + su.NotNil(err) + // cleanup + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) +} + +func (su *storerSuite) TestReadPuppy() { + // setup + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + expected := p1 + err := su.st.CreatePuppy(&p1) + su.NoError(err) + // can we read without error? + _, err = su.st.ReadPuppy(p1.ID) + su.NoError(err) + // do we error when reading what doesn't exist? + _, err = su.st.ReadPuppy(IDPuppyDoesNotExist) + su.NotNil(err) + // do the read contents match what we expect? + actual, err := su.st.ReadPuppy(p1.ID) + su.NoError(err) + actual.ID = 0 + su.Equal(expected, actual) + // do we error when trying to read a puppy with ID < 0? + _, err = su.st.ReadPuppy(IDPuppyInvalidNegative) + su.NotNil(err) + // cleanup + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) +} + +func (su *storerSuite) TestUpdatePuppy() { + // setup + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + expected := p1 + err := su.st.CreatePuppy(&p1) + su.NoError(err) + // we can update without error? + expectColour := "Black" + p1.Colour = expectColour + err = su.st.UpdatePuppy(p1) + su.NoError(err) + // updated content matches what we expect? + actual, err := su.st.ReadPuppy(p1.ID) + su.NoError(err) + expected.Colour = expectColour + actual.ID = 0 + su.Equal(expected, actual) + // do we error when trying to update what doesn't exist? + p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} + p2.ID = IDPuppyDoesNotExist + err = su.st.UpdatePuppy(p2) + su.NotNil(err) + // do we error when trying to update a puppy with ID < 0? + p2.ID = IDPuppyInvalidNegative + err = su.st.UpdatePuppy(p2) + su.NotNil(err) + //cleanup + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) +} + +func (su *storerSuite) TestDeletePuppy() { + // setup + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + err := su.st.CreatePuppy(&p1) + su.NoError(err) + // can we delete without error? + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) + // after we delete, can we read the data back? + p, err := su.st.ReadPuppy(p1.ID) + su.NotNil(err) + su.Equal(p, puppy.Puppy{ID: 0, Breed: "", Colour: "", Value: 0}) + // do we err when trying to delete what doesn't exist? + err = su.st.DeletePuppy(IDPuppyDoesNotExist) + su.Error(err) + // do we error when trying to delete a puppy with ID < 0? + err = su.st.DeletePuppy(IDPuppyInvalidNegative) + su.NotNil(err) +} + +func Test_Suite(t *testing.T) { + suite.Run(t, &storerSuite{st: NewSyncStore()}) + suite.Run(t, &storerSuite{st: NewmapStore()}) +} diff --git a/10_rest/alextmz/pkg/puppy/store/syncstore.go b/10_rest/alextmz/pkg/puppy/store/syncstore.go new file mode 100644 index 000000000..021424a13 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/syncstore.go @@ -0,0 +1,79 @@ +package store + +import ( + "sync" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" +) + +type SyncStore struct { + size int + sync.Map + sync.Mutex +} + +func NewSyncStore() *SyncStore { + a := SyncStore{} + return &a +} + +// func CreatePuppy takes +func (m *SyncStore) CreatePuppy(p *puppy.Puppy) error { + switch { + case p == nil: + return puppy.NewError(puppy.Err400BadRequest) + case p.ID == 0: + m.Lock() + defer m.Unlock() + p.ID = m.size + 1 + m.size++ + m.Store(p.ID, *p) + return nil + } + return puppy.NewError(puppy.Err400BadRequest) +} + +func (m *SyncStore) ReadPuppy(id int) (puppy.Puppy, error) { + switch { + case id < 0: + return puppy.Puppy{}, puppy.NewError(puppy.Err400BadRequest) + default: + m.Lock() + defer m.Unlock() + if r, ok := m.Load(id); ok { + puppy := r.(puppy.Puppy) + return puppy, nil + } + return puppy.Puppy{}, puppy.NewError(puppy.Err404NotFound) + } +} + +func (m *SyncStore) UpdatePuppy(p puppy.Puppy) error { + switch { + case p.ID < 0: + return puppy.NewError(puppy.Err400BadRequest) + default: + m.Lock() + defer m.Unlock() + if _, ok := m.Load(p.ID); ok { + m.Store(p.ID, p) + return nil + } + return puppy.NewError(puppy.Err404NotFound) + } +} + +func (m *SyncStore) DeletePuppy(id int) error { + switch { + case id < 0: + return puppy.NewError(puppy.Err400BadRequest) + default: + m.Lock() + defer m.Unlock() + if _, ok := m.Load(id); ok { + m.Delete(id) + return nil + } + return puppy.NewError(puppy.Err404NotFound) + } +} diff --git a/10_rest/alextmz/pkg/puppy/types.go b/10_rest/alextmz/pkg/puppy/types.go new file mode 100644 index 000000000..9b9a222b6 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/types.go @@ -0,0 +1,28 @@ +package puppy + +import "fmt" + +type Puppy struct { + ID int `json:"id"` + Breed string `json:"breed"` + Colour string `json:"colour"` + Value float64 `json:"value"` +} + +// Storer defines standard CRUD operations for Puppies +type Storer interface { + CreatePuppy(*Puppy) error + ReadPuppy(int) (Puppy, error) + UpdatePuppy(Puppy) error + DeletePuppy(int) error +} + +// type StorerHTTPHandler struct { +// store puppy.Storer +// len int +// } + +func (p *Puppy) JSONstr() string { + r := `{"id":%d,"breed":"%s","colour":"%s","value": %.2f}` + return fmt.Sprintf(r, p.ID, p.Breed, p.Colour, p.Value) +} diff --git a/10_rest/alextmz/pkg/puppy/types_test.go b/10_rest/alextmz/pkg/puppy/types_test.go new file mode 100644 index 000000000..11c4363f5 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/types_test.go @@ -0,0 +1,59 @@ +package puppy + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_marshall(t *testing.T) { + var tests typetest + buildTestvar(&tests) + + for name, tt := range tests { + test := tt + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(test.obj) + assert.NoError(t, err) + assert.JSONEq(t, test.jsn, string(got)) + }) + } +} + +func Test_unmarshall(t *testing.T) { + var tests typetest + buildTestvar(&tests) + + for name, tt := range tests { + test := tt + t.Run(name, func(t *testing.T) { + var p Puppy + err := json.Unmarshal([]byte(test.jsn), &p) + assert.NoError(t, err) + assert.JSONEq(t, test.obj.JSONstr(), p.JSONstr()) + }) + } +} + +type typetest map[string]struct { + obj Puppy + jsn string +} + +func buildTestvar(t *typetest) { + *t = typetest{ + "normal puppy": { + Puppy{ID: 1, Breed: "Wolfhound", Colour: "Gray", Value: 50}, + `{"id":1,"breed":"Wolfhound","colour":"Gray","value": 50}`, + }, + "empty puppy": { + Puppy{}, + `{"id":0,"breed":"","colour":"","value":0}`, + }, + "invalid puppy": { + Puppy{Value: -100}, + `{"id":0,"breed":"","colour":"","value":-100}`, + }, + } +} diff --git a/10_rest/alextmz/test/invalid-format.json b/10_rest/alextmz/test/invalid-format.json new file mode 100644 index 000000000..7eea5272d --- /dev/null +++ b/10_rest/alextmz/test/invalid-format.json @@ -0,0 +1 @@ +{"1":{"id":1,"breed":"Dogo","colour":"white","value":50}} \ No newline at end of file diff --git a/10_rest/alextmz/test/invalid-ids.json b/10_rest/alextmz/test/invalid-ids.json new file mode 100644 index 000000000..0f718e9d9 --- /dev/null +++ b/10_rest/alextmz/test/invalid-ids.json @@ -0,0 +1,18 @@ +[ + { + "id": -10, + "breed": "Dogo", + "colour": "White", + "value": 500 + }, + { + "breed": "Mastiff", + "colour": "Brindle", + "value": 700 + }, + { + "breed": "Fila", + "colour": "Golden", + "value": 900 + } +] \ No newline at end of file diff --git a/10_rest/alextmz/test/valid-formatted-json.json b/10_rest/alextmz/test/valid-formatted-json.json new file mode 100644 index 000000000..e8d4e780f --- /dev/null +++ b/10_rest/alextmz/test/valid-formatted-json.json @@ -0,0 +1,17 @@ +[ + { + "breed": "Dogo", + "colour": "White", + "value": 500 + }, + { + "breed": "Mastiff", + "colour": "Brindle", + "value": 700 + }, + { + "breed": "Fila", + "colour": "Golden", + "value": 900 + } +] \ No newline at end of file From 98d56caf7e0a0d0fb7da30b49a6855dc3bb35176 Mon Sep 17 00:00:00 2001 From: alexmedeiros Date: Sun, 29 Sep 2019 22:40:52 +1000 Subject: [PATCH 2/2] Add lab 10, Puppy REST --- 10_rest/alextmz/README.md | 114 ++--- 10_rest/alextmz/cmd/puppy-server/main.go | 126 +++--- 10_rest/alextmz/cmd/puppy-server/main_test.go | 293 +++++++------ 10_rest/alextmz/pkg/puppy/errors.go | 115 +++-- 10_rest/alextmz/pkg/puppy/errors_test.go | 104 ++++- 10_rest/alextmz/pkg/puppy/rest.go | 98 ----- 10_rest/alextmz/pkg/puppy/rest_test.go | 9 - 10_rest/alextmz/pkg/puppy/store/mapstore.go | 81 ++-- .../alextmz/pkg/puppy/store/storer_test.go | 180 +++++--- 10_rest/alextmz/pkg/puppy/store/syncstore.go | 95 +++-- 10_rest/alextmz/pkg/puppy/types.go | 12 - 10_rest/alextmz/pkg/puppy/types_test.go | 65 ++- 10_rest/alextmz/pkg/rest/rest.go | 124 ++++++ 10_rest/alextmz/pkg/rest/rest_test.go | 400 ++++++++++++++++++ 10_rest/alextmz/test/empty-file.json | 1 + 10_rest/alextmz/test/invalid-format.json | 1 - 10_rest/alextmz/test/invalid-ids.json | 3 +- .../alextmz/test/valid-formatted-json.json | 3 +- 18 files changed, 1244 insertions(+), 580 deletions(-) delete mode 100644 10_rest/alextmz/pkg/puppy/rest.go delete mode 100644 10_rest/alextmz/pkg/puppy/rest_test.go create mode 100644 10_rest/alextmz/pkg/rest/rest.go create mode 100644 10_rest/alextmz/pkg/rest/rest_test.go create mode 100644 10_rest/alextmz/test/empty-file.json delete mode 100644 10_rest/alextmz/test/invalid-format.json diff --git a/10_rest/alextmz/README.md b/10_rest/alextmz/README.md index 20abf2103..c970a98c7 100644 --- a/10_rest/alextmz/README.md +++ b/10_rest/alextmz/README.md @@ -2,18 +2,18 @@ - [Introduction](#introduction) - [API documentation](#api-documentation) - * [Object specification](#object-specification) - * [API requests](#api-requests) - + [```POST /api/puppy/```](#---post--api-puppy----) - + [```GET /api/puppy/{id}```](#---get--api-puppy--id----) - + [```PUT /api/puppy/{id}```](#---put--api-puppy--id----) - + [```DELETE /api/puppy/{id}```](#---delete--api-puppy--id----) + - [Object specification](#object-specification) + - [API requests](#api-requests) + - [`POST /api/puppy/`](#---post--api-puppy----) + - [`GET /api/puppy/{id}`](#---get--api-puppy--id----) + - [`PUT /api/puppy/{id}`](#---put--api-puppy--id----) + - [`DELETE /api/puppy/{id}`](#---delete--api-puppy--id----) - [Running it](#running-it) - * [Prerequisites](#prerequisites) - * [Build, install, execute](#build--install--execute) - + [Short version](#short-version) - + [Long version](#long-version) - * [Lint, test, coverage](#lint--test--coverage) + - [Prerequisites](#prerequisites) + - [Build, install, execute](#build--install--execute) + - [Short version](#short-version) + - [Long version](#long-version) + - [Lint, test, coverage](#lint--test--coverage) # Introduction @@ -22,12 +22,15 @@ This is part of the [Go](https://golang.org/) [Course](https://github.com/anz-ba It contains code to create a basic webserver that serves an REST API over HTTP allowing POST/PUT/GET/DELETE operations on simple objects that can be backed by different storage methods. The project is organized as follows: + - `cmd/puppy-server` contains the code for the server executable itself. - `pkg/puppy` contains the type definitions, interfaces and error values for the package - `pkg/puppy/store` contains the bulk of the code, 2 separate store backends based on the native Golang map and on sync.map. # API documentation + ## Object specification + The object Puppy is represented on the API requests by a JSON containing pre-defined fields, of which all are optional when sent by the client, and aways contain at least a valid `id` when sent by the server. Any alien field is ignored. In case a pre-defined field is ommitted, it defaults to "" (empty string) for strings, and 0 (zero) for numbers. @@ -36,110 +39,119 @@ Below, `{id}` means the object identifier on the URL called, and `id` means the The JSON field `id` is special: its value is always supplied by the server. Any value passed on it by the client on any request is either ignored or causes an error, depending on the request type. -The URL field `{id}` should be a non-zero, positive integer. +The URL field `{id}` should be a non-zero, positive integer. Valid JSON fields are: + > - **id**: Numeric positive integer. > - **breed**: String > - **colour**: String > - **value**: Numeric positive integer Example valid JSON: - + ```json { - "id": 290, - "breed": "Chihuahua", - "colour": "Cream", - "value": 300 + "id": 290, + "breed": "Chihuahua", + "colour": "Cream", + "value": 300 } ``` + ## API requests -### ```POST /api/puppy/``` -Creates an object. + +### `POST /api/puppy/` + +Creates an object. **Input**: Valid JSON on body. If `id` is supplied on the JSON, it is an error for it to be different of 0 (zero). **Output** is one of: - Type | Header | Body | Meaning - ------| -------- | ------ | ------- - Valid | `201 Created` | Puppy JSON | Object created successfully. Returned JSON contains the full object, including the `id` value assigned by the API that can be used to `GET` it. Currently `id` values start at 1 and increment by 1 for each new object created; however, do not rely on this as it may change without warning. - Error | `400 Bad Request` | `400 Bad Request` | `id` value is invalid and/or invalid JSON. No object was created. - -### ```GET /api/puppy/{id}``` +| Type | Header | Body | Meaning | +| ----- | ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Valid | `201 Created` | Puppy JSON | Object created successfully. Returned JSON contains the full object, including the `id` value assigned by the API that can be used to `GET` it. Currently `id` values start at 1 and increment by 1 for each new object created; however, do not rely on this as it may change without warning. | +| Error | `400 Bad Request` | `400 Bad Request` | `id` value is supplied (it shouldn't for POST) and/or invalid JSON. No object was created. | + +### `GET /api/puppy/{id}` + Returns a JSON object specified by `{id}`, representing a valid object existing in storage. **Input**: `{id}` on URL only. Request body is ignored. **Output** is one of: - Type | Header | Body | Meaning - ------| -------- | ------ | ------- - Valid | `200 OK` | Valid JSON | Object read successfully. Returned JSON contains the full object. - Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. - Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. +| Type | Header | Body | Meaning | +| ----- | ----------------- | ----------------- | ----------------------------------------------------------------- | +| Valid | `200 OK` | Valid JSON | Object read successfully. Returned JSON contains the full object. | +| Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. | +| Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid (eg. zero or negative) | + +### `PUT /api/puppy/{id}` -### ```PUT /api/puppy/{id}``` Updates an existing object identified by `{id}`. **Input**: `{id}` on URL, valid JSON on body. JSON field `id` is ignored if supplied. **Output** is one of: - Type | Header | Body | Meaning - ------| -------- | ------ | ------- - Valid | `200 OK` | `200 OK` | Object updated successfully. - Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was updated. - Error | `400 Bad Request`| `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was updated. - -### ```DELETE /api/puppy/{id}``` -Deletes an existing object identified by `{id}`. +| Type | Header | Body | Meaning | +| ----- | ----------------- | ----------------- | ---------------------------------------------------------------- | +| Valid | `200 OK` | `200 OK` | Object updated successfully. | +| Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was updated. | +| Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was updated. | + +### `DELETE /api/puppy/{id}` + +Deletes an existing object identified by `{id}`. **Input**: `{id}` on URL. Request body is ignored. **Output** is one of: - Type | Header | Body | Meaning - ------| -------- | ------ | ------- - Valid | `200 OK`| `200 OK` | Object deleted successfully. - Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was deleted. - Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was deleted. - +| Type | Header | Body | Meaning | +| ----- | ----------------- | ----------------- | ---------------------------------------------------------------- | +| Valid | `200 OK` | `200 OK` | Object deleted successfully. | +| Error | `404 Not Found` | `404 Not Found` | Object `{id}` not found. No object was deleted. | +| Error | `400 Bad Request` | `400 Bad Request` | `{id}` value invalid and/or invalid JSON. No object was deleted. | # Running it ## Prerequisites -- Install [`go`](https://golang.org/doc/install) and alternatively [`golangci-lint`](https://github.com/golangci/golangci-lint#local-installation) if you want to run tests or lint -- Clone this project outside your `$GOPATH` to enable [Go Modules](https://github.com/golang/go/wiki/Modules) + +- Install [`go`](https://golang.org/doc/install) and alternatively [`golangci-lint`](https://github.com/golangci/golangci-lint#local-installation) if you want to run tests or lint +- Clone this project outside your `$GOPATH` to enable [Go Modules](https://github.com/golang/go/wiki/Modules) All directory paths mentioned are relative to the root of the project. ## Build, install, execute ### Short version + For the anxious, you can just run the main executable quickly doing go run ./cmd/puppy-server/main.go ### Long version + Alternatively, you can build, install and run from your `$GOPATH` with go install ./... puppy-server -Or yet build and run from the project's root directory with +Or yet build and run from the project's root directory with go build -o puppy-server cmd/puppy-server/main.go ./puppy-server ## Lint, test, coverage -You can be sure the code adheres to (at least some) good practices by running the linter (alternatively, using -v): +You can be sure the code adheres to (at least some) good practices by running the linter (alternatively, using -v): golangci-lint run - -You can also run the built-in tests with + +You can also run the built-in tests with go test ./... diff --git a/10_rest/alextmz/cmd/puppy-server/main.go b/10_rest/alextmz/cmd/puppy-server/main.go index f24fc2e88..c42923859 100644 --- a/10_rest/alextmz/cmd/puppy-server/main.go +++ b/10_rest/alextmz/cmd/puppy-server/main.go @@ -1,87 +1,104 @@ package main import ( + "context" "encoding/json" - "errors" "fmt" "io" "io/ioutil" - "log" "net/http" "os" "strconv" "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy/store" - "github.com/go-chi/chi" + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/rest" "gopkg.in/alecthomas/kingpin.v2" ) var ( - args = os.Args[1:] - // Default("./test/valid-formatted-json.json") - flagfile = kingpin.Flag("data", "JSON file to read").Short('d').Required().String() - flagport = kingpin.Flag("port", "TCP port to listen on").Short('p').Required().Uint16() - flagstore = kingpin.Flag("store", "Backing store to use").Short('s').Required().Enum("map", "sync") -) + args = os.Args[1:] + flagfile = kingpin.Flag("data", "JSON file to read").Short('d').Default("./test/valid-formatted-json.json").String() + flagport = kingpin.Flag("port", "TCP port to listen on").Short('p').Default("7735").Uint16() + flagstore = kingpin.Flag("store", "Backing store to use").Short('s').Default("sync").Enum("map", "sync") -var out io.Writer = os.Stdout + out io.Writer = os.Stdout -func main() { + // shutdownhttp signals main() to... shutdown the http server + shutdownhttp = make(chan bool) - _, err := kingpin.CommandLine.Parse(args) - if err != nil { - fmt.Fprintf(out, "error %s", err.Error()) - kingpin.Usage() + // syncoutput signals whoever interested that main() output is done + syncoutput = make(chan bool, 1) +) + +func main() { + if _, err := kingpin.CommandLine.Parse(args); err != nil { + fmt.Fprintf(out, "error parsing command line: %v\n", err) return } - // var err error - // defer func() { - // if err != nil { - // log.Fatalln(err) - // } - // }() - - _, err = kingpin.CommandLine.Parse(args) + jsonfile, err := os.Open(*flagfile) if err != nil { - // fmt.Fprintf(out, "error!") - // return - // log.Fatalf("Failed to parse args.\n%v", err) - kingpin.FatalUsage("failed to parse arguments: %s, received arguments: %#v", err.Error(), args) - // return + fmt.Fprintf(out, "error opening file: %v\n", err) + return } + defer jsonfile.Close() - puppies, err := readfile(*flagfile, false) + puppies, err := readfile(jsonfile) if err != nil { - fmt.Fprintf(out, "failed to read file %s: %v\n", *flagfile, err) + fmt.Fprintf(out, "error reading JSON file: %v\n", err) return } var puppystore puppy.Storer + switch *flagstore { case "map": - puppystore = store.NewmapStore() - default: + puppystore = store.NewMapStore() + case "sync": puppystore = store.NewSyncStore() } n, err := storepuppies(puppystore, puppies) if err != nil { - fmt.Fprintf(out, "%v\n", err) + fmt.Fprintf(out, "error storing puppies: %v\n", err) return } + fmt.Fprintf(out, "Starting puppyserver with options:\n") fmt.Fprintf(out, "file = %s\nport = %d\nstore = %s\n", *flagfile, *flagport, *flagstore) fmt.Fprintf(out, "Loaded %d puppies.\n", n) - //printpuppies(puppystore, n) - h := puppy.HTTPHandler{Store: puppystore} - r := chi.NewRouter() + printpuppies(puppystore, n) + + h := rest.HTTPHandler{Store: puppystore} + s := http.Server{ + Addr: ":" + strconv.Itoa(int(*flagport)), + Handler: h, + } + + // synchttpshutdown forces main() to wait for http.Shutdown to complete. + // not necessarily needed here, but documented as good practice, so I used it. + synchttpshutdown := make(chan bool) + + // this goroutine just waits blocked for something in the shutdownhttp channel. + // if it gets anything, signals the server to stop gracefully. + go func() { + <-shutdownhttp + _ = s.Shutdown(context.Background()) + close(synchttpshutdown) + }() - puppy.SetupRoutes(r, h) - log.Fatal(http.ListenAndServe(":"+strconv.Itoa(int(*flagport)), r)) + // signals whoever is listening that there is no more + // io.Writer output to be done from main(). + syncoutput <- true + + err = s.ListenAndServe() + if err != nil && err == http.ErrServerClosed { + <-synchttpshutdown + } } +// printpuppies print n puppies contained in the store s func printpuppies(s puppy.Storer, n int) { for i := 1; i <= n; i++ { p, err := s.ReadPuppy(i) @@ -89,40 +106,39 @@ func printpuppies(s puppy.Storer, n int) { fmt.Fprintf(out, "%v\n", err) return } + fmt.Fprintf(out, "Printing puppy id %d: %#v\n", i, p) } } +// storepuppies store all puppies contained in slice 'puppies' +// into the store 'store', returning either (number of puppies stored, nil) +// if there is no error or (0, error) if there was an error. func storepuppies(store puppy.Storer, puppies []puppy.Puppy) (int, error) { - n := 0 - for _, v1 := range puppies { - n++ - v := v1 + for _, v := range puppies { + v := v + err := store.CreatePuppy(&v) if err != nil { return 0, err } } - return n, nil + + return len(puppies), nil } -func readfile(filename string, testing bool) ([]puppy.Puppy, error) { - jsonfile, err := os.Open(filename) - if err != nil { - return []puppy.Puppy{}, err - } - defer jsonfile.Close() - bytes, err := ioutil.ReadAll(jsonfile) - if testing { - err = errors.New("mock error") - } +func readfile(file io.Reader) ([]puppy.Puppy, error) { + bytes, err := ioutil.ReadAll(file) + if err != nil { return []puppy.Puppy{}, err } + var puppies []puppy.Puppy - err = json.Unmarshal(bytes, &puppies) - if err != nil { + + if err = json.Unmarshal(bytes, &puppies); err != nil { return []puppy.Puppy{}, err } + return puppies, nil } diff --git a/10_rest/alextmz/cmd/puppy-server/main_test.go b/10_rest/alextmz/cmd/puppy-server/main_test.go index 299c6dcc6..87a60b105 100644 --- a/10_rest/alextmz/cmd/puppy-server/main_test.go +++ b/10_rest/alextmz/cmd/puppy-server/main_test.go @@ -1,132 +1,185 @@ package main import ( - "os" + "bytes" + "errors" + "io" "testing" + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy/store" "github.com/stretchr/testify/assert" ) -func TestMain(m *testing.M) { - args = []string{"-d", "../../test/valid-formatted-json.json", "-p", "7735", "-s", "map"} - // args = []string{""} - os.Exit(m.Run()) +func TestMainHappyPathSyncStore(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", "../../test/valid-formatted-json.json", "-s", "sync"} + + go main() + // wait for all main() output to be done + <-syncoutput + + // test if main() text output is what we expect + got := buf.String() + want := `Starting puppyserver with options: +file = ../../test/valid-formatted-json.json +port = 7735 +store = sync +Loaded 3 puppies. +Printing puppy id 1: puppy.Puppy{ID:1, Breed:"Dogo", Colour:"White", Value:500} +Printing puppy id 2: puppy.Puppy{ID:2, Breed:"Mastiff", Colour:"Brindle", Value:700} +Printing puppy id 3: puppy.Puppy{ID:3, Breed:"Fila", Colour:"Golden", Value:900} +` + assert.Equal(t, want, got) + + // test if main() is really serving the HTTP we expect + // + // unfortunately, looks like travis-ci does not allow + // connections to 127.0.0.1: we get 'connect: connection refused' + // this works as expected locally. + // leaving the code in-place as it should work. + // + // resp, err := http.Get("http://127.0.0.1:7735/api/puppy/1") + // assert.NoError(t, err) + // defer resp.Body.Close() + // got2, err := ioutil.ReadAll(resp.Body) + // assert.NoError(t, err) + // want2 := "{\"id\":1,\"breed\":\"Dogo\",\"colour\":\"White\",\"value\":500}\n" + // assert.Equal(t, string(got2), want2) + + // signals main() to shutdown the http server + shutdownhttp <- true } -func TestBadArguments(t *testing.T) { - args = []string{"some", "invalid", "arguments"} - // cmd := exec.Command(os.Args[0]) - assert.NotPanics(t, main) - // os.Exit(0) +func TestMainHappyPathMapStore(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", "../../test/valid-formatted-json.json", "-s", "map"} + + go main() + // wait for all main() output to be done + <-syncoutput + + // test if main() text output is what we expect + got := buf.String() + want := `Starting puppyserver with options: +file = ../../test/valid-formatted-json.json +port = 7735 +store = map +Loaded 3 puppies. +Printing puppy id 1: puppy.Puppy{ID:1, Breed:"Dogo", Colour:"White", Value:500} +Printing puppy id 2: puppy.Puppy{ID:2, Breed:"Mastiff", Colour:"Brindle", Value:700} +Printing puppy id 3: puppy.Puppy{ID:3, Breed:"Fila", Colour:"Golden", Value:900} +` + assert.Equal(t, want, got) + + // test if main() is really serving the HTTP we expect + // unfortunately, looks like travis-ci does not allow + // connections to 127.0.0.1: we get 'connect: connection refused' + // this works as expected locally. + // leaving the code in-place as it should work. + // resp, err := http.Get("http://127.0.0.1:7735/api/puppy/1") + // assert.NoError(t, err) + // defer resp.Body.Close() + // got2, err := ioutil.ReadAll(resp.Body) + // assert.NoError(t, err) + // want2 := "{\"id\":1,\"breed\":\"Dogo\",\"colour\":\"White\",\"value\":500}\n" + // assert.Equal(t, string(got2), want2) + + // signals main() to shutdown the http server + shutdownhttp <- true } -// cmd.Env = append(os.Environ(), "TEST_MAIN=crasher") -// err := cmd.Run() -// if e, ok := err.(*exec.ExitError); ok && !e.Success() { -// return -// } -// t.Fatalf("process err %v, want exit status 1", err) - -// func TestBadArgs(t *testing.T) { -// var err error -// //os.Exit(0) -// cmd := exec.Command(os.Args[0]) -// out, err := cmd.CombinedOutput() -// sout := string(out) // because out is []byte -// if err != nil && !strings.Contains(sout, "somefunc failed") { -// fmt.Println(sout) // so we can see the full output -// t.Errorf("%v", err) -// } -// } - -// func Test_main(t *testing.M) { -// var tests = map[string]struct { -// params string -// expected string -// }{ -// "Map store": { -// params: "-d ../.../test/valid-formatted-json.json -p 7735 -s map", -// expected: `Starting puppyserver with options: -// file = ./test/valid-formatted-json.json -// port = 7735 -// store = map -// Loaded 3 puppies. -// `}, -// "Sync store": { -// params: "-d ../.../test/valid-formatted-json.json -p 7735 -s sync", -// expected: `Starting puppyserver with options: -// file = ./test/valid-formatted-json.json -// port = 7735 -// store = sync -// Loaded 3 puppies. -// `}, -// } - -// var buf bytes.Buffer -// out = &buf -// for k, v := range tests { -// args = []string{v.params} -// os.Exit(t.Run()) -// actual := buf.String() -// assert. -// assert.Equal(t, tests[k].expected, actual) -// } -// } - -// func Test_nofilegiven(t *testing.T) { -// var buf bytes.Buffer -// out = &buf -// args = []string{"-d", " "} -// main() -// actual := buf.String() -// assert.Equal(t, "open : no such file or directory\n", actual) -// } - -// func Test_invalidflag(t *testing.T) { -// var buf bytes.Buffer -// out = &buf -// args = []string{"-#"} -// main() -// actual := buf.String() -// assert.Equal(t, "unknown short flag '-#'", actual) -// } - -// func Test_invalidpuppyID(t *testing.T) { -// var buf bytes.Buffer -// out = &buf -// args = []string{"-d", "../../test/invalid-ids.json"} -// main() -// actual := buf.String() -// assert.Equal(t, "400 Bad Request\n", actual) -// } - -// func Test_invalidJSON(t *testing.T) { -// var buf bytes.Buffer -// out = &buf -// args = []string{"-d", "../../test/invalid-format.json"} -// main() -// actual := buf.String() -// assert.Equal(t, "json: cannot unmarshal object into Go value of type []puppy.Puppy\n", actual) -// } - -// func Test_printpuppieserr(t *testing.T) { - -// // puppies := []puppy.Puppy{{ID: 10}} -// puppystore := store.NewmapStore() -// printpuppies(puppystore, 1) - -// var buf bytes.Buffer -// out = &buf -// args = []string{"-d", "../../test/invalid-format.json"} -// main() -// actual := buf.String() -// assert.Equal(t, "json: cannot unmarshal object into Go value of type []puppy.Puppy\n", actual) -// } - -// func Test_readerror(t *testing.T) { -// var buf bytes.Buffer -// out = &buf -// args := "../../test/invalid-format.json" -// _, err := readfile(args, true) -// assert.Error(t, err) -// } +func TestNoFileGiven(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", " "} + + main() + + actual := buf.String() + assert.Equal(t, "error opening file: open : no such file or directory\n", actual) +} + +func TestInvalidFlag(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-#"} + + main() + + actual := buf.String() + assert.Equal(t, "error parsing command line: unknown short flag '-#'\n", actual) +} + +func TestInvalidPuppyID(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", "../../test/invalid-ids.json"} + + main() + + actual := buf.String() + assert.Equal(t, "error storing puppies: puppy already initialized with ID -10\n", actual) +} + +func TestInvalidJSON(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", "../../test/empty-file.json"} + + main() + + actual := buf.String() + assert.Equal(t, "error reading JSON file: unexpected end of JSON input\n", actual) +} + +func TestPrintPuppiesErr(t *testing.T) { + var buf bytes.Buffer + out = &buf + args = []string{"-d", "../../test/empty-file.json"} + puppystore := store.NewMapStore() + + printpuppies(puppystore, 1) + + actual := buf.String() + assert.Equal(t, "puppy with ID 1 being read does not exist\n", actual) +} + +func TestReadError(t *testing.T) { + var r1 readerror + _, err := readfile(r1) + assert.Error(t, err) +} + +func TestReadOk(t *testing.T) { + r2 := newReadOk(`[{"breed":"Dogo","colour":"White","value":500}]`) + _, err := readfile(r2) + assert.NoError(t, err) +} + +type readerror int + +func (readerror) Read(p []byte) (n int, err error) { + return 0, errors.New("always error") +} + +type readok struct { + s string + done bool +} + +func (r *readok) Read(p []byte) (n int, err error) { + if r.done { + return 0, io.EOF + } + + l := copy(p, r.s) + r.done = true + + return l, nil +} + +func newReadOk(s string) *readok { + return &readok{s, false} +} diff --git a/10_rest/alextmz/pkg/puppy/errors.go b/10_rest/alextmz/pkg/puppy/errors.go index d823cbdc4..6437ea5d2 100644 --- a/10_rest/alextmz/pkg/puppy/errors.go +++ b/10_rest/alextmz/pkg/puppy/errors.go @@ -1,50 +1,99 @@ package puppy +import ( + "fmt" +) + type Error struct { - Message string - Code int - CausedBy string + Message string + Code int } -// Not being able to do map consts really trips me. -// There must be a better way. const ( - Err400BadRequest = 400 - Err404NotFound = 404 - Err409Conflict = 409 - Err500InternalError = 500 - Err501NotImplemented = 501 + ErrNilPuppyPointer = iota + ErrNegativePuppyValueOnCreate + ErrNegativePuppyValueOnUpdate + ErrPuppyAlreadyIdentified + ErrPuppyNotFoundOnRead + ErrPuppyNotFoundOnUpdate + ErrPuppyNotFoundOnDelete + ErrNilPuppyPointerStr = "puppy pointer is nil%s" + ErrNegativePuppyValueOnCreateStr = "trying to create a puppy with a negative value%s" + ErrNegativePuppyValueOnUpdateStr = "trying to update a puppy with a negative value%s" + ErrPuppyAlreadyIdentifiedStr = "puppy already initialized%s" + ErrPuppyNotFoundOnReadStr = "puppy%s being read does not exist" + ErrPuppyNotFoundOnUpdateStr = "puppy%s being updated does not exist" + ErrPuppyNotFoundOnDeleteStr = "puppy%s being deleted does not exist" ) -const ( - Err400BadRequestS = "400 Bad Request" - Err404NotFoundS = "404 Not Found" - Err409ConflictS = "409 Conflict" - Err500InternalErrorS = "500 Internal Server Error" - Err501NotImplementedS = "501 Not Implemented" -) +// errorCodeDescription returns the verbose error description corresponding +// to a known/static error Code, allowing for an optional parameter to be passed +// for a more verbose error'ing. +// Passing an empty string makes it return the default error string only. +func (e Error) errorCodeDescription(param string) string { + errormap := map[int]struct { + errmsg, paramprefix, paramsuffix string + }{ + ErrNilPuppyPointer: {ErrNilPuppyPointerStr, "", ""}, + ErrNegativePuppyValueOnCreate: {ErrNegativePuppyValueOnCreateStr, " (", ")"}, + ErrNegativePuppyValueOnUpdate: {ErrNegativePuppyValueOnUpdateStr, " (", ")"}, + ErrPuppyAlreadyIdentified: {ErrPuppyAlreadyIdentifiedStr, " with ID ", ""}, + ErrPuppyNotFoundOnRead: {ErrPuppyNotFoundOnReadStr, " with ID ", ""}, + ErrPuppyNotFoundOnUpdate: {ErrPuppyNotFoundOnUpdateStr, " with ID ", ""}, + ErrPuppyNotFoundOnDelete: {ErrPuppyNotFoundOnDeleteStr, " with ID ", ""}, + } + + v, ok := errormap[e.Code] + if !ok { + return "undefined error" + } + + if param != "" { + return fmt.Sprintf(v.errmsg, v.paramprefix+param+v.paramsuffix) + } + + return fmt.Sprintf(v.errmsg, "") +} func (e Error) Error() string { - switch e.Code { - case Err400BadRequest: - e.Message = Err400BadRequestS - case Err404NotFound: - e.Message = Err404NotFoundS - case Err409Conflict: - e.Message = Err409ConflictS - case Err500InternalError: - e.Message = Err500InternalErrorS + if e.Message != "" { + return e.Message + } + + return fmt.Sprint(e.errorCodeDescription("")) +} + +// Errorp returns the known parametrized (verbose) error string for +// a given error code. +func Errorp(err int, param interface{}) Error { + e := Error{Code: err} + + switch v := param.(type) { + case string: + e.Message = e.errorCodeDescription(v) + case int: + e.Message = e.errorCodeDescription(fmt.Sprintf("%d", param)) + case float64: + e.Message = e.errorCodeDescription(fmt.Sprintf("%.2f", param)) default: - e.Code = Err501NotImplemented - e.Message = Err501NotImplementedS + panic("not implemented: param type is not either string, int or float") } - return e.Message + + return e } -func NewError(i int) error { +func NewError(err int, m string) Error { var e Error - e.Code = i - e.CausedBy = "" - e.Message = e.Error() + e.Code = err + e.Message = m + + return e +} + +func NewErrorf(err int, f string, m ...interface{}) Error { + var e Error + e.Code = err + e.Message = fmt.Sprintf(f, m...) + return e } diff --git a/10_rest/alextmz/pkg/puppy/errors_test.go b/10_rest/alextmz/pkg/puppy/errors_test.go index b748e01d5..ddbeefa56 100644 --- a/10_rest/alextmz/pkg/puppy/errors_test.go +++ b/10_rest/alextmz/pkg/puppy/errors_test.go @@ -6,30 +6,94 @@ import ( "github.com/stretchr/testify/assert" ) -func TestError_Error(t *testing.T) { - var tests = []struct { - arg int - want Error +func TestKnownErrorCodeDescr(t *testing.T) { + var tests = map[string]struct { + code int + parameter string + want string }{ - {arg: 400, want: Error{Message: "400 Bad Request", Code: 400}}, - {arg: Err400BadRequest, want: Error{Message: "400 Bad Request", Code: 400}}, - {arg: 404, want: Error{Message: "404 Not Found", Code: 404}}, - {arg: Err404NotFound, want: Error{Message: "404 Not Found", Code: 404}}, - {arg: 409, want: Error{Message: "409 Conflict", Code: 409}}, - {arg: Err409Conflict, want: Error{Message: "409 Conflict", Code: 409}}, - {arg: 500, want: Error{Message: "500 Internal Server Error", Code: 500}}, - {arg: Err500InternalError, want: Error{Message: "500 Internal Server Error", Code: 500}}, - {arg: 501, want: Error{Message: "501 Not Implemented", Code: 501}}, - {arg: Err501NotImplemented, want: Error{Message: "501 Not Implemented", Code: 501}}, - {arg: 171, want: Error{Message: "501 Not Implemented", Code: 171}}, - {arg: 666, want: Error{Message: "501 Not Implemented", Code: 666}}, + "ErrNilPuppyPointer": {ErrNilPuppyPointer, "", + "puppy pointer is nil"}, + "ErrNegativePuppyValueOnCreate": {ErrNegativePuppyValueOnCreate, "", + "trying to create a puppy with a negative value"}, + "ErrNegativePuppyValueOnCreate with parameter": {ErrNegativePuppyValueOnCreate, "-555.55", + "trying to create a puppy with a negative value (-555.55)"}, + "ErrNegativePuppyValueOnUpdate": {ErrNegativePuppyValueOnUpdate, "", + "trying to update a puppy with a negative value"}, + "ErrNegativePuppyValueOnUpdate with parameter": {ErrNegativePuppyValueOnUpdate, "-22.22", + "trying to update a puppy with a negative value (-22.22)"}, + "ErrPuppyAlreadyIdentified": {ErrPuppyAlreadyIdentified, "", + "puppy already initialized"}, + "ErrPuppyAlreadyIdentified with parameter": {ErrPuppyAlreadyIdentified, "42", + "puppy already initialized with ID 42"}, + "ErrPuppyNotFoundOnRead": {ErrPuppyNotFoundOnRead, "", + "puppy being read does not exist"}, + "ErrPuppyNotFoundOnRead with parameter": {ErrPuppyNotFoundOnRead, "11", + "puppy with ID 11 being read does not exist"}, + "ErrPuppyNotFoundOnUpdate": {ErrPuppyNotFoundOnUpdate, "", + "puppy being updated does not exist"}, + "ErrPuppyNotFoundOnUpdate with parameter": {ErrPuppyNotFoundOnUpdate, "22", + "puppy with ID 22 being updated does not exist"}, + "ErrPuppyNotFoundOnDelete": {ErrPuppyNotFoundOnDelete, "", + "puppy being deleted does not exist"}, + "ErrPuppyNotFoundOnDelete with parameter": {ErrPuppyNotFoundOnDelete, "33", + "puppy with ID 33 being deleted does not exist"}, + "UnknownErrorCode": { + 61621, "", + "undefined error"}, } - for _, tt := range tests { - test := tt - t.Run(test.want.Message, func(t *testing.T) { - got := NewError(test.arg) + for name, test := range tests { + test := test + + t.Run(name, func(t *testing.T) { + e := Error{Code: test.code} + got := e.errorCodeDescription(test.parameter) assert.Equal(t, test.want, got) }) } } + +func TestError(t *testing.T) { + e := Error{Code: ErrNilPuppyPointer} + assert.Equal(t, "puppy pointer is nil", e.Error()) + e = Error{Code: ErrPuppyNotFoundOnDelete} + assert.Equal(t, "puppy being deleted does not exist", e.Error()) +} + +func TestErrorp(t *testing.T) { + // test empyy parameters + e := Errorp(ErrNilPuppyPointer, "") + assert.Equal(t, "puppy pointer is nil", e.Error()) + // test string parameters + e = Errorp(ErrPuppyNotFoundOnDelete, "999") + assert.Equal(t, "puppy with ID 999 being deleted does not exist", e.Error()) + // test int parameters + e = Errorp(ErrPuppyNotFoundOnDelete, 999) + assert.Equal(t, "puppy with ID 999 being deleted does not exist", e.Error()) + // test float parameters + e = Errorp(ErrPuppyNotFoundOnDelete, 9.99) + assert.Equal(t, "puppy with ID 9.99 being deleted does not exist", e.Error()) + // test unimplemented parameter type + assert.Panics(t, func() { + _ = Errorp(ErrPuppyNotFoundOnDelete, true) + }) +} + +func TestNewError(t *testing.T) { + e1 := NewError(999, "error 999") + + var e2 Error + e2.Code = 999 + e2.Message = "error 999" + assert.Equal(t, e2, e1) +} + +func TestNewErrorf(t *testing.T) { + e1 := NewErrorf(999, "error %d", 999) + + var e2 Error + e2.Code = 999 + e2.Message = "error 999" + assert.Equal(t, e2, e1) +} diff --git a/10_rest/alextmz/pkg/puppy/rest.go b/10_rest/alextmz/pkg/puppy/rest.go deleted file mode 100644 index cd0540ccf..000000000 --- a/10_rest/alextmz/pkg/puppy/rest.go +++ /dev/null @@ -1,98 +0,0 @@ -package puppy - -import ( - "net/http" - "strconv" - - "github.com/go-chi/chi" - "github.com/go-chi/render" -) - -type HTTPHandler struct { - Store Storer -} - -// func NewHTTPHandler(store Storer) HTTPHandler { -// return HTTPHandler{store: store} -// } - -func SetupRoutes(r chi.Router, h HTTPHandler) { - r.Put("/api/puppy/{id}", h.handlePut) - r.Post("/api/puppy/", h.handlePost) - r.Get("/api/puppy/{id}", h.handleGet) - r.Delete("/api/puppy/{id}", h.handleDelete) -} - -func httpWriteStatus(w http.ResponseWriter, c int) { - http.Error(w, strconv.Itoa(c)+" "+http.StatusText(c), c) -} - -// httpWriteIfErr writes the error to w in the format 'code + string' -// and return true. If err is nil, return false. -func httpWriteIfErr(w http.ResponseWriter, err error) bool { - if err != nil { - if v, ok := err.(Error); ok { - http.Error(w, v.Message, v.Code) - } else { - http.Error(w, err.Error(), http.StatusBadRequest) - } - return true - } - return false -} - -func (ht *HTTPHandler) handleDelete(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if id == 0 || err != nil { - httpWriteStatus(w, http.StatusOK) - return - } - err = ht.Store.DeletePuppy(id) - if !httpWriteIfErr(w, err) { - httpWriteStatus(w, http.StatusOK) - } -} - -func (ht *HTTPHandler) handlePut(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if id == 0 || err != nil { - httpWriteStatus(w, http.StatusOK) - return - } - var puppy Puppy - err = render.DecodeJSON(r.Body, &puppy) - if err != nil { - httpWriteStatus(w, http.StatusOK) - return - } - puppy.ID = id - err = ht.Store.UpdatePuppy(puppy) - if !httpWriteIfErr(w, err) { - httpWriteStatus(w, http.StatusOK) - } -} - -func (ht *HTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) { - var puppy Puppy - if err := render.DecodeJSON(r.Body, &puppy); err != nil { - httpWriteStatus(w, http.StatusCreated) - return - } - err := ht.Store.CreatePuppy(&puppy) - if !httpWriteIfErr(w, err) { - render.JSON(w, r, puppy) - } - -} - -func (ht *HTTPHandler) handleGet(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(chi.URLParam(r, "id")) - if id == 0 || err != nil { - httpWriteStatus(w, http.StatusOK) - return - } - puppy, err := ht.Store.ReadPuppy(id) - if !httpWriteIfErr(w, err) { - render.JSON(w, r, puppy) - } -} diff --git a/10_rest/alextmz/pkg/puppy/rest_test.go b/10_rest/alextmz/pkg/puppy/rest_test.go deleted file mode 100644 index be7bf739f..000000000 --- a/10_rest/alextmz/pkg/puppy/rest_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package puppy - -// func TestSetupRoutes(t *testing.T) { -// var r = new(chi.Router) -// var h = new(HTTPHandler) -// t.Run("SetupRoutes", func(t *testing.T) { -// SetupRoutes(r, h) -// }) -// } diff --git a/10_rest/alextmz/pkg/puppy/store/mapstore.go b/10_rest/alextmz/pkg/puppy/store/mapstore.go index ede998daa..0dd8888fc 100644 --- a/10_rest/alextmz/pkg/puppy/store/mapstore.go +++ b/10_rest/alextmz/pkg/puppy/store/mapstore.go @@ -4,54 +4,63 @@ import ( "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" ) -type MapStore map[int]puppy.Puppy +type MapStore struct { + pmap map[int]puppy.Puppy + nextID int +} -func NewmapStore() MapStore { - a := MapStore{} - return a +func NewMapStore() *MapStore { + return &MapStore{pmap: make(map[int]puppy.Puppy)} } -func (m MapStore) CreatePuppy(p *puppy.Puppy) error { - switch { - case p == nil: - return puppy.NewError(puppy.Err400BadRequest) - case p.ID == 0: - p.ID = len(m) + 1 - m[p.ID] = *p - return nil - default: - return puppy.NewError(puppy.Err400BadRequest) +func (m *MapStore) CreatePuppy(p *puppy.Puppy) error { + if p == nil { + return puppy.Error{Code: puppy.ErrNilPuppyPointer} } -} -func (m MapStore) ReadPuppy(id int) (puppy.Puppy, error) { - if id < 0 { - return puppy.Puppy{}, puppy.NewError(puppy.Err400BadRequest) + if p.Value < 0 { + return puppy.Errorp(puppy.ErrNegativePuppyValueOnCreate, p.Value) } - if v, ok := m[id]; ok { - return v, nil + + if p.ID != 0 { + return puppy.Errorp(puppy.ErrPuppyAlreadyIdentified, p.ID) } - return puppy.Puppy{}, puppy.NewError(puppy.Err404NotFound) + m.nextID++ + p.ID = m.nextID + m.pmap[p.ID] = *p + + return nil } -func (m MapStore) UpdatePuppy(p puppy.Puppy) error { - if p.ID < 0 { - return puppy.NewError(puppy.Err400BadRequest) +func (m *MapStore) ReadPuppy(id int) (puppy.Puppy, error) { + v, ok := m.pmap[id] + if !ok { + return puppy.Puppy{}, puppy.Errorp(puppy.ErrPuppyNotFoundOnRead, id) } - if _, ok := m[p.ID]; ok { - m[p.ID] = p - return nil - } - return puppy.NewError(puppy.Err404NotFound) + + return v, nil } -func (m MapStore) DeletePuppy(id int) error { - if id < 0 { - return puppy.NewError(puppy.Err400BadRequest) +func (m *MapStore) UpdatePuppy(p puppy.Puppy) error { + if _, ok := m.pmap[p.ID]; !ok { + return puppy.Errorp(puppy.ErrPuppyNotFoundOnUpdate, p.ID) + } + + if p.Value < 0 { + return puppy.Errorp(puppy.ErrNegativePuppyValueOnUpdate, p.Value) } - if _, ok := m[id]; ok { - delete(m, id) - return nil + + m.pmap[p.ID] = p + + return nil +} + +func (m *MapStore) DeletePuppy(id int) error { + if _, ok := m.pmap[id]; !ok { + return puppy.Errorp(puppy.ErrPuppyNotFoundOnDelete, id) } - return puppy.NewError(puppy.Err404NotFound) + + delete(m.pmap, id) + + return nil } diff --git a/10_rest/alextmz/pkg/puppy/store/storer_test.go b/10_rest/alextmz/pkg/puppy/store/storer_test.go index a514ea7af..aa83ab2c0 100644 --- a/10_rest/alextmz/pkg/puppy/store/storer_test.go +++ b/10_rest/alextmz/pkg/puppy/store/storer_test.go @@ -12,39 +12,56 @@ type storerSuite struct { st puppy.Storer } -const ( - IDPuppyDoesNotExist = 99999 - IDPuppyInvalidNegative = -1 -) - func (su *storerSuite) TestCreatePuppy() { // can we create without error? p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} - // this value copy is here, not below, to guarantee that it is not going - // to be modified before being used on the relevant test expected := p1 err := su.st.CreatePuppy(&p1) su.NoError(err) + + // do we increase the ID correctly? + su.Run("PuppyIDIncreases", func() { + p2 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + err = su.st.CreatePuppy(&p2) + su.Equal(p1.ID+1, p2.ID) + }) + // do we error when creating something that already exists? - err = su.st.CreatePuppy(&p1) - su.NotNil(err) + su.Run("NoErrorOnCreate", func() { + err = su.st.CreatePuppy(&p1) + su.Error(err) + }) + // what we create and what we read back match? - actual, _ := su.st.ReadPuppy(p1.ID) - actual.ID = 0 - su.Equal(expected, actual) + su.Run("MatchCreatedAndRead", func() { + actual, _ := su.st.ReadPuppy(p1.ID) + actual.ID = 0 + su.Equal(expected, actual) + }) + // do we error when trying to create a puppy from a nil pointer? - var p4 *puppy.Puppy - err = su.st.CreatePuppy(p4) - su.NotNil(err) + su.Run("ErrorNilPuppy", func() { + var p4 *puppy.Puppy + err = su.st.CreatePuppy(p4) + su.Error(err) + }) + // do we error when trying to create an already identified Puppy? - p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} - p2.ID = IDPuppyDoesNotExist - err = su.st.CreatePuppy(&p2) - su.NotNil(err) - // do we error when trying to create a puppy that has ID < 0? - p3 := puppy.Puppy{ID: -1, Breed: "Fila", Colour: "Golden", Value: 900} - err = su.st.CreatePuppy(&p3) - su.NotNil(err) + su.Run("ErrorAlreadyIdentifiedPuppy", func() { + p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} + p2.ID = 99999 + err = su.st.CreatePuppy(&p2) + su.Error(err) + }) + + // do we error when trying to create a puppy with Value < 0? + su.Run("ErrorNegativeValue", func() { + p3 := puppy.Puppy{Breed: "Fila", Colour: "Golden", Value: -900} + err = su.st.CreatePuppy(&p3) + su.Error(err) + su.Require().IsType(puppy.Error{}, err) + }) + // cleanup err = su.st.DeletePuppy(p1.ID) su.NoError(err) @@ -56,20 +73,27 @@ func (su *storerSuite) TestReadPuppy() { expected := p1 err := su.st.CreatePuppy(&p1) su.NoError(err) + // can we read without error? - _, err = su.st.ReadPuppy(p1.ID) - su.NoError(err) + su.Run("NoErrorRead", func() { + _, err = su.st.ReadPuppy(p1.ID) + su.NoError(err) + }) + // do we error when reading what doesn't exist? - _, err = su.st.ReadPuppy(IDPuppyDoesNotExist) - su.NotNil(err) + su.Run("ErrorPuppyDoesNotExist", func() { + _, err = su.st.ReadPuppy(99999) + su.Error(err) + }) + // do the read contents match what we expect? - actual, err := su.st.ReadPuppy(p1.ID) - su.NoError(err) - actual.ID = 0 - su.Equal(expected, actual) - // do we error when trying to read a puppy with ID < 0? - _, err = su.st.ReadPuppy(IDPuppyInvalidNegative) - su.NotNil(err) + su.Run("NoErrorReadPuppyMatches", func() { + actual, err := su.st.ReadPuppy(p1.ID) + su.NoError(err) + actual.ID = 0 + su.Equal(expected, actual) + }) + // cleanup err = su.st.DeletePuppy(p1.ID) su.NoError(err) @@ -81,26 +105,43 @@ func (su *storerSuite) TestUpdatePuppy() { expected := p1 err := su.st.CreatePuppy(&p1) su.NoError(err) - // we can update without error? - expectColour := "Black" - p1.Colour = expectColour - err = su.st.UpdatePuppy(p1) + + p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} + err = su.st.CreatePuppy(&p2) su.NoError(err) + + // we can update without error? + su.Run("NoErrorOnUpdate", func() { + p1.Colour = "Black" + err = su.st.UpdatePuppy(p1) + su.NoError(err) + }) + // updated content matches what we expect? - actual, err := su.st.ReadPuppy(p1.ID) - su.NoError(err) - expected.Colour = expectColour - actual.ID = 0 - su.Equal(expected, actual) + su.Run("MatchUpdatedPuppy", func() { + actual, err := su.st.ReadPuppy(p1.ID) + su.NoError(err) + expected.Colour = "Black" + actual.ID = 0 + su.Equal(expected, actual) + }) + + // do we error when trying to update a puppy with Value < 0? + su.Run("ErrorUpdateNegativeValue", func() { + p2.Value = -10 + err = su.st.UpdatePuppy(p2) + su.Error(err) + su.Require().IsType(puppy.Error{}, err) + }) + // do we error when trying to update what doesn't exist? - p2 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} - p2.ID = IDPuppyDoesNotExist - err = su.st.UpdatePuppy(p2) - su.NotNil(err) - // do we error when trying to update a puppy with ID < 0? - p2.ID = IDPuppyInvalidNegative - err = su.st.UpdatePuppy(p2) - su.NotNil(err) + su.Run("ErrorUpdateNonexistentPuppy", func() { + p3 := puppy.Puppy{Breed: "Mastiff", Colour: "Brindle", Value: 700} + p3.ID = 99999 + err = su.st.UpdatePuppy(p3) + su.Error(err) + }) + //cleanup err = su.st.DeletePuppy(p1.ID) su.NoError(err) @@ -111,22 +152,33 @@ func (su *storerSuite) TestDeletePuppy() { p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} err := su.st.CreatePuppy(&p1) su.NoError(err) + // can we delete without error? - err = su.st.DeletePuppy(p1.ID) - su.NoError(err) + su.Run("DeleteWithNoError", func() { + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) + }) + // after we delete, can we read the data back? - p, err := su.st.ReadPuppy(p1.ID) - su.NotNil(err) - su.Equal(p, puppy.Puppy{ID: 0, Breed: "", Colour: "", Value: 0}) + su.Run("ErrorReadDeletedPuppy", func() { + p, err := su.st.ReadPuppy(p1.ID) + su.Error(err) + su.Equal(p, puppy.Puppy{ID: 0, Breed: "", Colour: "", Value: 0}) + }) + // do we err when trying to delete what doesn't exist? - err = su.st.DeletePuppy(IDPuppyDoesNotExist) - su.Error(err) - // do we error when trying to delete a puppy with ID < 0? - err = su.st.DeletePuppy(IDPuppyInvalidNegative) - su.NotNil(err) -} + su.Run("ErrorDeleteNonexistentPuppy", func() { + err = su.st.DeletePuppy(99999) + su.Error(err) + }) +} // no cleanup needed: all data taken care of already. func Test_Suite(t *testing.T) { - suite.Run(t, &storerSuite{st: NewSyncStore()}) - suite.Run(t, &storerSuite{st: NewmapStore()}) + t.Run("SyncStore", func(t *testing.T) { + suite.Run(t, &storerSuite{st: NewSyncStore()}) + }) + + t.Run("MapStore", func(t *testing.T) { + suite.Run(t, &storerSuite{st: NewMapStore()}) + }) } diff --git a/10_rest/alextmz/pkg/puppy/store/syncstore.go b/10_rest/alextmz/pkg/puppy/store/syncstore.go index 021424a13..4dedb5012 100644 --- a/10_rest/alextmz/pkg/puppy/store/syncstore.go +++ b/10_rest/alextmz/pkg/puppy/store/syncstore.go @@ -7,7 +7,7 @@ import ( ) type SyncStore struct { - size int + nextID int sync.Map sync.Mutex } @@ -17,63 +17,66 @@ func NewSyncStore() *SyncStore { return &a } -// func CreatePuppy takes func (m *SyncStore) CreatePuppy(p *puppy.Puppy) error { - switch { - case p == nil: - return puppy.NewError(puppy.Err400BadRequest) - case p.ID == 0: - m.Lock() - defer m.Unlock() - p.ID = m.size + 1 - m.size++ - m.Store(p.ID, *p) - return nil + if p == nil { + return puppy.Error{Code: puppy.ErrNilPuppyPointer} } - return puppy.NewError(puppy.Err400BadRequest) + + if p.Value < 0 { + return puppy.Errorp(puppy.ErrNegativePuppyValueOnCreate, p.Value) + } + + if p.ID != 0 { + return puppy.Errorp(puppy.ErrPuppyAlreadyIdentified, p.ID) + } + + m.Lock() + defer m.Unlock() + p.ID = m.nextID + 1 + m.nextID++ + m.Store(p.ID, *p) + + return nil } func (m *SyncStore) ReadPuppy(id int) (puppy.Puppy, error) { - switch { - case id < 0: - return puppy.Puppy{}, puppy.NewError(puppy.Err400BadRequest) - default: - m.Lock() - defer m.Unlock() - if r, ok := m.Load(id); ok { - puppy := r.(puppy.Puppy) - return puppy, nil - } - return puppy.Puppy{}, puppy.NewError(puppy.Err404NotFound) + v, ok := m.Load(id) + if !ok { + return puppy.Puppy{}, puppy.Errorp(puppy.ErrPuppyNotFoundOnRead, id) } + + m.Lock() + defer m.Unlock() + + return v.(puppy.Puppy), nil } func (m *SyncStore) UpdatePuppy(p puppy.Puppy) error { - switch { - case p.ID < 0: - return puppy.NewError(puppy.Err400BadRequest) - default: - m.Lock() - defer m.Unlock() - if _, ok := m.Load(p.ID); ok { - m.Store(p.ID, p) - return nil - } - return puppy.NewError(puppy.Err404NotFound) + m.Lock() + defer m.Unlock() + + if _, ok := m.Load(p.ID); !ok { + return puppy.Errorp(puppy.ErrPuppyNotFoundOnUpdate, p.ID) + } + + if p.Value < 0 { + return puppy.Errorp(puppy.ErrNegativePuppyValueOnUpdate, p.Value) } + + m.Store(p.ID, p) + + return nil } func (m *SyncStore) DeletePuppy(id int) error { - switch { - case id < 0: - return puppy.NewError(puppy.Err400BadRequest) - default: - m.Lock() - defer m.Unlock() - if _, ok := m.Load(id); ok { - m.Delete(id) - return nil - } - return puppy.NewError(puppy.Err404NotFound) + m.Lock() + defer m.Unlock() + + if _, ok := m.Load(id); !ok { + return puppy.Errorp(puppy.ErrPuppyNotFoundOnDelete, id) } + + m.Delete(id) + + return nil } diff --git a/10_rest/alextmz/pkg/puppy/types.go b/10_rest/alextmz/pkg/puppy/types.go index 9b9a222b6..313a8196b 100644 --- a/10_rest/alextmz/pkg/puppy/types.go +++ b/10_rest/alextmz/pkg/puppy/types.go @@ -1,7 +1,5 @@ package puppy -import "fmt" - type Puppy struct { ID int `json:"id"` Breed string `json:"breed"` @@ -16,13 +14,3 @@ type Storer interface { UpdatePuppy(Puppy) error DeletePuppy(int) error } - -// type StorerHTTPHandler struct { -// store puppy.Storer -// len int -// } - -func (p *Puppy) JSONstr() string { - r := `{"id":%d,"breed":"%s","colour":"%s","value": %.2f}` - return fmt.Sprintf(r, p.ID, p.Breed, p.Colour, p.Value) -} diff --git a/10_rest/alextmz/pkg/puppy/types_test.go b/10_rest/alextmz/pkg/puppy/types_test.go index 11c4363f5..5ca4e7548 100644 --- a/10_rest/alextmz/pkg/puppy/types_test.go +++ b/10_rest/alextmz/pkg/puppy/types_test.go @@ -7,42 +7,13 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_marshall(t *testing.T) { - var tests typetest - buildTestvar(&tests) - - for name, tt := range tests { - test := tt - t.Run(name, func(t *testing.T) { - got, err := json.Marshal(test.obj) - assert.NoError(t, err) - assert.JSONEq(t, test.jsn, string(got)) - }) - } -} - -func Test_unmarshall(t *testing.T) { - var tests typetest - buildTestvar(&tests) - - for name, tt := range tests { - test := tt - t.Run(name, func(t *testing.T) { - var p Puppy - err := json.Unmarshal([]byte(test.jsn), &p) - assert.NoError(t, err) - assert.JSONEq(t, test.obj.JSONstr(), p.JSONstr()) - }) - } -} - type typetest map[string]struct { - obj Puppy - jsn string + puppy Puppy + puppyJSON string } -func buildTestvar(t *typetest) { - *t = typetest{ +func newPuppyFixture() typetest { + return typetest{ "normal puppy": { Puppy{ID: 1, Breed: "Wolfhound", Colour: "Gray", Value: 50}, `{"id":1,"breed":"Wolfhound","colour":"Gray","value": 50}`, @@ -57,3 +28,31 @@ func buildTestvar(t *typetest) { }, } } + +func TestMarshall(t *testing.T) { + tests := newPuppyFixture() + + for name, test := range tests { + test := test + + t.Run(name, func(t *testing.T) { + got, err := json.Marshal(test.puppy) + assert.NoError(t, err) + assert.JSONEq(t, test.puppyJSON, string(got)) + }) + } +} + +func TestUnmarshall(t *testing.T) { + tests := newPuppyFixture() + for name, test := range tests { + test := test + + t.Run(name, func(t *testing.T) { + var p Puppy + err := json.Unmarshal([]byte(test.puppyJSON), &p) + assert.NoError(t, err) + assert.Equal(t, test.puppy, p) + }) + } +} diff --git a/10_rest/alextmz/pkg/rest/rest.go b/10_rest/alextmz/pkg/rest/rest.go new file mode 100644 index 000000000..2a9367f98 --- /dev/null +++ b/10_rest/alextmz/pkg/rest/rest.go @@ -0,0 +1,124 @@ +package rest + +import ( + "net/http" + "path" + "strconv" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" + "github.com/go-chi/render" +) + +type HTTPHandler struct { + Store puppy.Storer +} + +// despite the name, http.Error writes any http code/string. +var writeHTTP = http.Error + +func (ht HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + ht.handleGet(w, r) + case "PUT": + ht.handlePut(w, r) + case "POST": + ht.handlePost(w, r) + case "DELETE": + ht.handleDelete(w, r) + } +} + +func (ht *HTTPHandler) handleGet(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(path.Base(r.RequestURI)) + if writeHTTPOnError(w, err) { + return + } + + puppy, err := ht.Store.ReadPuppy(id) + + if !writeHTTPOnError(w, err) { + render.JSON(w, r, puppy) + } +} + +func (ht *HTTPHandler) handlePost(w http.ResponseWriter, r *http.Request) { + var puppy puppy.Puppy + + if err := render.DecodeJSON(r.Body, &puppy); writeHTTPOnError(w, err) { + return + } + + err := ht.Store.CreatePuppy(&puppy) + if !writeHTTPOnError(w, err) { + w.WriteHeader(http.StatusCreated) + render.JSON(w, r, puppy) + } +} + +func (ht *HTTPHandler) handlePut(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(path.Base(r.RequestURI)) + if writeHTTPOnError(w, err) { + return + } + + var puppy puppy.Puppy + + if err = render.DecodeJSON(r.Body, &puppy); writeHTTPOnError(w, err) { + return + } + + puppy.ID = id + if err = ht.Store.UpdatePuppy(puppy); !writeHTTPOnError(w, err) { + c := http.StatusOK + writeHTTP(w, strconv.Itoa(c)+" "+http.StatusText(c), c) + } +} + +func (ht *HTTPHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(path.Base(r.RequestURI)) + if writeHTTPOnError(w, err) { + return + } + + err = ht.Store.DeletePuppy(id) + + if !writeHTTPOnError(w, err) { + c := http.StatusOK + writeHTTP(w, strconv.Itoa(c)+" "+http.StatusText(c), c) + } +} + +// writeHTTPOnError is a helper function - if there is no error, returns false, +// otherwise writes the error out and returns true. +func writeHTTPOnError(w http.ResponseWriter, err error) bool { + if err != nil { + var e int + + var m string + + if v, ok := err.(puppy.Error); ok { + m = v.Error() + // translates "puppy" errors to "http" errors + switch v.Code { + case puppy.ErrPuppyNotFoundOnRead: + e = http.StatusNotFound + case puppy.ErrPuppyNotFoundOnUpdate: + e = http.StatusNotFound + case puppy.ErrPuppyNotFoundOnDelete: + e = http.StatusNotFound + default: + e = http.StatusBadRequest + } + } else { + e = http.StatusBadRequest + m = err.Error() + } + + writeHTTP(w, strconv.Itoa(e)+" "+http.StatusText(e)+" : "+m, e) + + return true + } + + return false +} diff --git a/10_rest/alextmz/pkg/rest/rest_test.go b/10_rest/alextmz/pkg/rest/rest_test.go new file mode 100644 index 000000000..fa1027f28 --- /dev/null +++ b/10_rest/alextmz/pkg/rest/rest_test.go @@ -0,0 +1,400 @@ +package rest + +import ( + "errors" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy/store" + "github.com/stretchr/testify/assert" +) + +type HTTPHandlerTest struct { + HTTPHandler +} + +// func printpuppies(s puppy.Storer, n int) { +// for i := 1; i <= n; i++ { +// p, err := s.ReadPuppy(i) +// if err != nil { +// fmt.Printf("error printing puppies: %v\n", err) +// return +// } + +// fmt.Printf("Printing puppy id %d: %#v\n", i, p) +// } +// } + +func TestHandleGet(t *testing.T) { + var tests = []struct { + name, argmethod, argurl, argjson string + wantcode int + wantbody string + }{ + { + name: "GET a not yet existing puppy 1", + argmethod: "GET", + argurl: "1", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 1 being read does not exist\n", + }, + { + name: "FIXTURE: POST a puppy", + argmethod: "POST", + argjson: `{"breed":"OKAPuppy","colour":"Rainbow","value":4}`, + wantcode: http.StatusCreated, + wantbody: `{"id":1,"breed":"OKAPuppy","colour":"Rainbow","value":4}` + "\n", + }, + { + name: "FIXTURE: POST a puppy", + argmethod: "POST", + argjson: `{"breed":"OKEPuppy","colour":"Invisible","value":2}`, + wantcode: http.StatusCreated, + wantbody: `{"id":2,"breed":"OKEPuppy","colour":"Invisible","value":2}` + "\n", + }, + { + name: "GET valid, existing puppy 1", + argmethod: "GET", + argurl: "1", + wantcode: http.StatusOK, + wantbody: `{"id":1,"breed":"OKAPuppy","colour":"Rainbow","value":4}` + "\n", + }, + { + name: "GET valid, existing puppy 2", + argmethod: "GET", + argurl: "2", + wantcode: http.StatusOK, + wantbody: `{"id":2,"breed":"OKEPuppy","colour":"Invisible","value":2}` + "\n", + }, + { + name: "GET a non-existing puppy 42", + argmethod: "GET", + argurl: "42", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 42 being read does not exist\n", + }, + { + name: "GET an invalid puppy -1", + argmethod: "GET", + argurl: "-1", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID -1 being read does not exist\n", + }, + { + name: "GET an invalid puppy 0", + argmethod: "GET", + argurl: "0", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 0 being read does not exist\n", + }, + { + name: "GET an invalid puppy X", + argmethod: "GET", + argurl: "X", + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : strconv.Atoi: parsing \"X\": invalid syntax\n", + }, + } + + var h HTTPHandlerTest + + for _, v := range tests { + test := v + t.Run(test.name, func(t *testing.T) { + gotcode, gotbody, err := h.runRequest(test.argmethod, test.argurl, test.argjson) + assert.Equal(t, test.wantcode, gotcode) + assert.Equal(t, test.wantbody, gotbody) + assert.NoError(t, err) + }) + } +} + +func TestHandlePost(t *testing.T) { + var h HTTPHandlerTest + + var tests = []struct { + name string + argmethod string + argurl string + argjson string + wantcode int + wantbody string + }{ + { + name: "FIXTURE: POST 1st valid puppy", + argmethod: "POST", + argjson: `{"breed":"OKPuppy","colour":"Rainbow","value":4}`, + wantcode: http.StatusCreated, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"Rainbow","value":4}` + "\n", + }, + { + name: "FIXTURE: POST 2nd valid puppy", + argmethod: "POST", + argjson: `{"breed":"OKPuppy2","colour":"Invisible","value":2}`, + wantcode: http.StatusCreated, + wantbody: `{"id":2,"breed":"OKPuppy2","colour":"Invisible","value":2}` + "\n", + }, + { + name: "POST invalid puppy JSON", + argmethod: "POST", + argjson: `{"breed":"InvalidJsonPuppy",colour:Rainbow}`, + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : invalid character 'c' looking for beginning of object key string\n", + }, + { + name: "POST invalid already-identified puppy", + argmethod: "POST", + argjson: `{"ID": 42, "breed":"OKPuppy","colour":"Rainbow","value":5}`, + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : puppy already initialized with ID 42\n", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + gotcode, gotbody, err := h.runRequest(test.argmethod, test.argurl, test.argjson) + assert.Equal(t, test.wantcode, gotcode) + assert.Equal(t, test.wantbody, gotbody) + assert.NoError(t, err) + }) + } +} + +func TestHandlePut(t *testing.T) { + var tests = []struct { + name string + argmethod string + argurl string + argjson string + wantcode int + wantbody string + }{ + { + name: "FIXTURE: POST initial puppy 1", + argmethod: "POST", + argjson: `{"breed":"OKPuppy","colour":"Rainbow","value":5}`, + wantcode: http.StatusCreated, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"Rainbow","value":5}` + "\n", + }, + { + name: "FIXTURE: POST valid puppy 2", + argmethod: "POST", + argjson: `{"breed":"OKPuppy2","colour":"Invisible","value":5}`, + wantcode: http.StatusCreated, + wantbody: `{"id":2,"breed":"OKPuppy2","colour":"Invisible","value":5}` + "\n", + }, + { + name: "GET the valid, existing puppy 1", + argmethod: "GET", + argurl: "1", + wantcode: http.StatusOK, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"Rainbow","value":5}` + "\n", + }, + { + name: "PUT changed puppy 1", + argmethod: "PUT", + argurl: "1", + argjson: `{"breed":"OKPuppy","colour":"HalfRainbow","value":20}`, + wantcode: http.StatusOK, + wantbody: "200 OK\n", + }, + { + name: "GET the changed, valid, existing puppy 1", + argmethod: "GET", + argurl: "1", + wantcode: http.StatusOK, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"HalfRainbow","value":20}` + "\n", + }, + { + name: "PUT changed puppy 2", + argmethod: "PUT", + argurl: "2", + argjson: `{"breed":"Unknown","colour":"Blue","value":0}`, + wantcode: http.StatusOK, + wantbody: "200 OK\n", + }, + { + name: "GET the changed, valid, existing puppy 2", + argmethod: "GET", + argurl: "2", + wantcode: http.StatusOK, + wantbody: `{"id":2,"breed":"Unknown","colour":"Blue","value":0}` + "\n", + }, + { + name: "PUT a changed, invalid puppy 2", + argmethod: "PUT", + argurl: "2", + argjson: `{"breed":"Unknown","colour":"Blue","value":-1}`, + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : trying to update a puppy with a negative value (-1.00)\n", + }, + { + name: "GET make sure puppy 2 wasnt modified", + argmethod: "GET", + argurl: "2", + wantcode: http.StatusOK, + wantbody: `{"id":2,"breed":"Unknown","colour":"Blue","value":0}` + "\n", + }, + { + name: "PUT a changed, invalid puppy X", + argmethod: "PUT", + argurl: "X", + argjson: `{"breed":"OKPuppy","colour":"HalfRainbow","value":20}`, + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : strconv.Atoi: parsing \"X\": invalid syntax\n", + }, + { + name: "PUT a changed, invalid JSON puppy 1", + argmethod: "PUT", + argurl: "1", + argjson: `"breed":"OKPuppy","colour":"HalfRainbow","value":20}`, + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : json: cannot unmarshal string into Go value of type puppy.Puppy\n", + }, + { + name: "GET make sure puppy 1 wasnt modified ", + argmethod: "GET", + argurl: "1", + wantcode: http.StatusOK, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"HalfRainbow","value":20}` + "\n", + }, + { + name: "PUT non-existing puppy 99", + argmethod: "PUT", + argurl: "99", + argjson: `{"breed":"Unknown","colour":"Blue","value":0}`, + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 99 being updated does not exist\n", + }, + } + + var h HTTPHandlerTest + + for _, v := range tests { + test := v + t.Run(test.name, func(t *testing.T) { + gotcode, gotbody, err := h.runRequest(test.argmethod, test.argurl, test.argjson) + assert.Equal(t, test.wantcode, gotcode) + assert.Equal(t, test.wantbody, gotbody) + assert.NoError(t, err) + }) + } +} + +func TestHandleDelete(t *testing.T) { + var tests = []struct { + name, argmethod, argurl, argjson string + wantcode int + wantbody string + }{ + { + name: "DELETE a puppy that never existed", + argmethod: "DELETE", + argurl: "1", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 1 being deleted does not exist\n", + }, + { + name: "FIXTURE: POST initial puppy 1", + argmethod: "POST", + argjson: `{"breed":"OKPuppy","colour":"Rainbow","value":5}`, + wantcode: http.StatusCreated, + wantbody: `{"id":1,"breed":"OKPuppy","colour":"Rainbow","value":5}` + "\n", + }, + { + name: "FIXTURE: POST puppy 2", + argmethod: "POST", + argjson: `{"breed":"OKPuppy2","colour":"Red","value":1}`, + wantcode: http.StatusCreated, + wantbody: `{"id":2,"breed":"OKPuppy2","colour":"Red","value":1}` + "\n", + }, + { + name: "DELETE a valid puppy 1", + argmethod: "DELETE", + argurl: "1", + wantcode: http.StatusOK, + wantbody: "200 OK\n", + }, + { + name: "DELETE already deleted puppy 1", + argmethod: "DELETE", + argurl: "1", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 1 being deleted does not exist\n", + }, + { + name: "FIXTURE: POST puppy 3", + argmethod: "POST", + argjson: `{"breed":"OKPuppy","colour":"Rainbow","value":5}`, + wantcode: http.StatusCreated, + wantbody: `{"id":3,"breed":"OKPuppy","colour":"Rainbow","value":5}` + "\n", + }, + { + name: "DELETE on invalid puppy 0", + argmethod: "DELETE", + argurl: "0", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID 0 being deleted does not exist\n", + }, + { + name: "DELETE on invalid puppy -1", + argmethod: "DELETE", + argurl: "-1", + wantcode: http.StatusNotFound, + wantbody: "404 Not Found : puppy with ID -1 being deleted does not exist\n", + }, + { + name: "DELETE on invalid puppy X", + argmethod: "DELETE", + argurl: "X", + wantcode: http.StatusBadRequest, + wantbody: "400 Bad Request : strconv.Atoi: parsing \"X\": invalid syntax\n", + }, + { + name: "DELETE a valid puppy 2", + argmethod: "DELETE", + argurl: "2", + wantcode: http.StatusOK, + wantbody: "200 OK\n", + }, + } + + var h HTTPHandlerTest + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + gotcode, gotbody, err := h.runRequest(test.argmethod, test.argurl, test.argjson) + assert.Equal(t, test.wantcode, gotcode) + assert.Equal(t, test.wantbody, gotbody) + assert.NoError(t, err) + }) + } +} + +func (h *HTTPHandlerTest) runRequest(method, urlparam, puppystr string) (int, string, error) { + url := "/api/puppy/" + urlparam + + if h.Store == nil { + h.Store = store.NewMapStore() + } + + puppyreader := strings.NewReader(puppystr) + req := httptest.NewRequest(method, url, puppyreader) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + resp := rr.Result() + defer resp.Body.Close() + + bbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return 0, "", errors.New("could not read response body, error " + err.Error()) + } + + return resp.StatusCode, string(bbody), nil +} diff --git a/10_rest/alextmz/test/empty-file.json b/10_rest/alextmz/test/empty-file.json new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/10_rest/alextmz/test/empty-file.json @@ -0,0 +1 @@ + diff --git a/10_rest/alextmz/test/invalid-format.json b/10_rest/alextmz/test/invalid-format.json deleted file mode 100644 index 7eea5272d..000000000 --- a/10_rest/alextmz/test/invalid-format.json +++ /dev/null @@ -1 +0,0 @@ -{"1":{"id":1,"breed":"Dogo","colour":"white","value":50}} \ No newline at end of file diff --git a/10_rest/alextmz/test/invalid-ids.json b/10_rest/alextmz/test/invalid-ids.json index 0f718e9d9..87aaf64f7 100644 --- a/10_rest/alextmz/test/invalid-ids.json +++ b/10_rest/alextmz/test/invalid-ids.json @@ -15,4 +15,5 @@ "colour": "Golden", "value": 900 } -] \ No newline at end of file +] + diff --git a/10_rest/alextmz/test/valid-formatted-json.json b/10_rest/alextmz/test/valid-formatted-json.json index e8d4e780f..205bdd033 100644 --- a/10_rest/alextmz/test/valid-formatted-json.json +++ b/10_rest/alextmz/test/valid-formatted-json.json @@ -14,4 +14,5 @@ "colour": "Golden", "value": 900 } -] \ No newline at end of file +] +