Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add lab 10 - Puppy REST #669

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions 10_rest/alextmz/README.md
Original file line number Diff line number Diff line change
@@ -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
144 changes: 144 additions & 0 deletions 10_rest/alextmz/cmd/puppy-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main
alextmz marked this conversation as resolved.
Show resolved Hide resolved

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it's overkill, but let's wait for code owners. You are not dealing with graceful shutdown to justify this

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intent was to do real connection testing on main(), not just handler testing, and this was a (relatively) elegant way I found to http.Shutdown a http.ListenAndServe; but as I later found out, Travis doesn't allow connections to localhost.
So this could had been substituted for a global http.Server variable, and the test could had http.Shutdown the server it before calling main().
Which also doesn't feel a good solution despite the simplicity.
So I left it in as it works, and doesn't use another global.
I hope reviewers like it but I admit I'm not high on hopes 🤞


// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a kingpimg flag file type that opens the file as part of command line parsing. saves you the error handling here.

if err != nil {
fmt.Fprintf(out, "error opening file: %v\n", err)
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.Exit(1) or log.Fatal(msg) ?

}
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are making assumptions on puppy IDs being a sequence, that might be dangerous imo.
I think it's niver if you just impoment the Stringer interface for puppy, and if you truly want to print a slice of puppies, retrieve them as a list and then maybe fmt.Println(strings.Join(puppies, "\n")) or similar. It is a easier to test correct string creation than it is to test correct printing.

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
}
Loading