From 0db535469d893da342d3b0ac48c9b2433f1e08b3 Mon Sep 17 00:00:00 2001 From: Patrick Reagan Date: Mon, 6 Nov 2017 21:22:58 -0700 Subject: [PATCH 1/2] Reorganize data fetching for easier testing * Added a new `Client` interface to facilitate testing * Removed duplication when providing meetup names * Pushed event fetching and sorting to new Schedule struct * Simplified templates by rendering events inline * Added tests for `data` package --- data/data.go | 166 ++++++++++++++++++--------------------- data/data_test.go | 132 +++++++++++++++++++++++++++++++ server/server.go | 17 ++-- ui/bindata.go | 45 +++-------- ui/templates/events.html | 14 ---- ui/templates/index.html | 29 ++++--- ui/ui.go | 7 +- 7 files changed, 248 insertions(+), 162 deletions(-) create mode 100644 data/data_test.go delete mode 100644 ui/templates/events.html diff --git a/data/data.go b/data/data.go index e3b1970..2b836c6 100644 --- a/data/data.go +++ b/data/data.go @@ -3,6 +3,7 @@ package data import ( "encoding/json" "fmt" + "io/ioutil" "log" "net/http" "sort" @@ -10,71 +11,26 @@ import ( "time" ) -const apiTemplate = "https://api.meetup.com/%s/events?status=upcoming" - -var ( - meetupNames = []string{ - "Boulder-Gophers", - "Denver-Go-Language-User-Group", - "Denver-Go-Programming-Language-Meetup", - } -) - -// Store contains data for the site. -type Store struct { - pollingInterval time.Duration - - mu sync.Mutex - meetupSchedule *MeetupSchedule +type Client interface { + Get(string) ([]byte, error) } -// NewStore creates a new store initialized with a polling interval. -func NewStore(i time.Duration) *Store { - return &Store{ - pollingInterval: i, - } -} +type MeetupClient struct{} -// Poll runs forever, polling the meetup API for event data and updating the -// internal cache. -func (s *Store) Poll() { - for { - events := s.poll() - s.updateCache(events) - time.Sleep(s.pollingInterval) +func (c MeetupClient) Get(url string) (data []byte, err error) { + resp, err := http.Get(url) + + if err != nil { + return data, err } -} -func (s *Store) updateCache(schedule *MeetupSchedule) { - s.mu.Lock() - defer s.mu.Unlock() - s.meetupSchedule = schedule -} + defer resp.Body.Close() -func (s *Store) poll() *MeetupSchedule { - schedule := NewMeetupSchedule() - for _, meetup := range meetupNames { - eds, err := events(meetup) - if err != nil { - log.Printf("error fetching events for %s: %s", meetup, err) - continue - } - sort.Slice(eds, func(i, j int) bool { - return eds[i].Time < eds[j].Time - }) - schedule.Add(meetup, eds) - } - return schedule -} + data, err = ioutil.ReadAll(resp.Body) -// AllEvents returns the current meetup events in CO. -func (s *Store) AllEvents() *MeetupSchedule { - s.mu.Lock() - defer s.mu.Unlock() - return s.meetupSchedule + return data, err } -// Event contains information about a meetup event. type Event struct { ID string `json:"id"` Name string `json:"name"` @@ -86,52 +42,86 @@ func (e Event) HumanTime() string { return time.Unix(e.Time/1000, 0).Format(time.RFC1123) } -func NewMeetupSchedule() *MeetupSchedule { - return &MeetupSchedule{ - events: make(map[string][]Event), - } -} +type Schedule struct { + key string -type MeetupSchedule struct { - events map[string][]Event + Label string + Events []Event } -func (m *MeetupSchedule) Add(name string, events []Event) { - m.events[name] = events -} +type Schedules []*Schedule + +func (s *Schedule) FetchEvents(client Client) (err error) { + url := fmt.Sprintf("https://api.meetup.com/%s/events?status=upcoming", s.key) + + data, err := client.Get(url) -func (m *MeetupSchedule) BoulderEvents() []Event { - return nextThree(m.events["Boulder-Gophers"]) + if err != nil { + return err + } + + err = json.Unmarshal(data, &s.Events) + + if err != nil { + return err + } + + sort.SliceStable(s.Events, func(i, j int) bool { + return s.Events[i].Time < s.Events[j].Time + }) + + return err } -func (m *MeetupSchedule) DenverEvents() []Event { - return nextThree(m.events["Denver-Go-Language-User-Group"]) +func (s *Schedule) Next(count int) []Event { + if len(s.Events) > count { + return s.Events[0:count] + } + + return s.Events } -func (m *MeetupSchedule) DTCEvents() []Event { - return nextThree(m.events["Denver-Go-Programming-Language-Meetup"]) +// Store contains data for the site. +type Store struct { + pollingInterval time.Duration + mu sync.Mutex + + Schedules Schedules } -func nextThree(events []Event) []Event { - if len(events) < 3 { - return events +// NewStore creates a new store initialized with a polling interval. +func NewStore(i time.Duration) *Store { + return &Store{ + pollingInterval: i, + Schedules: Schedules{ + &Schedule{key: "Boulder-Gophers", Label: "Boulder"}, + &Schedule{key: "Denver-Go-Language-User-Group", Label: "Denver"}, + &Schedule{key: "Denver-Go-Programming-Language-Meetup", Label: "Denver Tech Center"}, + }, } - - return events[0:3] } -func events(name string) ([]Event, error) { - resp, err := http.Get(fmt.Sprintf(apiTemplate, name)) - if err != nil { - log.Fatal(err) +// Poll runs forever, polling the meetup API for event data and updating the +// internal cache. +func (s *Store) Poll() { + for { + s.mu.Lock() + defer s.mu.Unlock() + s.refresh() + + time.Sleep(s.pollingInterval) } - defer resp.Body.Close() +} - decoder := json.NewDecoder(resp.Body) - var data []Event - err = decoder.Decode(&data) - if err != nil { - return nil, err +func (s *Store) refresh() { + client := MeetupClient{} + + for _, s := range s.Schedules { + err := s.FetchEvents(client) + + if err != nil { + log.Printf("error fetching events for %s: %s", s.key, err) + continue + } } - return data, nil } diff --git a/data/data_test.go b/data/data_test.go new file mode 100644 index 0000000..5ef2d3e --- /dev/null +++ b/data/data_test.go @@ -0,0 +1,132 @@ +package data + +import ( + "errors" + "reflect" + "testing" +) + +type TestClient struct { + *MeetupClient + + data []byte + err error +} + +func NewClient(data string, err error) TestClient { + return TestClient{ + data: []byte(data), + err: err, + } +} + +func (c TestClient) Get(key string) ([]byte, error) { + if c.err != nil { + return []byte{}, c.err + } + + return c.data, c.err +} + +func TestScheduleHasNoEvents(t *testing.T) { + s := Schedule{} + if len(s.Events) != 0 { + t.Fail() + } +} + +func TestFetchEventsStoresEvents(t *testing.T) { + s := Schedule{key: "Boulder-Gophers"} + + c := NewClient(`[{"id":"id","name":"Event","time":400}]`, nil) + + s.FetchEvents(c) + + expected := []Event{Event{ID: "id", Name: "Event", Time: 400}} + + if !reflect.DeepEqual(s.Events, expected) { + t.Fail() + } +} + +func TestFetchEventsReturnsErrorWhenRequestFails(t *testing.T) { + s := Schedule{key: "Boulder-Gophers"} + c := NewClient(``, errors.New("Failed to connect")) + + err := s.FetchEvents(c) + + if err == nil { + t.Fail() + } +} + +func TestFetchEventsReturnsErrorWhenUnmarshalFails(t *testing.T) { + s := Schedule{key: "Boulder-Gophers"} + c := NewClient(`<>`, nil) + + err := s.FetchEvents(c) + + if err == nil { + t.Fail() + } +} + +func TestFetchEventsReturnsEventsInOrderOfTime(t *testing.T) { + s := Schedule{key: "Boulder-Gophers"} + c := NewClient(` + [ + {"id":"two","name":"Two","time":2}, + {"id":"one","name":"One","time":1} + ] + `, nil) + + s.FetchEvents(c) + + expected := []Event{ + Event{ID: "one", Name: "One", Time: 1}, + Event{ID: "two", Name: "Two", Time: 2}, + } + + if !reflect.DeepEqual(s.Events, expected) { + t.Fail() + } +} + +func TestNextReturnsSubsetOfEvents(t *testing.T) { + s := Schedule{Events: []Event{ + Event{ID: "one", Name: "One", Time: 1}, + Event{ID: "two", Name: "Two", Time: 2}, + Event{ID: "three", Name: "Three", Time: 3}, + }} + + expected := []Event{ + Event{ID: "one", Name: "One", Time: 1}, + Event{ID: "two", Name: "Two", Time: 2}, + } + + if !reflect.DeepEqual(s.Next(2), expected) { + t.Fail() + } +} + +func TestNextReturnsAllWhenLimitGreaterThanLen(t *testing.T) { + s := Schedule{Events: []Event{ + Event{ID: "one", Name: "One", Time: 1}, + }} + + expected := []Event{ + Event{ID: "one", Name: "One", Time: 1}, + } + + if !reflect.DeepEqual(s.Next(2), expected) { + t.Fail() + } +} + +func TestHumanTimeReturnsTimeInProperFormat(t *testing.T) { + e := Event{Time: 1533121600000} + + if e.HumanTime() != "Wed, 01 Aug 2018 05:06:40 MDT" { + t.Fail() + } +} diff --git a/server/server.go b/server/server.go index 1df4735..9b0a06a 100644 --- a/server/server.go +++ b/server/server.go @@ -8,11 +8,11 @@ import ( "github.com/milehighgophers/website/ui" ) -type Store interface { - AllEvents() *data.MeetupSchedule -} +// type Store interface { +// AllEvents() *data.MeetupSchedule +// } -func Start(addr string, s Store) error { +func Start(addr string, s *data.Store) error { log.Printf("listening on %s", addr) mux := http.NewServeMux() @@ -22,17 +22,18 @@ func Start(addr string, s Store) error { } type IndexHandler struct { - store Store + store data.Store } -func NewIndexHandler(s Store) *IndexHandler { +func NewIndexHandler(s *data.Store) *IndexHandler { return &IndexHandler{ - store: s, + store: *s, } } func (h *IndexHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - html := ui.Render(h.store.AllEvents()) + html := ui.Render(h.store.Schedules) + _, err := rw.Write(html) if err != nil { log.Print("error occured with /:", err) diff --git a/ui/bindata.go b/ui/bindata.go index 4272ed7..836943b 100644 --- a/ui/bindata.go +++ b/ui/bindata.go @@ -3,7 +3,6 @@ // assets/hero.jpg // assets/logo.png // assets/styles.css -// templates/events.html // templates/index.html // DO NOT EDIT! @@ -87,7 +86,7 @@ func assetsHeroJpg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "assets/hero.jpg", size: 379991, mode: os.FileMode(420), modTime: time.Unix(1497126590, 0)} + info := bindataFileInfo{name: "assets/hero.jpg", size: 379991, mode: os.FileMode(420), modTime: time.Unix(1508786768, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -107,7 +106,7 @@ func assetsLogoPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "assets/logo.png", size: 85609, mode: os.FileMode(420), modTime: time.Unix(1497127258, 0)} + info := bindataFileInfo{name: "assets/logo.png", size: 85609, mode: os.FileMode(420), modTime: time.Unix(1508786768, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -127,32 +126,12 @@ func assetsStylesCss() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "assets/styles.css", size: 418, mode: os.FileMode(420), modTime: time.Unix(1497128434, 0)} + info := bindataFileInfo{name: "assets/styles.css", size: 418, mode: os.FileMode(420), modTime: time.Unix(1509992601, 0)} a := &asset{bytes: bytes, info: info} return a, nil } -var _templatesEventsHtml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x4c\x8e\x31\xaa\xc4\x30\x0c\x44\x7b\x9f\x42\xe4\x00\xd1\x05\x84\xbb\x0f\xbf\x4a\xb5\x17\x30\x48\x1b\x0c\x8e\x02\xb1\x93\x46\xf8\xee\x8b\xd6\x2e\xb6\xd3\xcc\x1b\x46\x63\xc6\xf2\xce\x2a\xb0\xc8\x23\xda\xea\xd2\x7b\xa0\xbb\xc4\x60\x76\x25\xdd\x05\x56\x37\x4a\x8e\x01\x00\x80\xb8\x8c\x63\x88\x16\xcd\xd6\xff\xfb\x48\xfa\xca\x87\xf4\x4e\xc8\xed\x97\xb3\xf3\x2d\x4d\xc4\xb3\x03\xbd\x84\xd0\x3b\xcd\xa4\x54\xf1\x0f\x9c\x9f\x48\xb5\x5d\xa7\xee\x71\x3b\xe1\xef\x3b\x86\x70\x3a\x84\xce\x3d\xae\xec\x69\x1c\x0b\x87\xfa\x04\x00\x00\xff\xff\x61\x72\x21\xfa\xc3\x00\x00\x00") - -func templatesEventsHtmlBytes() ([]byte, error) { - return bindataRead( - _templatesEventsHtml, - "templates/events.html", - ) -} - -func templatesEventsHtml() (*asset, error) { - bytes, err := templatesEventsHtmlBytes() - if err != nil { - return nil, err - } - - info := bindataFileInfo{name: "templates/events.html", size: 195, mode: os.FileMode(420), modTime: time.Unix(1498425077, 0)} - a := &asset{bytes: bytes, info: info} - return a, nil -} - -var _templatesIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x92\x41\x6f\xd4\x30\x10\x85\xcf\xd9\x5f\x31\xf8\x9e\xb8\x7b\x02\x21\x27\x07\xb6\x05\x2e\x08\x0e\xcb\x81\xa3\xeb\xcc\xc6\x2e\x13\x7b\xe5\x99\xae\x5a\xaa\xfe\x77\x14\x7b\x43\x25\x84\x54\x2d\xb9\x24\xe3\x79\xf3\xbe\x64\x5e\xcc\x9b\xeb\xaf\xbb\xfd\x8f\x6f\x37\xe0\x65\xa6\x61\x63\xea\xad\x31\x1e\xed\x38\x6c\x9a\xc6\xcc\x28\x16\x9c\xb7\x99\x51\x7a\xf5\x7d\xff\xb1\x7d\xa7\x4a\x43\x82\x10\x0e\x5f\x02\x21\x7c\x0e\x93\x87\x4f\xe9\xe8\x31\xc3\xcd\x09\xa3\xb0\xd1\xb5\xbd\x08\x29\xc4\x9f\x90\x91\x7a\xc5\xf2\x48\xc8\x1e\x51\x14\xf8\x8c\x87\x5e\x79\x91\x23\xbf\xd7\xda\x8d\xf1\x8e\x3b\x47\xe9\x7e\x3c\x90\xcd\xd8\xb9\x34\x6b\x7b\x67\x1f\x34\x85\x5b\xd6\x31\xe5\xd9\x52\xf8\x85\xfa\x6d\x77\xd5\x5d\xbd\xd4\x9d\x63\x56\xaf\x52\xb4\x65\x46\x61\x5d\x3b\xeb\x8c\xd1\xe7\x8f\x34\xb7\x69\x7c\x2c\x26\xcb\x01\x66\x70\x64\x99\x7b\x55\xab\x62\xdf\x98\x30\x4f\xc0\xd9\xbd\x98\x51\x9a\x52\x77\x8c\x93\x02\x4b\xd2\xab\x39\x10\xb6\x3e\x4c\xbe\x9d\xca\x22\xb8\x5d\x04\x6a\xf5\x2a\x45\x61\xe8\x6a\x5b\x9e\x19\x9d\x84\x14\x57\xd1\xf2\x1e\x70\x20\x7c\x68\x5d\x8a\x62\x43\xfc\x43\x1f\xc3\x69\x15\x95\xfe\xb6\x9e\x37\xc6\x6f\x87\x0f\xe9\x9e\x46\xcc\x46\xfb\xed\xb0\x81\xbf\xae\xa7\x27\xc1\xf9\x48\x56\x10\x14\x96\x68\x14\x74\xe7\x89\x1a\xd5\xf3\x73\x21\xe8\x31\x9c\x5e\x67\x5d\x63\x3c\x5d\x84\xaa\x03\xff\x4d\x82\x3d\x3a\x0f\x3b\x8c\x72\x19\x75\xbf\xfb\x27\xd2\xe8\xf3\xc6\x4b\xfc\x35\x76\xa3\xeb\x3f\xff\x3b\x00\x00\xff\xff\xb7\x36\xb7\x49\x0b\x03\x00\x00") +var _templatesIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x84\x92\x31\x93\xd3\x30\x10\x85\x6b\xdf\xaf\x10\xea\x6d\x5d\x86\x02\x86\x91\xdd\xc0\xc1\x15\x10\x28\x42\x41\xa9\x58\x1b\x4b\xc7\x5a\xca\x68\x95\x4c\x82\x27\xff\x9d\x91\x6c\xe1\x50\x5d\x65\xad\xde\xdb\xef\x59\x2b\xc9\x37\x9f\xbe\x7f\xdc\xfd\xfa\xf1\xc4\x4c\x1c\xb1\x7b\x90\xf3\xa7\x92\x06\x94\xee\x1e\xaa\x4a\x8e\x10\x15\xeb\x8d\x0a\x04\xb1\xe5\x3f\x77\x9f\xeb\xf7\x3c\x0b\xd1\x46\x84\xee\x9b\x45\x60\xcf\x76\x30\xec\x8b\x3f\x1a\x08\xec\xe9\x0c\x2e\x92\x14\xb3\x9c\x8c\x68\xdd\x6f\x16\x00\x5b\x4e\xf1\x8a\x40\x06\x20\x72\x66\x02\x1c\x5a\x6e\x62\x3c\xd2\x07\x21\x7a\xed\x5e\xa8\xe9\xd1\x9f\xf4\x01\x55\x80\xa6\xf7\xa3\x50\x2f\xea\x22\xd0\xee\x49\x38\x1f\x46\x85\xf6\x0f\x88\x77\xcd\x63\xf3\xb8\xd6\x4d\x4f\xc4\x5f\x4d\x11\x8a\x08\x22\x89\x59\x29\x3d\x52\x2c\x87\x94\x7b\xaf\xaf\x19\x92\x36\x20\xb0\x1e\x15\x51\xcb\xe7\x2a\xe3\x2b\x69\xc7\x81\x51\xe8\x57\x18\xfa\xc1\x37\x47\x37\x70\xa6\x30\xb6\x7c\xb4\x08\xb5\xb1\x83\xa9\x87\x3c\x08\xaa\x93\x81\x17\x56\x2e\x72\x86\x98\xb1\x79\x4d\xd0\x47\xeb\x5d\x31\xa5\xff\x60\x07\x84\x4b\xdd\x7b\x17\x95\x75\x25\x7d\x9a\x82\x72\x03\xb0\xe6\x76\x4b\x65\x25\xb5\x3d\x97\xa6\xec\xdf\xcc\xbe\x74\x84\x4d\x37\x4d\xcd\x57\xb5\x07\xbc\xdd\xa4\x30\x9b\x22\x9c\x70\x59\xad\xb4\x2d\x5c\x22\x7b\xbb\x30\xab\x3c\xc4\xe2\x49\x19\xb8\x16\x95\xd4\x31\x71\x9f\x4f\xa3\x72\x3b\x3b\x42\x62\xeb\xf8\x9f\x41\x27\xc3\x56\x2d\x9a\xbe\x23\x89\x3b\x94\x14\x6b\xc8\x34\x01\x12\xdc\xe5\x6b\x7b\xee\x24\xc5\xe0\xdd\xd0\x6d\xfd\xbf\xb7\xb4\xec\x48\x91\xf4\xb5\xd7\xe9\xd2\x2a\x45\x39\xdd\xea\x59\x0d\x52\x2c\x73\xce\x97\x3e\x5f\xb6\x14\xf3\x4b\xff\x1b\x00\x00\xff\xff\x1a\xca\x1f\xc9\x01\x03\x00\x00") func templatesIndexHtmlBytes() ([]byte, error) { return bindataRead( @@ -167,7 +146,7 @@ func templatesIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "templates/index.html", size: 779, mode: os.FileMode(420), modTime: time.Unix(1497127865, 0)} + info := bindataFileInfo{name: "templates/index.html", size: 769, mode: os.FileMode(420), modTime: time.Unix(1510027702, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -224,10 +203,9 @@ func AssetNames() []string { // _bindata is a table, holding each asset generator, mapped to its name. var _bindata = map[string]func() (*asset, error){ - "assets/hero.jpg": assetsHeroJpg, - "assets/logo.png": assetsLogoPng, - "assets/styles.css": assetsStylesCss, - "templates/events.html": templatesEventsHtml, + "assets/hero.jpg": assetsHeroJpg, + "assets/logo.png": assetsLogoPng, + "assets/styles.css": assetsStylesCss, "templates/index.html": templatesIndexHtml, } @@ -270,14 +248,14 @@ type bintree struct { Func func() (*asset, error) Children map[string]*bintree } + var _bintree = &bintree{nil, map[string]*bintree{ "assets": &bintree{nil, map[string]*bintree{ - "hero.jpg": &bintree{assetsHeroJpg, map[string]*bintree{}}, - "logo.png": &bintree{assetsLogoPng, map[string]*bintree{}}, + "hero.jpg": &bintree{assetsHeroJpg, map[string]*bintree{}}, + "logo.png": &bintree{assetsLogoPng, map[string]*bintree{}}, "styles.css": &bintree{assetsStylesCss, map[string]*bintree{}}, }}, "templates": &bintree{nil, map[string]*bintree{ - "events.html": &bintree{templatesEventsHtml, map[string]*bintree{}}, "index.html": &bintree{templatesIndexHtml, map[string]*bintree{}}, }}, }} @@ -328,4 +306,3 @@ func _filePath(dir, name string) string { cannonicalName := strings.Replace(name, "\\", "/", -1) return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) } - diff --git a/ui/templates/events.html b/ui/templates/events.html deleted file mode 100644 index d71d035..0000000 --- a/ui/templates/events.html +++ /dev/null @@ -1,14 +0,0 @@ -{{define "events"}} - -{{end}} diff --git a/ui/templates/index.html b/ui/templates/index.html index 4586565..f96c48c 100644 --- a/ui/templates/index.html +++ b/ui/templates/index.html @@ -11,18 +11,23 @@
-
-

Boulder

- {{template "events" .BoulderEvents}} -
-
-

Denver

- {{template "events" .DenverEvents}} -
-
-

Denver Tech Center

- {{template "events" .DTCEvents}} -
+ {{range .}} +
+

{{.Label}}

+
    + {{range .Next 3}} +
  • +
    +
    {{.HumanTime}}
    +
    {{.Name}}
    +
    +
  • + {{else}} +
    No Events
    + {{end}} +
+
+ {{end}}
diff --git a/ui/ui.go b/ui/ui.go index c586084..20dd3f0 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -14,21 +14,16 @@ var indexTemplate *template.Template func init() { t := template.New("index") - events, err := Asset("templates/events.html") - if err != nil { - log.Fatal(err) - } index, err := Asset("templates/index.html") if err != nil { log.Fatal(err) } t = template.Must(t.Parse(string(index))) - t = template.Must(t.Parse(string(events))) indexTemplate = t } // Render will turn meetup event data into something to write out. -func Render(s *data.MeetupSchedule) []byte { +func Render(s data.Schedules) []byte { buf := &bytes.Buffer{} indexTemplate.Execute(buf, s) return buf.Bytes() From 71597316b4b10d0b0699a1d844fb8bc5d80a07f2 Mon Sep 17 00:00:00 2001 From: Patrick Reagan Date: Mon, 6 Nov 2017 21:23:06 -0700 Subject: [PATCH 2/2] Add Makefile for convenience --- Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dfac040 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +run: + go run main.go + +test: + go test ./... + +fmt: + find . -type f -name '*.go' | xargs gofmt -w +