diff --git a/10_rest/alextmz/README.md b/10_rest/alextmz/README.md new file mode 100644 index 000000000..c970a98c7 --- /dev/null +++ b/10_rest/alextmz/README.md @@ -0,0 +1,160 @@ +# 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 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 (eg. zero or negative) | + +### `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..c42923859 --- /dev/null +++ b/10_rest/alextmz/cmd/puppy-server/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "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/anz-bank/go-course/10_rest/alextmz/pkg/rest" + "gopkg.in/alecthomas/kingpin.v2" +) + +var ( + 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") + + out io.Writer = os.Stdout + + // shutdownhttp signals main() to... shutdown the http server + shutdownhttp = make(chan bool) + + // 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 + } + + jsonfile, err := os.Open(*flagfile) + if err != nil { + fmt.Fprintf(out, "error opening file: %v\n", err) + return + } + defer jsonfile.Close() + + puppies, err := readfile(jsonfile) + if err != nil { + fmt.Fprintf(out, "error reading JSON file: %v\n", err) + return + } + + var puppystore puppy.Storer + + switch *flagstore { + case "map": + puppystore = store.NewMapStore() + case "sync": + puppystore = store.NewSyncStore() + } + + n, err := storepuppies(puppystore, puppies) + if err != nil { + 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 := 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) + }() + + // 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) + if err != nil { + 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) { + for _, v := range puppies { + v := v + + err := store.CreatePuppy(&v) + if err != nil { + return 0, err + } + } + + return len(puppies), nil +} + +func readfile(file io.Reader) ([]puppy.Puppy, error) { + bytes, err := ioutil.ReadAll(file) + + if err != nil { + return []puppy.Puppy{}, err + } + + var puppies []puppy.Puppy + + 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 new file mode 100644 index 000000000..87a60b105 --- /dev/null +++ b/10_rest/alextmz/cmd/puppy-server/main_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy/store" + "github.com/stretchr/testify/assert" +) + +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 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 +} + +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 new file mode 100644 index 000000000..6437ea5d2 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/errors.go @@ -0,0 +1,99 @@ +package puppy + +import ( + "fmt" +) + +type Error struct { + Message string + Code int +} + +const ( + 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" +) + +// 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 { + 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: + panic("not implemented: param type is not either string, int or float") + } + + return e +} + +func NewError(err int, m string) Error { + var 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 new file mode 100644 index 000000000..ddbeefa56 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/errors_test.go @@ -0,0 +1,99 @@ +package puppy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKnownErrorCodeDescr(t *testing.T) { + var tests = map[string]struct { + code int + parameter string + want string + }{ + "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 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/store/mapstore.go b/10_rest/alextmz/pkg/puppy/store/mapstore.go new file mode 100644 index 000000000..0dd8888fc --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/mapstore.go @@ -0,0 +1,66 @@ +package store + +import ( + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" +) + +type MapStore struct { + pmap map[int]puppy.Puppy + nextID int +} + +func NewMapStore() *MapStore { + return &MapStore{pmap: make(map[int]puppy.Puppy)} +} + +func (m *MapStore) CreatePuppy(p *puppy.Puppy) error { + if p == nil { + return puppy.Error{Code: puppy.ErrNilPuppyPointer} + } + + if p.Value < 0 { + return puppy.Errorp(puppy.ErrNegativePuppyValueOnCreate, p.Value) + } + + if p.ID != 0 { + return puppy.Errorp(puppy.ErrPuppyAlreadyIdentified, p.ID) + } + m.nextID++ + p.ID = m.nextID + m.pmap[p.ID] = *p + + return nil +} + +func (m *MapStore) ReadPuppy(id int) (puppy.Puppy, error) { + v, ok := m.pmap[id] + if !ok { + return puppy.Puppy{}, puppy.Errorp(puppy.ErrPuppyNotFoundOnRead, id) + } + + return v, nil +} + +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) + } + + 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) + } + + 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 new file mode 100644 index 000000000..aa83ab2c0 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/storer_test.go @@ -0,0 +1,184 @@ +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 +} + +func (su *storerSuite) TestCreatePuppy() { + // can we create without error? + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + 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? + su.Run("NoErrorOnCreate", func() { + err = su.st.CreatePuppy(&p1) + su.Error(err) + }) + + // what we create and what we read back match? + 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? + 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? + 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) +} + +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? + su.Run("NoErrorRead", func() { + _, err = su.st.ReadPuppy(p1.ID) + su.NoError(err) + }) + + // do we error when reading what doesn't exist? + su.Run("ErrorPuppyDoesNotExist", func() { + _, err = su.st.ReadPuppy(99999) + su.Error(err) + }) + + // do the read contents match what we expect? + 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) +} + +func (su *storerSuite) TestUpdatePuppy() { + // setup + p1 := puppy.Puppy{Breed: "Dogo", Colour: "White", Value: 500} + expected := p1 + err := su.st.CreatePuppy(&p1) + su.NoError(err) + + 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? + 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? + 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) +} + +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? + su.Run("DeleteWithNoError", func() { + err = su.st.DeletePuppy(p1.ID) + su.NoError(err) + }) + + // after we delete, can we read the data back? + 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? + 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) { + 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 new file mode 100644 index 000000000..4dedb5012 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/store/syncstore.go @@ -0,0 +1,82 @@ +package store + +import ( + "sync" + + "github.com/anz-bank/go-course/10_rest/alextmz/pkg/puppy" +) + +type SyncStore struct { + nextID int + sync.Map + sync.Mutex +} + +func NewSyncStore() *SyncStore { + a := SyncStore{} + return &a +} + +func (m *SyncStore) CreatePuppy(p *puppy.Puppy) error { + if p == nil { + return puppy.Error{Code: puppy.ErrNilPuppyPointer} + } + + 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) { + 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 { + 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 { + 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 new file mode 100644 index 000000000..313a8196b --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/types.go @@ -0,0 +1,16 @@ +package puppy + +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 +} 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..5ca4e7548 --- /dev/null +++ b/10_rest/alextmz/pkg/puppy/types_test.go @@ -0,0 +1,58 @@ +package puppy + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +type typetest map[string]struct { + puppy Puppy + puppyJSON string +} + +func newPuppyFixture() typetest { + return 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}`, + }, + } +} + +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-ids.json b/10_rest/alextmz/test/invalid-ids.json new file mode 100644 index 000000000..87aaf64f7 --- /dev/null +++ b/10_rest/alextmz/test/invalid-ids.json @@ -0,0 +1,19 @@ +[ + { + "id": -10, + "breed": "Dogo", + "colour": "White", + "value": 500 + }, + { + "breed": "Mastiff", + "colour": "Brindle", + "value": 700 + }, + { + "breed": "Fila", + "colour": "Golden", + "value": 900 + } +] + 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..205bdd033 --- /dev/null +++ b/10_rest/alextmz/test/valid-formatted-json.json @@ -0,0 +1,18 @@ +[ + { + "breed": "Dogo", + "colour": "White", + "value": 500 + }, + { + "breed": "Mastiff", + "colour": "Brindle", + "value": 700 + }, + { + "breed": "Fila", + "colour": "Golden", + "value": 900 + } +] +