diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..08c11d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/gopherdojo/dojo6 + +go 1.12 + +require ( + github.com/google/go-cmp v0.3.1 + github.com/pkg/errors v0.8.1 + golang.org/x/sync v0.0.0-20190423024810-112230192c58 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..968da45 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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= diff --git a/kadai3-2/en-ken/Readme.md b/kadai3-2/en-ken/Readme.md new file mode 100644 index 0000000..08eaa2c --- /dev/null +++ b/kadai3-2/en-ken/Readme.md @@ -0,0 +1,39 @@ +# 課題3-2 + +## 分割ダウンローダを作ろう + +- Rangeアクセスを用いる +- いくつかのゴルーチンでダウンロードしてマージする +- エラー処理を工夫する + - golang.org/x/sync/errgroupパッケージなどを使ってみる +- キャンセルが発生した場合の実装を行う + +## 使い方 + +``` +go get github.com/gopherdojo/dojo6/kadai3-2/en-ken/dl-mgr +div -n [goroutineの並列数(デフォルト:5)] -o [保存ファイル名(デフォルト:リモート名)] URL +``` + +## やったこと + +1. HEADリクエストしてAccept-Rangesヘッダの有無確認 +1. Accept-Rangesがあった場合、 + 1. 任意の分割数に応じて、goroutineごとの割当範囲を決めて、各goroutineでRange GET + 1. 部分ファイルに出力 +1. 全部ダウンロードできたら、ファイルをマージして部分ファイル削除 + +## 工夫した点 + +- 1つのgoroutineがダウンロードするデータが1MBを超えた場合、1MBごとにファイル出力する。 +- Rangeアクセスに対応していなかったら普通にダウンロードする。 +- すでにダウンロード済のデータがあった場合(そのパートのファイルがあった場合)、それを再利用する。 + +## 困っていること + +- `go test`のやり方がいまいちわかっていない。依存関係の解決の仕方が理解に至っていない。 + - `go test *.go`だと`export_test.go`が解決できない + - `go test ./`だと解決できる + - `go test ./...`すると`utils`以下がimport cycleで失敗してしまう + - `go test ./utils/*`だと`utils`だと問題ない + - `go test -cover ./utils/*`でちゃんとカバレッジがどれない diff --git a/kadai3-2/en-ken/dl-manager.go b/kadai3-2/en-ken/dl-manager.go new file mode 100644 index 0000000..83d02de --- /dev/null +++ b/kadai3-2/en-ken/dl-manager.go @@ -0,0 +1,124 @@ +package divdl + +import ( + "fmt" + "os" + + "golang.org/x/sync/errgroup" + + "github.com/gopherdojo/dojo6/kadai3-2/en-ken/utils" + "github.com/pkg/errors" +) + +// DlRange expresses range of Range request +type DlRange struct { + id int + from int64 + to int64 +} + +var maxRangeSize = int64(1024 * 1024) //1MB + +func divideIntoRanges(contentLength int64, numOfDivision int) (numOfRanges int, rngs [][]*DlRange) { + rngs = make([][]*DlRange, numOfDivision) + + var rngSize int64 + if contentLength%int64(numOfDivision) == 0 { + rngSize = contentLength / int64(numOfDivision) + } else { + rngSize = (contentLength + int64(numOfDivision)) / int64(numOfDivision) + } + if maxRangeSize < rngSize { + rngSize = maxRangeSize + } + + for j, pos := 0, int64(0); pos < contentLength; j++ { + for i := 0; i < numOfDivision && pos < contentLength; i++ { + id := j*numOfDivision + i + numOfRanges = id + 1 + // Last range + if contentLength-pos < rngSize { + rngs[i] = append(rngs[i], &DlRange{ + id: id, + from: int64(id) * rngSize, + to: contentLength - 1, + }) + pos = contentLength + break + } + + rngs[i] = append(rngs[i], &DlRange{ + id: id, + from: int64(id) * rngSize, + to: int64(id+1)*rngSize - 1, + }) + pos += rngSize + } + } + return +} + +// Do manages separately downloading. +func Do(url string, fileName string, numOfDivision int) error { + req, err := utils.NewRequest(url) + if err != nil { + return errors.WithStack(err) + } + + // Range request is not accepted + if !req.CanAcceptRangeRequest() { + data, err := req.Download() + if err != nil { + return err + } + + return utils.SaveFile(fileName, data) + } + + // Range request is accepted + n, rngs := divideIntoRanges(req.GetContentLength(), numOfDivision) + + var g errgroup.Group + for _, rList := range rngs { + rList := rList + g.Go(func() error { + for _, r := range rList { + tmpFileName := createPartialFileName(fileName, r.id) + + // Pass downloading if tmpFileName exists. + if fileExists(tmpFileName) { + continue + } + + data, err := req.DownloadPartially(r.from, r.to) + if err != nil { + return err + } + if err := utils.SaveFile(tmpFileName, data); err != nil { + return err + } + fmt.Printf("%v saved\n", tmpFileName) + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return errors.WithStack(err) + } + + files := make([]string, 0) + for i := 0; i < n; i++ { + files = append(files, createPartialFileName(fileName, i)) + } + return utils.MergeFiles(files, fileName) +} + +func createPartialFileName(fileName string, suffix int) string { + return fmt.Sprintf("%v.%v", fileName, suffix) +} + +func fileExists(fileName string) bool { + _, err := os.Stat(fileName) + return err == nil +} diff --git a/kadai3-2/en-ken/dl-manager_test.go b/kadai3-2/en-ken/dl-manager_test.go new file mode 100644 index 0000000..7af553d --- /dev/null +++ b/kadai3-2/en-ken/dl-manager_test.go @@ -0,0 +1,259 @@ +package divdl_test + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "path/filepath" + "regexp" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + divdl "github.com/gopherdojo/dojo6/kadai3-2/en-ken" +) + +func TestDivideIntoRanges(t *testing.T) { + + type testCase struct { + contentLength int64 + num int + expected [][]*divdl.TestRange + expectedNum int + } + + cases := []testCase{ + { + contentLength: 100, + num: 2, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: 49}, + }, + { + &divdl.TestRange{ID: 1, From: 50, To: 99}, + }, + }, + expectedNum: 2, + }, + { + contentLength: 1000, + num: 5, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: 199}, + }, + { + &divdl.TestRange{ID: 1, From: 200, To: 399}, + }, + { + &divdl.TestRange{ID: 2, From: 400, To: 599}, + }, + { + &divdl.TestRange{ID: 3, From: 600, To: 799}, + }, + { + &divdl.TestRange{ID: 4, From: 800, To: 999}, + }, + }, + expectedNum: 5, + }, + { + contentLength: 1001, + num: 5, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: 200}, + }, + { + &divdl.TestRange{ID: 1, From: 201, To: 401}, + }, + { + &divdl.TestRange{ID: 2, From: 402, To: 602}, + }, + { + &divdl.TestRange{ID: 3, From: 603, To: 803}, + }, + { + &divdl.TestRange{ID: 4, From: 804, To: 1000}, + }, + }, + expectedNum: 5, + }, + { + contentLength: 1005, + num: 5, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: 200}, + }, + { + &divdl.TestRange{ID: 1, From: 201, To: 401}, + }, + { + &divdl.TestRange{ID: 2, From: 402, To: 602}, + }, + { + &divdl.TestRange{ID: 3, From: 603, To: 803}, + }, + { + &divdl.TestRange{ID: 4, From: 804, To: 1004}, + }, + }, + expectedNum: 5, + }, + { + contentLength: divdl.MaxRangeSize*8 - 10, + num: 5, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: divdl.MaxRangeSize - 1}, + &divdl.TestRange{ID: 5, From: divdl.MaxRangeSize * 5, To: divdl.MaxRangeSize*6 - 1}, + }, + { + &divdl.TestRange{ID: 1, From: divdl.MaxRangeSize, To: divdl.MaxRangeSize*2 - 1}, + &divdl.TestRange{ID: 6, From: divdl.MaxRangeSize * 6, To: divdl.MaxRangeSize*7 - 1}, + }, + { + &divdl.TestRange{ID: 2, From: divdl.MaxRangeSize * 2, To: divdl.MaxRangeSize*3 - 1}, + &divdl.TestRange{ID: 7, From: divdl.MaxRangeSize * 7, To: divdl.MaxRangeSize*8 - 10 - 1}, + }, + { + &divdl.TestRange{ID: 3, From: divdl.MaxRangeSize * 3, To: divdl.MaxRangeSize*4 - 1}, + }, + { + &divdl.TestRange{ID: 4, From: divdl.MaxRangeSize * 4, To: divdl.MaxRangeSize*5 - 1}, + }, + }, + expectedNum: 8, + }, + { + contentLength: divdl.MaxRangeSize * 3, + num: 2, + expected: [][]*divdl.TestRange{ + { + &divdl.TestRange{ID: 0, From: 0, To: divdl.MaxRangeSize - 1}, + &divdl.TestRange{ID: 2, From: divdl.MaxRangeSize * 2, To: divdl.MaxRangeSize*3 - 1}, + }, + { + &divdl.TestRange{ID: 1, From: divdl.MaxRangeSize, To: divdl.MaxRangeSize*2 - 1}, + }, + }, + expectedNum: 3, + }, + } + + for i, c := range cases { + c := c + t.Run(fmt.Sprintf("case %v", i), func(t *testing.T) { + n, actual := divdl.DivideIntoRanges(c.contentLength, c.num) + + if !cmp.Equal(actual, c.expected) || n != c.expectedNum { + t.Errorf("failed. Diff:\n%v", cmp.Diff(actual, c.expected)) + } + }) + } +} + +func TestDo(t *testing.T) { + type testCase struct { + newRequestError bool + downloadError bool + canGetRange bool + } + + cases := []testCase{ + { + canGetRange: true, + }, + { + canGetRange: false, + }, + { + newRequestError: true, + }, + { + canGetRange: true, + downloadError: true, + }, + { + canGetRange: false, + downloadError: true, + }, + } + + bodyStr := fmt.Sprintf("%v%v%v%v%v", + "0000000000", + "1111111111", + "2222222222", + "3333333333", + "4444444444", + ) + + divdl.SetMaxRangeSize(10) + tmpDir, _ := ioutil.TempDir("", ".tmp") + fileName := filepath.Join(tmpDir, "test.txt") + + for i, c := range cases { + c := c + + var testHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "HEAD": + if c.newRequestError { + w.WriteHeader(http.StatusMethodNotAllowed) + fmt.Fprint(w, "") + return + } + + if c.canGetRange { + w.Header().Add("Accept-Ranges", "bytes") + } + w.Header().Add("Content-Length", fmt.Sprint(len(bodyStr))) + fmt.Print(w, "") + case "GET": + if c.downloadError { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "") + return + } + + rngStr := r.Header.Get("Range") + if rngStr != "" { + reg := regexp.MustCompile(`\d+`) + rngs := reg.FindAllString(rngStr, -1) + from, _ := strconv.ParseInt(rngs[0], 10, 0) + to, _ := strconv.ParseInt(rngs[1], 10, 0) + fmt.Fprint(w, bodyStr[from:to+1]) + } else { + fmt.Fprint(w, bodyStr) + } + } + }) + ts := httptest.NewServer(testHandler) + defer ts.Close() + + t.Run(fmt.Sprintf("case %v", i), func(t *testing.T) { + err := divdl.Do(ts.URL, fileName, 3) + if err != nil { + if !c.newRequestError && + !c.downloadError { + t.Errorf("Unexpected result: %v", err) + } + } else { + if c.newRequestError || + c.downloadError { + t.Errorf("Unexpected result: newRequestError->%v, downloadError->%v", + c.newRequestError, c.downloadError) + } + } + + actual, _ := ioutil.ReadFile(fileName) + if string(actual) != bodyStr { + t.Errorf("Downloading file is invalid data: %v", string(actual)) + } + + }) + } +} diff --git a/kadai3-2/en-ken/dl-mgr/main.go b/kadai3-2/en-ken/dl-mgr/main.go new file mode 100644 index 0000000..4ec025d --- /dev/null +++ b/kadai3-2/en-ken/dl-mgr/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path" + + divdl "github.com/gopherdojo/dojo6/kadai3-2/en-ken" + + "github.com/pkg/errors" +) + +// Do is I/F to divdl.Do +type Do func(url string, fileName string, numOfDivision int) error + +func outputError(err error) { + os.Stderr.Write([]byte(fmt.Sprintf("%v", errors.WithStack(err)))) +} + +func main() { + var ( + fileName string + numOfDivision int + ) + + flag.StringVar(&fileName, "o", "[remote-name]", "output file name") + flag.IntVar(&numOfDivision, "n", 5, "number of downloading in parallel") + flag.Parse() + url := flag.Arg(0) + // Set remote name as fileName + if fileName == "[remote-name]" { + fileName = path.Base(url) + } + + err := Do(divdl.Do)(url, fileName, numOfDivision) + if err != nil { + outputError(err) + return + } + + os.Stdout.Write([]byte("Downloading done.\n")) +} diff --git a/kadai3-2/en-ken/export_test.go b/kadai3-2/en-ken/export_test.go new file mode 100644 index 0000000..f80150d --- /dev/null +++ b/kadai3-2/en-ken/export_test.go @@ -0,0 +1,28 @@ +package divdl + +var DivideIntoRanges = func(contentLength int64, numOfDivision int) (int, [][]*TestRange) { + n, result := divideIntoRanges(contentLength, numOfDivision) + rngs := make([][]*TestRange, numOfDivision) + for i, rp := range result { + for _, r := range rp { + rngs[i] = append(rngs[i], &TestRange{ + ID: r.id, + From: r.from, + To: r.to, + }) + } + } + return n, rngs +} + +type TestRange struct { + ID int + From int64 + To int64 +} + +var MaxRangeSize = maxRangeSize + +func SetMaxRangeSize(size int64) { + maxRangeSize = size +} diff --git a/kadai3-2/en-ken/utils/fileutil.go b/kadai3-2/en-ken/utils/fileutil.go new file mode 100644 index 0000000..138017c --- /dev/null +++ b/kadai3-2/en-ken/utils/fileutil.go @@ -0,0 +1,54 @@ +package utils + +import ( + "fmt" + "io" + "os" + + "github.com/pkg/errors" +) + +// SaveFile saves data as fileName. +func SaveFile(fileName string, data []byte) error { + file, err := os.Create(fileName) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to open %v", fileName)) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return errors.WithStack(err) + } + return nil +} + +// MergeFiles merges separeted files. +func MergeFiles(inputFiles []string, outputFileName string) error { + + fw, err := os.Create(outputFileName) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to open output file: %v", outputFileName)) + } + defer fw.Close() + + for _, f := range inputFiles { + fr, err := os.Open(f) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to open [%v]", f)) + } + defer fr.Close() + + _, err = io.Copy(fw, fr) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to write data from %v", f)) + } + + if err := os.Remove(f); err != nil { + return errors.Wrap(err, fmt.Sprintf("Failed to delete %v", f)) + } + + } + + return nil +} diff --git a/kadai3-2/en-ken/utils/fileutil_test.go b/kadai3-2/en-ken/utils/fileutil_test.go new file mode 100644 index 0000000..b9c5558 --- /dev/null +++ b/kadai3-2/en-ken/utils/fileutil_test.go @@ -0,0 +1,76 @@ +package utils_test + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/gopherdojo/dojo6/kadai3-2/en-ken/utils" + + "github.com/google/go-cmp/cmp" +) + +var tmpDir string + +func TestMain(m *testing.M) { + tmpDir, _ = ioutil.TempDir("", ".tmp") + m.Run() +} + +func TestSaveFile(t *testing.T) { + file := filepath.Join(tmpDir, "test.txt") + + expected := []byte("foo\nbar\nbaz\n") + utils.SaveFile(file, expected) + + actual, _ := ioutil.ReadFile(file) + if string(actual) != string(expected) { + t.Errorf("failed. Diff:\n%v", cmp.Diff(actual, expected)) + } +} + +func TestMergeFiles(t *testing.T) { + strA := "0\n1\n2\n3\n4\n" + strB := "5\n6\n7\n8\n9\n" + fileInfo := []struct { + fileName string + data string + }{ + { + fileName: filepath.Join(tmpDir, "merged.txt.0"), + data: strA, + }, + { + fileName: filepath.Join(tmpDir, "merged.txt.1"), + data: strB, + }, + { + fileName: filepath.Join(tmpDir, "merged.txt.2"), + data: strA, + }, + { + fileName: filepath.Join(tmpDir, "merged.txt.3"), + data: strB, + }, + } + + var inputFiles []string + var expected string + for _, fi := range fileInfo { + if err := utils.SaveFile(fi.fileName, []byte(fi.data)); err != nil { + t.Errorf("SaveFile failed: %v", err) + } + inputFiles = append(inputFiles, fi.fileName) + expected += fi.data + } + + mergedFile := filepath.Join(tmpDir, "merged.txt") + if err := utils.MergeFiles(inputFiles, mergedFile); err != nil { + t.Errorf("MergeFiles failed: %v", err) + } + + actual, _ := ioutil.ReadFile(mergedFile) + if string(actual) != expected { + t.Errorf("MergeFiles makes invalid data: %v", string(actual)) + } +} diff --git a/kadai3-2/en-ken/utils/request.go b/kadai3-2/en-ken/utils/request.go new file mode 100644 index 0000000..90e536d --- /dev/null +++ b/kadai3-2/en-ken/utils/request.go @@ -0,0 +1,92 @@ +package utils + +import ( + "fmt" + "io/ioutil" + "net/http" + "strconv" + + "github.com/pkg/errors" +) + +// Request wraps downloading proceess. +type Request struct { + url string + contentLength int64 + canAcceptRangeRequest bool +} + +// NewRequest is a constructor of Request. +func NewRequest(url string) (*Request, error) { + resp, err := http.Head(url) + if err != nil { + return nil, errors.WithStack(err) + } + + // Header request before downloading + acceptRanges := resp.Header.Get("Accept-Ranges") + contentLength, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 0) + if err != nil { + return nil, errors.WithStack(err) + } + + return &Request{ + url: url, + contentLength: contentLength, + canAcceptRangeRequest: acceptRanges == "bytes", + }, nil +} + +// DownloadPartially downloads specified part of data from the specified url. +func (r *Request) DownloadPartially(from int64, to int64) ([]byte, error) { + if !r.canAcceptRangeRequest { + return nil, fmt.Errorf("This file cannot download with Range") + } + + // Set Range Header if Range request is accepted. + headers := map[string]string{} + headers["Range"] = fmt.Sprintf("bytes=%d-%d", from, to) + + return r.download(headers) +} + +// Download downloads data from the specified url. +func (r *Request) Download() ([]byte, error) { + return r.download(nil) +} + +func (r *Request) download(extraHeaders map[string]string) ([]byte, error) { + req, err := http.NewRequest("GET", r.url, nil) + if err != nil { + return nil, errors.WithStack(err) + } + for k, v := range extraHeaders { + req.Header.Add(k, v) + } + + resp, err := http.DefaultClient.Do(req) + if resp.Status[:1] != "2" { + return nil, errors.Errorf("Faild to request: %v", resp.StatusCode) + } + if err != nil { + return nil, errors.WithStack(err) + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll((resp.Body)) + if err != nil { + return nil, errors.WithStack(err) + } + + return body, nil +} + +// CanAcceptRangeRequest is getter of canAcceptRengeRequest +func (r *Request) CanAcceptRangeRequest() bool { + return r.canAcceptRangeRequest +} + +// GetContentLength is getter to content length of data. +func (r *Request) GetContentLength() int64 { + return r.contentLength +} diff --git a/kadai3-2/en-ken/utils/request_test.go b/kadai3-2/en-ken/utils/request_test.go new file mode 100644 index 0000000..6130116 --- /dev/null +++ b/kadai3-2/en-ken/utils/request_test.go @@ -0,0 +1,160 @@ +package utils + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gopherdojo/dojo6/kadai3-2/en-ken/utils" + + "github.com/google/go-cmp/cmp" +) + +func TestNewRequest(t *testing.T) { + cases := []struct { + canHead bool + canAcceptRange bool + }{ + { + canHead: true, + canAcceptRange: true, + }, + { + canHead: true, + canAcceptRange: false, + }, + { + canHead: false, + canAcceptRange: false, + }, + } + + for _, c := range cases { + c := c + + body := []byte("success") + var testHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !c.canHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if c.canAcceptRange { + w.Header().Add("Accept-Ranges", "bytes") + } + w.Header().Add("Content-Length", fmt.Sprint(len(body))) + fmt.Fprint(w, "") + }) + ts := httptest.NewServer(testHandler) + defer ts.Close() + + req, err := utils.NewRequest(ts.URL) + if c.canHead { + if err != nil { + t.Errorf("Unexpected result: %v", err) + } + } else { + if err == nil { + t.Error("Http status error epected.") + } + return + } + + if req == nil { + t.Errorf("Unexpected result") + } + if req.CanAcceptRangeRequest() != c.canAcceptRange { + t.Errorf("Unexpected result") + } + } +} + +func TestDownload(t *testing.T) { + bodyStr := fmt.Sprintf("%v%v%v%v%v", + "0000000000", + "1111111111", + "2222222222", + "3333333333", + "4444444444", + ) + + cases := []struct { + canGet bool + canGetRange bool + from int64 + to int64 + rngStr string + expected []byte + }{ + { + canGet: true, + canGetRange: true, + from: 10, + to: 19, + rngStr: "bytes=10-19", + expected: []byte(bodyStr[10:20]), + }, + { + canGet: true, + canGetRange: false, + expected: []byte(bodyStr), + }, + { + canGet: false, + }, + } + + for i, c := range cases { + c := c + bodyStr := bodyStr + + var testHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "HEAD": + if c.canGetRange { + w.Header().Add("Accept-Ranges", "bytes") + } + w.Header().Add("Content-Length", fmt.Sprint(len(bodyStr))) + fmt.Print(w, "") + case "GET": + if c.canGet { + rng := r.Header.Get("Range") + if rng != "" && rng == c.rngStr { + fmt.Fprint(w, bodyStr[c.from:c.to+1]) + } else { + fmt.Fprint(w, bodyStr) + } + } else { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "") + } + } + }) + ts := httptest.NewServer(testHandler) + defer ts.Close() + + t.Run(fmt.Sprintf("case %v", i), func(t *testing.T) { + req, _ := utils.NewRequest(ts.URL) + var ( + actual []byte + err error + ) + if req.CanAcceptRangeRequest() { + actual, err = req.DownloadPartially(c.from, c.to) + } else { + actual, err = req.Download() + } + + if !c.canGet { + if err == nil { + t.Error("Unexpected http status") + } + } + + if !cmp.Equal(c.expected, actual) { + t.Errorf("Unexpected response: Diff\n%v", cmp.Diff(c.expected, actual)) + } + }) + } +} diff --git a/kadai4/en-ken/kadai4/export_test.go b/kadai4/en-ken/kadai4/export_test.go new file mode 100644 index 0000000..f3e1aff --- /dev/null +++ b/kadai4/en-ken/kadai4/export_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "time" +) + +var GetFortune = getFortune +var Handler = handler + +func SetNow(n func() time.Time) { + now = n +} + +func SetFortunes(f []string) { + fortunes = f +} + +func SetNewEncoder(n NewEncoder) { + newEncoder = n +} diff --git a/kadai4/en-ken/kadai4/fortune.go b/kadai4/en-ken/kadai4/fortune.go new file mode 100644 index 0000000..7eebd47 --- /dev/null +++ b/kadai4/en-ken/kadai4/fortune.go @@ -0,0 +1,29 @@ +package main + +import ( + "math/rand" + "time" +) + +// For testing +var now = time.Now +var fortunes []string + +func init() { + rand.Seed(time.Now().UnixNano()) + fortunes = []string{"大吉", "吉", "中吉", "小吉", "末吉", "凶", "大凶"} +} + +func getFortune() string { + i := rand.Int() % len(fortunes) + f := fortunes[i] + + t := now() + if t.Month() == 1 { + if t.Day() >= 1 && t.Day() <= 3 { + f = "大吉" + } + } + + return f +} diff --git a/kadai4/en-ken/kadai4/fortune_test.go b/kadai4/en-ken/kadai4/fortune_test.go new file mode 100644 index 0000000..56b80f1 --- /dev/null +++ b/kadai4/en-ken/kadai4/fortune_test.go @@ -0,0 +1,64 @@ +package main_test + +import ( + "fmt" + "testing" + "time" + + main "github.com/gopherdojo/dojo6/kadai4/en-ken/kadai4" +) + +func TestGetLuck(t *testing.T) { + + cases := []struct { + now time.Time + expected string + }{ + { + now: time.Date(2018, time.December, 31, 0, 0, 0, 0, time.Local), + expected: "凶", + }, + { + now: time.Date(2018, time.December, 31, 23, 59, 59, 999, time.Local), + expected: "凶", + }, + { + now: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.Local), + expected: "大吉", + }, + { + now: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.Local), + expected: "大吉", + }, + { + now: time.Date(2019, time.January, 3, 0, 0, 0, 0, time.Local), + expected: "大吉", + }, + { + now: time.Date(2018, time.January, 3, 23, 59, 59, 999, time.Local), + expected: "大吉", + }, + { + now: time.Date(2019, time.January, 4, 0, 0, 0, 0, time.Local), + expected: "凶", + }, + } + + main.SetFortunes([]string{"凶", "凶", "凶"}) + + for i, c := range cases { + c := c + t.Run( + fmt.Sprintf("case[%v]", i), + func(t *testing.T) { + main.SetNow(func() time.Time { + return c.now + }) + + if actual := main.GetFortune(); actual != c.expected { + t.Errorf("actual:%v, expected:%v\n", actual, c.expected) + } + }) + } + +} diff --git a/kadai4/en-ken/kadai4/main.go b/kadai4/en-ken/kadai4/main.go new file mode 100644 index 0000000..dd31146 --- /dev/null +++ b/kadai4/en-ken/kadai4/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" +) + +// Encoder is I/F to json.Encoder +type Encoder interface { + Encode(v interface{}) error +} + +// NewEncoder is I/F to json.NewEncoder +type NewEncoder func(w io.Writer) Encoder + +var newEncoder NewEncoder + +func init() { + // For testing + newEncoder = func(w io.Writer) Encoder { + return json.NewEncoder(w) + } +} + +func main() { + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +} + +func handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "applicaiton/json; charset=utf-8") + + enc := newEncoder(w) + + resp := &struct { + Fortune string `json:"fortune"` + }{ + Fortune: getFortune(), + } + + if err := enc.Encode(resp); err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + } +} diff --git a/kadai4/en-ken/kadai4/main_test.go b/kadai4/en-ken/kadai4/main_test.go new file mode 100644 index 0000000..1b970bb --- /dev/null +++ b/kadai4/en-ken/kadai4/main_test.go @@ -0,0 +1,79 @@ +package main_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + main "github.com/gopherdojo/dojo6/kadai4/en-ken/kadai4" +) + +type data struct { + Fortune string `json:"fortune"` +} + +func TestServer(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(main.Handler)) + defer ts.Close() + + res, err := http.Get(ts.URL) + if err != nil { + t.Errorf("Response error:%v", err) + } + + var d data + if err = json.NewDecoder(res.Body).Decode(&d); err != nil { + t.Errorf("Data format error:%v", err) + } +} + +type EncoderMock struct { +} + +func NewEncoderMock(w io.Writer) main.Encoder { + return &EncoderMock{} +} + +func (enc *EncoderMock) Encode(v interface{}) error { + return fmt.Errorf("Internal server error") +} + +func TestServerInternalServerError(t *testing.T) { + main.SetNewEncoder(NewEncoderMock) + defer main.SetNewEncoder(main.NewEncoder(func(w io.Writer) main.Encoder { + return json.NewEncoder(w) + })) + + ts := httptest.NewServer(http.HandlerFunc(main.Handler)) + defer ts.Close() + + res, _ := http.Get(ts.URL) + if res.StatusCode != 500 { + t.Errorf("Response code error:%v", res.StatusCode) + } + +} + +func TestHandler(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + main.SetFortunes([]string{"大凶"}) + + main.Handler(w, r) + + if w.Code != 200 { + t.Errorf("Invalid code: %v", w.Code) + } + if w.Header().Get("Content-Type") != "applicaiton/json; charset=utf-8" { + t.Errorf("Insufficient headers: %v", w.Header()) + } + body := w.Body + dec := json.NewDecoder(body) + var d data + if err := dec.Decode(&d); err != nil || d.Fortune != "大凶" { + t.Errorf("Failed to decode: %v", err) + } +} diff --git a/kadai4/en-ken/reame.md b/kadai4/en-ken/reame.md new file mode 100644 index 0000000..6cd0128 --- /dev/null +++ b/kadai4/en-ken/reame.md @@ -0,0 +1,14 @@ +# 課題4 + +おみくじ API を作りましょう + +- JSON形式でおみくじの結果を返す +- 正月 (1/1-1/3) だけ大吉にする +- ハンドラのテストを書いてみる + +## 使い方 + +```go +go get github.com/gopherdojo/dojo6/kadai4/en-ken/kadai4 +kadai4 +```