diff --git a/kadai4/hioki-daichi/.gitignore b/kadai4/hioki-daichi/.gitignore new file mode 100644 index 0000000..8176ffd --- /dev/null +++ b/kadai4/hioki-daichi/.gitignore @@ -0,0 +1,2 @@ +/omikuji-server +/coverage/ diff --git a/kadai4/hioki-daichi/LICENSE b/kadai4/hioki-daichi/LICENSE new file mode 100644 index 0000000..218ee44 --- /dev/null +++ b/kadai4/hioki-daichi/LICENSE @@ -0,0 +1,2 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 diff --git a/kadai4/hioki-daichi/Makefile b/kadai4/hioki-daichi/Makefile new file mode 100644 index 0000000..0ac3b24 --- /dev/null +++ b/kadai4/hioki-daichi/Makefile @@ -0,0 +1,24 @@ +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOTOOL=$(GOCMD) tool +GODOCCMD=godoc +GODOCPORT=6060 +BINARY_NAME=omikuji-server + +all: test build +build: + $(GOBUILD) -o $(BINARY_NAME) -v +test: + $(GOTEST) ./... +cov: + $(GOTEST) ./... -race -coverprofile=coverage/c.out -covermode=atomic + $(GOTOOL) cover -html=coverage/c.out -o coverage/index.html + open coverage/index.html +clean: + $(GOCLEAN) + rm -f $(BINARY_NAME) +doc: + (sleep 1; open http://localhost:$(GODOCPORT)/pkg/github.com/gopherdojo/dojo3) & + $(GODOCCMD) -http ":$(GODOCPORT)" diff --git a/kadai4/hioki-daichi/README.md b/kadai4/hioki-daichi/README.md new file mode 100644 index 0000000..6cb476c --- /dev/null +++ b/kadai4/hioki-daichi/README.md @@ -0,0 +1,61 @@ +# omikuji-server + +omikuji-server is a JSON API server that randomly returns fortune. + +## How to try + +The server side starts as follows. + +```shell +$ make build +$ ./omikuji-server +``` + +The client side sends a request as follows. + +```shell +$ curl -s localhost:8080 | jq . +{ + "name": "Gopher", + "fortune": "吉" +} +``` + +You can change the name returned from the default "Gopher" by specifying the name parameter. + +```shell +$ curl -s 'localhost:8080/?name=hioki-daichi' | jq . +{ + "name": "hioki-daichi", + "fortune": "大凶" +} +``` + +The name can be up to 32 characters. + +```shell +$ curl -s 'localhost:8080/?name=A%20name%20longer%20than%20thirty%20two%20characters' | jq . +{ + "errors": [ + "Name is too long (maximum is 32 characters)" + ] +} +``` + +## How to run the test + +```shell +$ make test +``` + +## How to read GoDoc + +```shell +$ make doc +``` + +## How to see code coverage + +```shell +$ make cov +``` diff --git a/kadai4/hioki-daichi/coverage.html b/kadai4/hioki-daichi/coverage.html new file mode 100644 index 0000000..be4ec5a --- /dev/null +++ b/kadai4/hioki-daichi/coverage.html @@ -0,0 +1,351 @@ + + + + + + + + +
+ +
+ not tracked + + no coverage + low coverage + * + * + * + * + * + * + * + * + high coverage + +
+
+
+ + + + + + + + + + + + + +
+ + + diff --git a/kadai4/hioki-daichi/coverage/.keep b/kadai4/hioki-daichi/coverage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/kadai4/hioki-daichi/datehelper/datehelper.go b/kadai4/hioki-daichi/datehelper/datehelper.go new file mode 100644 index 0000000..89b43cf --- /dev/null +++ b/kadai4/hioki-daichi/datehelper/datehelper.go @@ -0,0 +1,24 @@ +/* +Package datehelper is a collection of convenient functions for manipulating dates. +*/ +package datehelper + +import "time" + +// for testing +var nowFunc = time.Now +var loadLocationFunc = time.LoadLocation + +// IsDuringTheNewYear returns whether the current date is the New Year or not. +func IsDuringTheNewYear() bool { + loc, err := loadLocationFunc("Asia/Tokyo") + if err != nil { + panic(err) + } + + _, month, day := nowFunc().In(loc).Date() + if month == time.January && (day == 1 || day == 2 || day == 3) { + return true + } + return false +} diff --git a/kadai4/hioki-daichi/datehelper/datehelper_test.go b/kadai4/hioki-daichi/datehelper/datehelper_test.go new file mode 100644 index 0000000..c3a0499 --- /dev/null +++ b/kadai4/hioki-daichi/datehelper/datehelper_test.go @@ -0,0 +1,61 @@ +package datehelper + +import ( + "regexp" + "testing" + "time" +) + +func TestDatehelper_IsDuringTheNewYear(t *testing.T) { + loc, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Fatalf("err %s", err) + } + + cases := map[string]struct { + year int + month time.Month + day int + expected bool + }{ + "2018-12-31": {year: 2018, month: time.December, day: 31, expected: false}, + "2019-01-01": {year: 2019, month: time.January, day: 1, expected: true}, + "2019-01-02": {year: 2019, month: time.January, day: 2, expected: true}, + "2019-01-03": {year: 2019, month: time.January, day: 3, expected: true}, + "2019-01-04": {year: 2019, month: time.January, day: 4, expected: false}, + } + + for n, c := range cases { + c := c + t.Run(n, func(t *testing.T) { + nowFunc = func() time.Time { + return time.Date(c.year, c.month, c.day, 0, 0, 0, 0, loc) + } + + expected := c.expected + actual := IsDuringTheNewYear() + if actual != expected { + t.Errorf(`expected: "%t" actual: "%t"`, expected, actual) + } + }) + } +} + +func TestDatehelper_IsDuringTheNewYear_Panic(t *testing.T) { + loadLocationFunc = func(name string) (*time.Location, error) { + return time.LoadLocation("Nonexistent/Location") + } + + defer func() { + err := recover() + if err == nil { + t.Fatal("did not panic") + } + expected := "cannot find Nonexistent/Location in zip file " + actual := err.(error).Error() + if !regexp.MustCompile(expected).MatchString(actual) { + t.Errorf(`unmatched error: expected: "%s" actual: "%s"`, expected, actual) + } + }() + IsDuringTheNewYear() +} diff --git a/kadai4/hioki-daichi/form/rootform.go b/kadai4/hioki-daichi/form/rootform.go new file mode 100644 index 0000000..e54a5cf --- /dev/null +++ b/kadai4/hioki-daichi/form/rootform.go @@ -0,0 +1,33 @@ +/* +Package form provides methods to generate models from Form and Form from Request. +*/ +package form + +import ( + "net/http" + + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/fortune" + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/person" +) + +// RootForm has name. +type RootForm struct { + name string +} + +// NewRootForm returns a form for the route of "/". +func NewRootForm(req *http.Request) *RootForm { + f := &RootForm{} + nameParam := req.URL.Query().Get("name") + if nameParam != "" { + f.name = nameParam + } else { + f.name = "Gopher" + } + return f +} + +// NewPerson generates a person according to the content of form. +func (f *RootForm) NewPerson(ftn fortune.Fortune) *person.Person { + return person.NewPerson(f.name, ftn) +} diff --git a/kadai4/hioki-daichi/form/rootform_test.go b/kadai4/hioki-daichi/form/rootform_test.go new file mode 100644 index 0000000..d3c8204 --- /dev/null +++ b/kadai4/hioki-daichi/form/rootform_test.go @@ -0,0 +1,48 @@ +package form + +import ( + "net/http/httptest" + "testing" + + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/fortune" +) + +func TestForm_NewRootForm(t *testing.T) { + cases := map[string]struct { + nameParam string + expected string + }{ + "/?name=hioki-daichi": {nameParam: "hioki-daichi", expected: "hioki-daichi"}, + "/": {nameParam: "", expected: "Gopher"}, + } + + for n, c := range cases { + t.Run(n, func(t *testing.T) { + nameParam := c.nameParam + expected := c.expected + + req := httptest.NewRequest("GET", "/", nil) + q := req.URL.Query() + q.Add("name", nameParam) + req.URL.RawQuery = q.Encode() + f := NewRootForm(req) + + actual := f.name + if actual != expected { + t.Errorf(`unexpected name: expected: "%s" actual: "%s"`, expected, actual) + } + }) + } +} + +func TestForm_NewPerson(t *testing.T) { + name := "foo" + + f := RootForm{name: name} + p := f.NewPerson(fortune.Daikichi) + + actual := p.Name + if actual != name { + t.Errorf(`unexpected name: expected: "%s" actual: "%s"`, name, actual) + } +} diff --git a/kadai4/hioki-daichi/fortune/fortune.go b/kadai4/hioki-daichi/fortune/fortune.go new file mode 100644 index 0000000..a85505c --- /dev/null +++ b/kadai4/hioki-daichi/fortune/fortune.go @@ -0,0 +1,45 @@ +/* +Package fortune is a package that manages processing around fortune. +*/ +package fortune + +import ( + "math/rand" +) + +// Fortune means 運勢 +type Fortune string + +const ( + // Daikichi means "大吉" + Daikichi Fortune = "大吉" + + // Chukichi means "中吉" + Chukichi Fortune = "中吉" + + // Shokichi means "小吉" + Shokichi Fortune = "小吉" + + // Kichi means "吉" + Kichi Fortune = "吉" + + // Suekichi means "末吉" + Suekichi Fortune = "末吉" + + // Kyo means "凶" + Kyo Fortune = "凶" + + // Daikyo means "大凶" + Daikyo Fortune = "大凶" +) + +// DrawFortune draws a fortune. +func DrawFortune() Fortune { + fs := AllFortunes() + return fs[rand.Intn(len(fs))] +} + +// AllFortunes returns all fortunes. +func AllFortunes() []Fortune { + return []Fortune{Daikichi, Chukichi, Shokichi, Kichi, Suekichi, Kyo, Daikyo} +} diff --git a/kadai4/hioki-daichi/fortune/fortune_test.go b/kadai4/hioki-daichi/fortune/fortune_test.go new file mode 100644 index 0000000..c565a8a --- /dev/null +++ b/kadai4/hioki-daichi/fortune/fortune_test.go @@ -0,0 +1,34 @@ +package fortune + +import ( + "math/rand" + "testing" +) + +func TestFortune_DrawFortune(t *testing.T) { + cases := map[string]struct { + seed int64 + expected Fortune + }{ + "KYOU": {seed: 0, expected: Kyo}, + "DAIKYOU": {seed: 1, expected: Daikyo}, + "SUEKICHI": {seed: 2, expected: Suekichi}, + "KICHI": {seed: 3, expected: Kichi}, + "CHUKICHI": {seed: 4, expected: Chukichi}, + "SHOKICHI": {seed: 5, expected: Shokichi}, + "DAICHIKI": {seed: 9, expected: Daikichi}, + } + + for n, c := range cases { + c := c + t.Run(n, func(t *testing.T) { + rand.Seed(c.seed) + + expected := c.expected + actual := DrawFortune() + if actual != expected { + t.Errorf(`unexpected response body: expected: "%s" actual: "%s"`, expected, actual) + } + }) + } +} diff --git a/kadai4/hioki-daichi/jsonhelper/jsonhelper.go b/kadai4/hioki-daichi/jsonhelper/jsonhelper.go new file mode 100644 index 0000000..57ad520 --- /dev/null +++ b/kadai4/hioki-daichi/jsonhelper/jsonhelper.go @@ -0,0 +1,30 @@ +/* +Package jsonhelper is a collection of convenient functions for manipulating JSON. +*/ +package jsonhelper + +import ( + "bytes" + "encoding/json" + "io" +) + +// ToJSON converts v to JSON. +func ToJSON(v interface{}) (string, error) { + var buf bytes.Buffer + encoder := newEncoderFunc(&buf) + if err := encoder.Encode(v); err != nil { + return "", err + } + return buf.String(), nil +} + +// for testing +type encoder interface { + Encode(v interface{}) error +} + +// for testing +var newEncoderFunc = func(w io.Writer) encoder { + return json.NewEncoder(w) +} diff --git a/kadai4/hioki-daichi/jsonhelper/jsonhelper_test.go b/kadai4/hioki-daichi/jsonhelper/jsonhelper_test.go new file mode 100644 index 0000000..ab30805 --- /dev/null +++ b/kadai4/hioki-daichi/jsonhelper/jsonhelper_test.go @@ -0,0 +1,43 @@ +package jsonhelper + +import ( + "errors" + "io" + "testing" +) + +func TestJsonhelper_ToJSON(t *testing.T) { + foo := struct { + Bar string `json:"bar"` + Baz int `json:"baz"` + }{ + Bar: "barbar", + Baz: 1, + } + + actual, err := ToJSON(foo) + if err != nil { + t.Fatalf("err %s", err) + } + expected := "{\"bar\":\"barbar\",\"baz\":1}\n" + if actual != expected { + t.Errorf(`unexpected : expected: "%s" actual: "%s"`, expected, actual) + } +} + +func TestJsonhelper_ToJSON_Error(t *testing.T) { + expected := errInMock + + newEncoderFunc = func(w io.Writer) encoder { return &mockEncoder{} } + + _, actual := ToJSON(struct{}{}) + if actual != expected { + t.Errorf(`unexpected : expected: "%s" actual: "%s"`, expected, actual) + } +} + +var errInMock = errors.New("error in mock") + +type mockEncoder struct{} + +func (m *mockEncoder) Encode(v interface{}) error { return errInMock } diff --git a/kadai4/hioki-daichi/main.go b/kadai4/hioki-daichi/main.go new file mode 100644 index 0000000..391ff5c --- /dev/null +++ b/kadai4/hioki-daichi/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "log" + "math/rand" + "net/http" + "time" + + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/datehelper" + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/form" + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/fortune" + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/jsonhelper" +) + +// for testing +var nowFunc = time.Now +var isDuringTheNewYearFunc = datehelper.IsDuringTheNewYear +var toJSONFunc = jsonhelper.ToJSON + +func init() { + rand.Seed(nowFunc().UnixNano()) +} + +func main() { + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +} + +func handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + var ftn fortune.Fortune + if isDuringTheNewYearFunc() { + ftn = fortune.Daikichi + } else { + ftn = fortune.DrawFortune() + } + + p := form.NewRootForm(r).NewPerson(ftn) + + p.Validate() + + var v interface{} + if len(p.Errors) > 0 { + w.WriteHeader(http.StatusBadRequest) + v = map[string][]string{"errors": p.Errors} + } else { + v = p + } + + json, err := toJSONFunc(v) + if err != nil { + log.Println(err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + + fmt.Fprint(w, json) +} diff --git a/kadai4/hioki-daichi/main_test.go b/kadai4/hioki-daichi/main_test.go new file mode 100644 index 0000000..484cc54 --- /dev/null +++ b/kadai4/hioki-daichi/main_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "errors" + "io/ioutil" + "math/rand" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMain_handler_StatusCode(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + handler(w, req) + rw := w.Result() + defer rw.Body.Close() + + expected := http.StatusOK + actual := rw.StatusCode + if actual != expected { + t.Errorf(`unexpected status code: expected: "%d" actual: "%d"`, expected, actual) + } +} + +func TestMain_handler_ResponseBody(t *testing.T) { + cases := map[string]struct { + seed int64 + nameParam string + expected string + }{ + "KYOU": {seed: 0, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"凶\"}\n"}, + "DAIKYOU": {seed: 1, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"大凶\"}\n"}, + "SUEKICHI": {seed: 2, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"末吉\"}\n"}, + "KICHI": {seed: 3, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"吉\"}\n"}, + "CHUKICHI": {seed: 4, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"中吉\"}\n"}, + "SHOKICHI": {seed: 5, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"小吉\"}\n"}, + "DAICHIKI": {seed: 9, nameParam: "", expected: "{\"name\":\"Gopher\",\"fortune\":\"大吉\"}\n"}, + "name param": {seed: 9, nameParam: "hioki-daichi", expected: "{\"name\":\"hioki-daichi\",\"fortune\":\"大吉\"}\n"}, + } + + for n, c := range cases { + c := c + t.Run(n, func(t *testing.T) { + rand.Seed(c.seed) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + if c.nameParam != "" { + q := req.URL.Query() + q.Add("name", c.nameParam) + req.URL.RawQuery = q.Encode() + } + + handler(w, req) + rw := w.Result() + defer rw.Body.Close() + + b, err := ioutil.ReadAll(rw.Body) + if err != nil { + t.Fatalf("err %s", err) + } + + expected := c.expected + actual := string(b) + if actual != expected { + t.Errorf(`unexpected response body: expected: "%s" actual: "%s"`, expected, actual) + } + }) + } +} + +func TestMain_handler_DuringTheNewYear(t *testing.T) { + isDuringTheNewYearFunc = func() bool { + return true + } + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + handler(w, req) + rw := w.Result() + defer rw.Body.Close() + + b, err := ioutil.ReadAll(rw.Body) + if err != nil { + t.Fatalf("err %s", err) + } + + expected := "{\"name\":\"Gopher\",\"fortune\":\"大吉\"}\n" + actual := string(b) + if actual != expected { + t.Errorf(`unexpected response body: expected: "%s" actual: "%s"`, expected, actual) + } +} + +func TestMain_handler_ValidationError(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + q := req.URL.Query() + q.Add("name", "123456789012345678901234567890123") + req.URL.RawQuery = q.Encode() + handler(w, req) + rw := w.Result() + defer rw.Body.Close() + + b, err := ioutil.ReadAll(rw.Body) + if err != nil { + t.Fatalf("err %s", err) + } + + if rw.StatusCode != http.StatusBadRequest { + t.Errorf(`unexpected status code: expected: %d actual: %d`, http.StatusBadRequest, rw.StatusCode) + } + + expected := "{\"errors\":[\"Name is too long (maximum is 32 characters)\"]}\n" + actual := string(b) + if actual != expected { + t.Errorf(`unexpected response body: expected: "%s" actual: "%s"`, expected, actual) + } +} + +func TestMain_handler_ToJSONError(t *testing.T) { + toJSONFunc = func(v interface{}) (string, error) { + return "", errors.New("error in TestMain_handler_ToJSONError") + } + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + handler(w, req) + rw := w.Result() + defer rw.Body.Close() + + b, err := ioutil.ReadAll(rw.Body) + if err != nil { + t.Fatalf("err %s", err) + } + + expected := "Internal Server Error\n" + actual := string(b) + if actual != expected { + t.Errorf(`unexpected response body: expected: "%s" actual: "%s"`, expected, actual) + } +} diff --git a/kadai4/hioki-daichi/person/person.go b/kadai4/hioki-daichi/person/person.go new file mode 100644 index 0000000..f2bee2f --- /dev/null +++ b/kadai4/hioki-daichi/person/person.go @@ -0,0 +1,28 @@ +/* +Package person is a package that manages processing around person. +*/ +package person + +import "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/fortune" + +// Person has Name and Fortune. +type Person struct { + Name string `json:"name"` + Fortune fortune.Fortune `json:"fortune"` + Errors []string `json:"-"` +} + +// NewPerson generates a new person. +func NewPerson(n string, f fortune.Fortune) *Person { + return &Person{ + Name: n, + Fortune: f, + } +} + +// Validate validates its own fields. +func (p *Person) Validate() { + if len(p.Name) > 32 { + p.Errors = append(p.Errors, "Name is too long (maximum is 32 characters)") + } +} diff --git a/kadai4/hioki-daichi/person/person_test.go b/kadai4/hioki-daichi/person/person_test.go new file mode 100644 index 0000000..6611958 --- /dev/null +++ b/kadai4/hioki-daichi/person/person_test.go @@ -0,0 +1,31 @@ +package person + +import ( + "reflect" + "testing" + + "github.com/gopherdojo/dojo3/kadai4/hioki-daichi/fortune" +) + +func TestPerson_NewPerson(t *testing.T) { + expected := "*person.Person" + + p := NewPerson("Gopher", fortune.Daikichi) + + actual := reflect.TypeOf(p).String() + if actual != expected { + t.Errorf(`unexpected : expected: "%s" actual: "%s"`, expected, actual) + } +} + +func TestPerson_Validate(t *testing.T) { + expected := "Name is too long (maximum is 32 characters)" + + p := NewPerson("123456789012345678901234567890123", fortune.Daikichi) + p.Validate() + + actual := p.Errors[0] + if actual != expected { + t.Errorf(`unexpected : expected: "%s" actual: "%s"`, expected, actual) + } +}