diff --git a/.deadlines.yml b/.deadlines.yml index 294f508..e7c2dc8 100644 --- a/.deadlines.yml +++ b/.deadlines.yml @@ -12,3 +12,13 @@ score: 100 - task: fetchall score: 100 + +- group: Basics + start: 20-02-2020 18:00 + deadline: 02-03-2020 23:59 + tasks: + - task: digitalclock + score: 300 + + - task: urlshortener + score: 200 diff --git a/digitalclock/README.md b/digitalclock/README.md new file mode 100644 index 0000000..4528ad2 --- /dev/null +++ b/digitalclock/README.md @@ -0,0 +1,114 @@ +## digitalclock + +В этой задаче нужно написать http server, +который по запросу `/?time=hh:mm:ss` вернет PNG с данным временем. + +Сервер должен слушать порт, переданный через аргумент `--port`. + +### Примеры + +Запуск: +``` +$ digitalclock -port 6029 +``` + +После запуска можно открыть в браузере `http://localhost:6029/?time=15:04:05` и получить png наподобие этой: + +![Image description](assets/150405.png) + +Если параметр `time` не задан (или равен ""), сервен должен вернуть текущее время. +В этом случае при обновлении страницы картинка должна меняться. + +В запросе также нужно поддержать параметр 1 <=`k` <= 30, +который отвечает за увеличение картинки в `k` раз: + +http://localhost:6029/?time=15:04:05?k=15 + +![Image description](assets/150405_15.png) + +На невалидных `time` и `k` сервер должен возврщать произвольное сообщение об ошибке и http status 400 (Bad Request): + +``` +$ curl -i "http://localhost:6029/?k=100" +HTTP/1.1 400 Bad Request +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Fri, 14 Feb 2020 02:02:41 GMT +Content-Length: 10 + +invalid k +``` + +В случае успеха приложение должно возвращать http status 200 (OK), +а также проставлять http header `Content-Type: image/png`: + +``` +$ curl -i "http://localhost:6029/" +HTTP/1.1 200 OK +Content-Type: image/png +Date: Fri, 14 Feb 2020 02:08:19 GMT +Content-Length: 193 +... +``` + +### Формат + +`hh:mm:ss` + +В [./symbols.go](./symbols.go) определены строковые константы, кодирующие цифры и двоеточие. +Один символ константы (отличный от '\n') - 1 пиксель. +'.' должны кодироваться в белый цвет, '1' - в бирюзовый (переменная **Cyan** в [./symbols.go](./symbols.go)). +Все константы имеют одинаковую высоту `h`, все цифры имеют одинаковую ширину `w`. + +`k` отличный от 1 (default) превращает каждый пиксель `p` картинки в блок `k x k` пикселей `p`. +Высота результирующей картинки - `h * k`, ширина - `(6 * w + 2 * w_colon) * k`, +где `w_colon` ширина константы, соответствующей ':'. + +### Проверка решения + +Для запуска тестов нужно выполнить следующую команду: + +``` +go test -v ./digitalclock/... +``` + +### Запуск программы + +``` +go run -v ./digitalclock/... +``` + +### Компиляция + +``` +go install ./digitalclock/... +``` + +После выполнения в `$GOPATH/bin` появится исполняемый файл с именем `digitalclock`. + +### Walkthrough + +1. парсинг аргументов командной строки https://gobyexample.com/command-line-flags +2. http server: https://p.go.manytask.org/00-intro/lecture.slide#23 +3. url params: https://golang.org/pkg/net/url/#URL.Query +3. форматирование времени: https://gobyexample.com/time-formatting-parsing +4. работа с картинками: https://golang.org/pkg/image/ + +Пример, создания простой png размера 5x5: +```go +package main + +import ( + "image" + "image/color" + "image/png" + "os" +) + +func main() { + img := image.NewRGBA(image.Rect(0, 0, 5, 5)) + img.Set(2, 2, color.RGBA{255, 0, 0, 255}) + f, _ := os.Create("/tmp/img.png") + png.Encode(f, img) +} +``` diff --git a/digitalclock/assets/150405.png b/digitalclock/assets/150405.png new file mode 100644 index 0000000..e9c67b2 Binary files /dev/null and b/digitalclock/assets/150405.png differ diff --git a/digitalclock/assets/150405_15.png b/digitalclock/assets/150405_15.png new file mode 100644 index 0000000..3484f02 Binary files /dev/null and b/digitalclock/assets/150405_15.png differ diff --git a/digitalclock/main.go b/digitalclock/main.go new file mode 100644 index 0000000..cab7d3a --- /dev/null +++ b/digitalclock/main.go @@ -0,0 +1,7 @@ +// +build !solution + +package main + +func main() { + +} diff --git a/digitalclock/main_test.go b/digitalclock/main_test.go new file mode 100644 index 0000000..b5b70cc --- /dev/null +++ b/digitalclock/main_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "encoding/json" + "fmt" + "image" + "image/png" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/tools/testtool" +) + +const importPath = "gitlab.com/slon/shad-go/digitalclock" + +var binCache testtool.BinCache + +func TestMain(m *testing.M) { + os.Exit(func() int { + var teardown testtool.CloseFunc + binCache, teardown = testtool.NewBinCache() + defer teardown() + + return m.Run() + }()) +} + +func startServer(t *testing.T) (port string, stop func()) { + binary, err := binCache.GetBinary(importPath) + require.NoError(t, err) + + port, err = testtool.GetFreePort() + require.NoError(t, err, "unable to get free port") + + cmd := exec.Command(binary, "-port", port) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + + require.NoError(t, cmd.Start()) + + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + stop = func() { + _ = cmd.Process.Kill() + <-done + } + + if err = testtool.WaitForPort(t, time.Second*30, port); err != nil { + stop() + } + + require.NoError(t, err) + return +} + +func readImage(fname string) (image.Image, error) { + f, err := os.Open(fname) + if err != nil { + return nil, fmt.Errorf("unable to open %s: %w", fname, err) + } + defer func() { _ = f.Close() }() + + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("unable to decode %s: %w", fname, err) + } + + return img, nil +} + +func abs(a, b uint32) int64 { + if a > b { + return int64(a - b) + } + return int64(b - a) +} + +func calcImgDiff(i1, i2 image.Image) float64 { + w, h := i1.Bounds().Dx(), i1.Bounds().Dy() + + var sum int64 + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + r1, g1, b1, _ := i1.At(x, y).RGBA() + r2, g2, b2, _ := i2.At(x, y).RGBA() + sum += abs(r1, r2) + sum += abs(g1, g2) + sum += abs(b1, b2) + } + } + + return float64(sum) / (float64(w*h) * 0xffff * 3) * 100.0 +} + +func queryImage(t *testing.T, c *http.Client, url string) image.Image { + t.Logf("querying: %s", url) + + resp, err := c.Get(url) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "image/png", resp.Header.Get("Content-Type")) + + img, err := png.Decode(resp.Body) + require.Nil(t, err) + + return img +} + +func TestDigitalClock_valid(t *testing.T) { + port, stop := startServer(t) + defer stop() + + files, err := ioutil.ReadDir("./testdata") + require.Nil(t, err) + + c := &http.Client{Timeout: time.Second * 10} + + for _, f := range files { + t.Run(f.Name(), func(t *testing.T) { + parts := strings.SplitN(strings.TrimSuffix(f.Name(), ".png"), "_", 2) + + v := url.Values{} + v.Add("time", parts[0]) + v.Add("k", parts[1]) + + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + img := queryImage(t, c, u) + + expected, err := readImage(path.Join("testdata", f.Name())) + require.NoError(t, err) + + w, h := img.Bounds().Dx(), img.Bounds().Dy() + ew, eh := expected.Bounds().Dx(), expected.Bounds().Dy() + if w != ew || h != eh { + t.Errorf("expected size %d x %d got %d x %d", ew, eh, w, h) + } + + diff := calcImgDiff(img, expected) + t.Logf("image diff: %.2f %%", diff) + require.True(t, diff < 1.0, + fmt.Sprintf("%s images are too different (%.2f %%)", f.Name(), diff)) + }) + } +} + +func TestDigitalClock_invalid(t *testing.T) { + port, stop := startServer(t) + defer stop() + + c := &http.Client{Timeout: time.Second * 10} + + type TestCase struct { + Time string `json:"time"` + K string `json:"k"` + } + + testName := func(tc *TestCase) string { + data, err := json.Marshal(tc) + require.Nil(t, err) + return string(data) + } + + for _, tc := range []*TestCase{ + {K: "0"}, + {K: "31"}, + {K: "2.5"}, + {K: "f"}, + {Time: "15:04"}, + {Time: "15:04:0"}, + {Time: "3:04:05"}, + {Time: "15:4:05"}, + {Time: "24:00:00"}, + {Time: "00:00:60"}, + {Time: "f"}, + } { + t.Run(testName(tc), func(t *testing.T) { + v := url.Values{} + if tc.Time != "" { + v.Add("time", tc.Time) + } + if tc.K != "" { + v.Add("k", tc.K) + } + + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + t.Logf("querying: %s", u) + + resp, err := c.Get(u) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + } +} + +func TestDigitalClock_now(t *testing.T) { + port, stop := startServer(t) + defer stop() + + c := &http.Client{Timeout: time.Second * 10} + + for _, k := range []int{1, 10, 29} { + k := k + t.Run(fmt.Sprintf("k=%d", k), func(t *testing.T) { + v := url.Values{"k": []string{strconv.Itoa(k)}} + u := fmt.Sprintf("http://localhost:%s/?%s", port, v.Encode()) + img := queryImage(t, c, u) + + w, h := img.Bounds().Dx(), img.Bounds().Dy() + ew, eh := getExpectedWidth(k), getExpectedHeight(k) + if w != ew || h != eh { + t.Errorf("expected size %d x %d got %d x %d", ew, eh, w, h) + } + }) + } +} + +func getSymbolWidth(s string) int { + return len(strings.SplitN(s, "\n", 2)[0]) +} + +func getExpectedWidth(k int) int { + return (getSymbolWidth(Zero)*6 + getSymbolWidth(Colon)*2) * k +} + +func getExpectedHeight(k int) int { + return len(strings.Split(Zero, "\n")) * k +} diff --git a/digitalclock/symbols.go b/digitalclock/symbols.go new file mode 100644 index 0000000..8578102 --- /dev/null +++ b/digitalclock/symbols.go @@ -0,0 +1,152 @@ +// +build !change + +package main + +import "image/color" + +var Cyan = color.RGBA{R: 100, G: 200, B: 200, A: 0xff} + +const ( + Zero = `........ +.111111. +.111111. +.11..11. +.11..11. +.11..11. +.11..11. +.11..11. +.11..11. +.111111. +.111111. +........` + + One = `........ +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +........` + + Two = `........ +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +........` + + Three = `........ +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +........` + + Four = `........ +.11..11. +.11..11. +.11..11. +.11..11. +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +........` + + Five = `........ +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +.....11. +.....11. +.111111. +.111111. +........` + + Six = `........ +.111111. +.111111. +.11..... +.11..... +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +........` + + Seven = `........ +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +.....11. +........` + + Eight = `........ +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +........` + + Nine = `........ +.111111. +.111111. +.11..11. +.11..11. +.111111. +.111111. +.....11. +.....11. +.....11. +.....11. +........` + + Colon = `.... +.... +.... +.... +.11. +.11. +.... +.11. +.11. +.... +.... +....` +) diff --git a/digitalclock/testdata/00:51:41_20.png b/digitalclock/testdata/00:51:41_20.png new file mode 100644 index 0000000..67ecb56 Binary files /dev/null and b/digitalclock/testdata/00:51:41_20.png differ diff --git a/digitalclock/testdata/02:06:08_11.png b/digitalclock/testdata/02:06:08_11.png new file mode 100644 index 0000000..3937a87 Binary files /dev/null and b/digitalclock/testdata/02:06:08_11.png differ diff --git a/digitalclock/testdata/02:07:50_29.png b/digitalclock/testdata/02:07:50_29.png new file mode 100644 index 0000000..cfddb29 Binary files /dev/null and b/digitalclock/testdata/02:07:50_29.png differ diff --git a/digitalclock/testdata/02:40:33_26.png b/digitalclock/testdata/02:40:33_26.png new file mode 100644 index 0000000..381bd49 Binary files /dev/null and b/digitalclock/testdata/02:40:33_26.png differ diff --git a/digitalclock/testdata/03:05:12_9.png b/digitalclock/testdata/03:05:12_9.png new file mode 100644 index 0000000..ac91621 Binary files /dev/null and b/digitalclock/testdata/03:05:12_9.png differ diff --git a/digitalclock/testdata/03:55:20_19.png b/digitalclock/testdata/03:55:20_19.png new file mode 100644 index 0000000..947a577 Binary files /dev/null and b/digitalclock/testdata/03:55:20_19.png differ diff --git a/digitalclock/testdata/03:57:01_9.png b/digitalclock/testdata/03:57:01_9.png new file mode 100644 index 0000000..4c6389d Binary files /dev/null and b/digitalclock/testdata/03:57:01_9.png differ diff --git a/digitalclock/testdata/04:16:43_21.png b/digitalclock/testdata/04:16:43_21.png new file mode 100644 index 0000000..d99c377 Binary files /dev/null and b/digitalclock/testdata/04:16:43_21.png differ diff --git a/digitalclock/testdata/04:49:37_14.png b/digitalclock/testdata/04:49:37_14.png new file mode 100644 index 0000000..4f91e62 Binary files /dev/null and b/digitalclock/testdata/04:49:37_14.png differ diff --git a/digitalclock/testdata/05:01:26_14.png b/digitalclock/testdata/05:01:26_14.png new file mode 100644 index 0000000..e661c78 Binary files /dev/null and b/digitalclock/testdata/05:01:26_14.png differ diff --git a/digitalclock/testdata/06:12:20_21.png b/digitalclock/testdata/06:12:20_21.png new file mode 100644 index 0000000..da90110 Binary files /dev/null and b/digitalclock/testdata/06:12:20_21.png differ diff --git a/digitalclock/testdata/07:36:15_2.png b/digitalclock/testdata/07:36:15_2.png new file mode 100644 index 0000000..d32ff08 Binary files /dev/null and b/digitalclock/testdata/07:36:15_2.png differ diff --git a/digitalclock/testdata/09:00:51_21.png b/digitalclock/testdata/09:00:51_21.png new file mode 100644 index 0000000..755fb78 Binary files /dev/null and b/digitalclock/testdata/09:00:51_21.png differ diff --git a/digitalclock/testdata/09:17:08_23.png b/digitalclock/testdata/09:17:08_23.png new file mode 100644 index 0000000..f92f2f9 Binary files /dev/null and b/digitalclock/testdata/09:17:08_23.png differ diff --git a/digitalclock/testdata/11:14:07_30.png b/digitalclock/testdata/11:14:07_30.png new file mode 100644 index 0000000..3646dd5 Binary files /dev/null and b/digitalclock/testdata/11:14:07_30.png differ diff --git a/digitalclock/testdata/13:10:10_13.png b/digitalclock/testdata/13:10:10_13.png new file mode 100644 index 0000000..959a6d1 Binary files /dev/null and b/digitalclock/testdata/13:10:10_13.png differ diff --git a/digitalclock/testdata/13:11:11_27.png b/digitalclock/testdata/13:11:11_27.png new file mode 100644 index 0000000..2d4dc47 Binary files /dev/null and b/digitalclock/testdata/13:11:11_27.png differ diff --git a/digitalclock/testdata/14:58:09_30.png b/digitalclock/testdata/14:58:09_30.png new file mode 100644 index 0000000..d1c4247 Binary files /dev/null and b/digitalclock/testdata/14:58:09_30.png differ diff --git a/digitalclock/testdata/15:25:05_7.png b/digitalclock/testdata/15:25:05_7.png new file mode 100644 index 0000000..02f7441 Binary files /dev/null and b/digitalclock/testdata/15:25:05_7.png differ diff --git a/digitalclock/testdata/15:37:28_14.png b/digitalclock/testdata/15:37:28_14.png new file mode 100644 index 0000000..0086138 Binary files /dev/null and b/digitalclock/testdata/15:37:28_14.png differ diff --git a/digitalclock/testdata/18:15:02_7.png b/digitalclock/testdata/18:15:02_7.png new file mode 100644 index 0000000..55ea51a Binary files /dev/null and b/digitalclock/testdata/18:15:02_7.png differ diff --git a/digitalclock/testdata/19:03:33_7.png b/digitalclock/testdata/19:03:33_7.png new file mode 100644 index 0000000..a608b93 Binary files /dev/null and b/digitalclock/testdata/19:03:33_7.png differ diff --git a/digitalclock/testdata/20:17:35_27.png b/digitalclock/testdata/20:17:35_27.png new file mode 100644 index 0000000..67a39b3 Binary files /dev/null and b/digitalclock/testdata/20:17:35_27.png differ diff --git a/digitalclock/testdata/20:21:38_24.png b/digitalclock/testdata/20:21:38_24.png new file mode 100644 index 0000000..d2f8e7f Binary files /dev/null and b/digitalclock/testdata/20:21:38_24.png differ diff --git a/digitalclock/testdata/21:21:21_14.png b/digitalclock/testdata/21:21:21_14.png new file mode 100644 index 0000000..73d9a8e Binary files /dev/null and b/digitalclock/testdata/21:21:21_14.png differ diff --git a/digitalclock/testdata/22:14:54_27.png b/digitalclock/testdata/22:14:54_27.png new file mode 100644 index 0000000..ec8fd86 Binary files /dev/null and b/digitalclock/testdata/22:14:54_27.png differ diff --git a/digitalclock/testdata/22:16:10_20.png b/digitalclock/testdata/22:16:10_20.png new file mode 100644 index 0000000..2e67bb3 Binary files /dev/null and b/digitalclock/testdata/22:16:10_20.png differ diff --git a/digitalclock/testdata/22:48:18_10.png b/digitalclock/testdata/22:48:18_10.png new file mode 100644 index 0000000..c1079b3 Binary files /dev/null and b/digitalclock/testdata/22:48:18_10.png differ diff --git a/digitalclock/testdata/22:55:07_23.png b/digitalclock/testdata/22:55:07_23.png new file mode 100644 index 0000000..b9965aa Binary files /dev/null and b/digitalclock/testdata/22:55:07_23.png differ diff --git a/digitalclock/testdata/23:43:50_25.png b/digitalclock/testdata/23:43:50_25.png new file mode 100644 index 0000000..7db2d18 Binary files /dev/null and b/digitalclock/testdata/23:43:50_25.png differ diff --git a/go.mod b/go.mod index a13edd4..13e7d01 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.com/slon/shad-go go 1.13 require ( + github.com/go-resty/resty/v2 v2.1.0 github.com/spf13/cobra v0.0.5 github.com/stretchr/testify v1.4.0 go.uber.org/goleak v1.0.0 diff --git a/go.sum b/go.sum index 177e085..51612cd 100644 --- a/go.sum +++ b/go.sum @@ -8,11 +8,15 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-resty/resty/v2 v2.1.0 h1:Z6IefCpUMfnvItVJaJXWv/pMiiD11So35QgwEELsldE= +github.com/go-resty/resty/v2 v2.1.0/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -48,6 +52,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -63,6 +69,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbO golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/urlshortener/README.md b/urlshortener/README.md new file mode 100644 index 0000000..bb9425a --- /dev/null +++ b/urlshortener/README.md @@ -0,0 +1,99 @@ +## urlshortener + +В этой задаче нужно написать http сервер со следующим API: + +* POST /shorten {"url": "\"} -> {"key": "\"} +* GET /go/\ -> 302 + +GET и POST - это методы HTTP. GET запрос используется для того, чтобы получать данные, а POST - чтобы добавлять и модифицировать. + +В тело `/shorten` запроса будет передаваться json вида +``` +{"url":"https://github.com/golang/go/wiki/CodeReviewComments"} +``` + +Сервер должен ответить json'ом следующего вида: +``` +{ + "url": "https://github.com/golang/go/wiki/CodeReviewComments", + "key": "ed1De1" +} +``` + +`ed1De1` здесь - это сгенерированное сервисом число. + +После такого `/shorten` можно делать `/go/ed1De1`. +Ответ должен иметь иметь HTTP код 302. +302 указывает на то, что запрошенный ресурс был временно перемещен на другой адрес (передаваемый в HTTP header'е `Location`). + +Если открыть http://localhost:6029/go/ed1De1 в браузере, тот перенаправит на https://github.com/golang/go/wiki/CodeReviewComments. + +Сервер должен слушать порт, переданный через аргумент `--port`. + +### Примеры + +Запуск: +``` +$ urlshortener -port 6029 +``` + +Успешное добавление URL'а (200, Content-Type: application/json): +``` +$ curl -i -X POST "localhost:6029/shorten" -d '{"url":"https://github.com/golang/go/wiki/CodeReviewComments"}' +HTTP/1.1 200 OK +Content-Type: application/json +Date: Sat, 15 Feb 2020 23:35:26 GMT +Content-Length: 82 + +{"url":"https://github.com/golang/go/wiki/CodeReviewComments","key":"65ed150831"} +``` + +Невалидный json (400): +``` +$ curl -i -X POST "localhost:6029/shorten" -d '{"url":"https://github.com' +HTTP/1.1 400 Bad Request +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Sat, 15 Feb 2020 23:30:27 GMT +Content-Length: 16 + +invalid request +``` + +Успешный запрос (302, Location header): +``` +$ curl -i -X GET "localhost:6029/go/c1464c853a" +HTTP/1.1 302 Found +Content-Type: text/html; charset=utf-8 +Location: https://github.com/golang/go/wiki/CodeReviewComments +Date: Sat, 15 Feb 2020 23:25:26 GMT +Content-Length: 75 + +Found. +``` + +Несуществующий key (404): +``` +$ curl -i -X GET "localhost:6029/go/uaaab" +HTTP/1.1 404 Not Found +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff +Date: Sat, 15 Feb 2020 23:26:48 GMT +Content-Length: 14 + +key not found +``` + +### Состояние + +Своё состояние сервис должен целиком хранить в памяти. +Стандартный http server на каждый запрос запускает handler в отдельной горутине (https://golang.org/pkg/net/http/#Serve), +поэтому доступ к состоянию нужно защитить. Например, это можно сделать с помощью [мьютекса](https://golang.org/pkg/sync/#Mutex). + +## Ссылки + +1. Пример web сервера и работы с общим состоянием: https://p.go.manytask.org/00-intro/lecture.slide#24 +2. протокол HTTP: https://ru.wikipedia.org/wiki/HTTP +3. http multiplexer: https://golang.org/pkg/net/http/#ServeMux +4. десериализация json'а: https://golang.org/pkg/encoding/json/#example_Unmarshal +5. генерация случайных данных: https://golang.org/pkg/math/rand/ diff --git a/urlshortener/main.go b/urlshortener/main.go new file mode 100644 index 0000000..cab7d3a --- /dev/null +++ b/urlshortener/main.go @@ -0,0 +1,7 @@ +// +build !solution + +package main + +func main() { + +} diff --git a/urlshortener/main_test.go b/urlshortener/main_test.go new file mode 100644 index 0000000..b67497b --- /dev/null +++ b/urlshortener/main_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "reflect" + "sync" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/tools/testtool" +) + +const importPath = "gitlab.com/slon/shad-go/urlshortener" + +var binCache testtool.BinCache + +func TestMain(m *testing.M) { + os.Exit(func() int { + var teardown testtool.CloseFunc + binCache, teardown = testtool.NewBinCache() + defer teardown() + + return m.Run() + }()) +} + +func startServer(t *testing.T) (port string, stop func()) { + binary, err := binCache.GetBinary(importPath) + require.NoError(t, err) + + port, err = testtool.GetFreePort() + require.NoError(t, err, "unable to get free port") + + cmd := exec.Command(binary, "-port", port) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + + require.NoError(t, cmd.Start()) + + done := make(chan error) + go func() { + done <- cmd.Wait() + }() + + stop = func() { + _ = cmd.Process.Kill() + <-done + } + + if err = testtool.WaitForPort(t, time.Second*5, port); err != nil { + stop() + } + + require.NoError(t, err) + return +} + +func add(t *testing.T, c *resty.Client, shortenerURL, request string) string { + type Response struct { + URL string `json:"url"` + Key string `json:"key"` + } + + resp, err := c.R(). + SetBody(map[string]interface{}{"url": request}). + SetResult(&Response{}). + Post(shortenerURL) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + response := resp.Result().(*Response) + require.Equal(t, request, response.URL) + require.Contains(t, resp.Header().Get("Content-Type"), "application/json") + + return response.Key +} + +func TestURLShortener_redirect(t *testing.T) { + port, stop := startServer(t) + defer stop() + + var mu sync.Mutex + redirects := make(map[string]struct{}) + + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + redirects[r.RequestURI] = struct{}{} + mu.Unlock() + _, _ = fmt.Fprintln(w, "hello") + })) + + client := resty.New() + addURL := fmt.Sprintf("http://localhost:%s/shorten", port) + + requests := make(map[string]struct{}) + for i := 0; i < 10; i++ { + path := "/" + testtool.RandomName() + req := redirectTarget.URL + path + requests[path] = struct{}{} + key := add(t, client, addURL, req) + + getURL := fmt.Sprintf("http://localhost:%s/go/%s", port, key) + resp, err := client.R().Get(getURL) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + } + + mu.Lock() + defer mu.Unlock() + + require.True(t, reflect.DeepEqual(requests, redirects), + fmt.Sprintf("expected: %+v, got: %+v", requests, redirects)) +} + +func TestURLShortener_badRequest(t *testing.T) { + port, stop := startServer(t) + defer stop() + + u := fmt.Sprintf("http://localhost:%s/shorten", port) + resp, err := resty.New().R(). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(`{"url":"abc}`)). + Post(u) + + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode()) +} + +func TestURLShortener_badKey(t *testing.T) { + port, stop := startServer(t) + defer stop() + + u := fmt.Sprintf("http://localhost:%s/go/%s", port, testtool.RandomName()) + resp, err := resty.New(). + SetRedirectPolicy(resty.RedirectPolicyFunc(func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + })).R(). + Get(u) + + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, resp.StatusCode()) +} + +func TestURLShortener_consistency(t *testing.T) { + port, stop := startServer(t) + defer stop() + + client := resty.New() + + get := func(originalURL, key string) { + getURL := fmt.Sprintf("http://localhost:%s/go/%s", port, key) + + resp, err := client. + SetRedirectPolicy(resty.RedirectPolicyFunc(func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + })).R(). + Get(getURL) + + require.NoError(t, err) + require.Equal(t, http.StatusFound, resp.StatusCode()) + require.Contains(t, resp.Header().Get("Location"), originalURL) + } + + var urls []string + for i := 0; i < 10; i++ { + urls = append(urls, testtool.RandomName()) + } + + keyToURL := make(map[string]string) + urlToKey := make(map[string]string) + + addURL := fmt.Sprintf("http://localhost:%s/shorten", port) + for _, u := range urls { + key := add(t, client, addURL, u) + + url, ok := keyToURL[key] + require.False(t, ok, fmt.Sprintf("duplicate key %s for urls [%s, %s]", key, u, url)) + + urlToKey[u] = key + keyToURL[key] = u + } + + for _, u := range urls { + get(u, urlToKey[u]) + } + + for _, u := range urls { + key := add(t, client, addURL, u) + + url, ok := keyToURL[key] + require.True(t, ok, fmt.Sprintf("different keys for the same url %s", u)) + require.Equal(t, url, u) + } +}