diff --git a/chapter17/section68/.air.toml b/chapter17/section68/.air.toml new file mode 100644 index 0000000..803bd97 --- /dev/null +++ b/chapter17/section68/.air.toml @@ -0,0 +1,33 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/main ." +# 'cmd'에서 바이너리 파일 지정 +bin = "tmp/main" + +# 80번 포트를 사용하도록 실행 시 인수를 지정 +full_bin = "APP_ENV=dev APP_USER=air ./tmp/main 80" + +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules", "_tools", "cert", "testutil"] +exclude_regex = ["_test.go"] +exclude_unchanged = true +follow_symlink = true +log = "air.log" +delay = 1000 +stop_on_error = true +send_interrupt = false +kill_delay = 500 + +[log] +time = false + +[color] +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +[misc] +clean_on_exit = true diff --git a/chapter17/section68/.dockerignore b/chapter17/section68/.dockerignore new file mode 100644 index 0000000..a09d331 --- /dev/null +++ b/chapter17/section68/.dockerignore @@ -0,0 +1,2 @@ +.git +.DS_Store \ No newline at end of file diff --git a/chapter17/section68/Dockerfile b/chapter17/section68/Dockerfile new file mode 100644 index 0000000..030ab8e --- /dev/null +++ b/chapter17/section68/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +# 최신 버전으로 수정 ****** +FROM golang:1.23.1 AS deploy-builder + +WORKDIR /app + +# go.mod, go.sum 파일 복사 및 의존성 다운로드 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# 바이너리 빌드 +RUN go build -trimpath -ldflags "-w -s" -o app + +#-------------------------------- +# Deployment stage +# 최신 GLIBC 버전을 사용하기 위해 우분투로 교체 + +FROM ubuntu:latest as deploy + +RUN apt-get update && apt-get install -y libgcc-s1 + +# 빌드된 바이너리를 복사 +COPY --from=deploy-builder /app/app . + +# 실행 명령 +CMD ["./app"] + +#-------------------------------- +# Development stage with Air for live reloading + +# ******** 최신 버전으로 수정 *********** +FROM golang:1.23.1 as dev +WORKDIR /app +RUN go install github.com/air-verse/air@latest +CMD ["air"] diff --git a/chapter17/section68/Makefile b/chapter17/section68/Makefile new file mode 100644 index 0000000..e33175e --- /dev/null +++ b/chapter17/section68/Makefile @@ -0,0 +1,28 @@ +.PHONY: help build build-local up down logs ps test +.DEFAULT_GOAL := help + +DOCKER_TAG := latest +build: ## 배포용 도커 이미지 빌드 + 내 모듈 이름에 맞게 수정 ********** + docker build -t myeunee/golangstudy-chapter17-section68:${DOCKER_TAG} --target deploy ./ + +build-local: ## 로컬 환경용 도커 이미지 빌드 + docker compose build --no-cache + +up: ## 자동 새로고침을 사용한 도커 컴포즈 실행 + docker compose up -d + +down: ## 도커 컴포즈 종료 + docker compose down + +logs: ## 도커 컴포즈 로그 출력 + docker compose logs -f + +ps: ## 컨테이너 상태 확인 + docker compose ps + +test: ## 테스트 실행 + go test -race -shuffle=on ./... + +help: ## 옵션 보기 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' \ No newline at end of file diff --git a/chapter17/section68/config/config.go b/chapter17/section68/config/config.go new file mode 100644 index 0000000..de1fab7 --- /dev/null +++ b/chapter17/section68/config/config.go @@ -0,0 +1,18 @@ +package config + +import ( + "github.com/caarlos0/env/v6" +) + +type Config struct { + Env string `env:"TODO_ENV" envDefault:"dev"` + Port int `env:"PORT" envDefault:"80"` +} + +func New() (*Config, error) { + cfg := &Config{} + if err := env.Parse(cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/chapter17/section68/config/config_test.go b/chapter17/section68/config/config_test.go new file mode 100644 index 0000000..0cac651 --- /dev/null +++ b/chapter17/section68/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "fmt" + "testing" +) + +func TestNew(t *testing.T) { + wantPort := 3333 + t.Setenv("PORT", fmt.Sprint(wantPort)) + + got, err := New() + if err != nil { + t.Fatalf("cannot create config: %v", err) + } + if got.Port != wantPort { + t.Errorf("want %d, but %d", wantPort, got.Port) + } + wantEnv := "dev" + if got.Env != wantEnv { + t.Errorf("want %s, but %s", wantEnv, got.Env) + } +} diff --git a/chapter17/section68/docker-compose.yml b/chapter17/section68/docker-compose.yml new file mode 100644 index 0000000..521ffd7 --- /dev/null +++ b/chapter17/section68/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" +services: + app: + image: gotodo + build: + args: + - target=dev + environment: + TODO_ENV: dev + PORT: 8080 + volumes: + - .:/app + ports: + - "18000:8080" \ No newline at end of file diff --git a/chapter17/section68/entity/task.go b/chapter17/section68/entity/task.go new file mode 100644 index 0000000..accde7c --- /dev/null +++ b/chapter17/section68/entity/task.go @@ -0,0 +1,21 @@ +package entity + +import "time" + +type TaskID int64 +type TaskStatus string + +const ( + TaskStatusTodo TaskStatus = "todo" + TaskStatusDoing TaskStatus = "doing" + TaskStatusDone TaskStatus = "done" +) + +type Task struct { + ID TaskID `json:"id"` + Title string `json:"title"` + Status TaskStatus `json:"status" ` + Created time.Time `json:"created"` +} + +type Tasks []*Task diff --git a/chapter17/section68/go.mod b/chapter17/section68/go.mod new file mode 100644 index 0000000..14f883b --- /dev/null +++ b/chapter17/section68/go.mod @@ -0,0 +1,21 @@ +module github.com/myeunee/GolangStudy/chapter17/section68 + +go 1.23.1 + +require ( + github.com/caarlos0/env/v6 v6.10.1 + github.com/go-playground/validator/v10 v10.22.1 + github.com/google/go-cmp v0.6.0 + golang.org/x/sync v0.8.0 +) + +require ( + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/chapter17/section68/go.sum b/chapter17/section68/go.sum new file mode 100644 index 0000000..d0df2a6 --- /dev/null +++ b/chapter17/section68/go.sum @@ -0,0 +1,34 @@ +github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/chapter17/section68/handler/add_task.go b/chapter17/section68/handler/add_task.go new file mode 100644 index 0000000..e5790eb --- /dev/null +++ b/chapter17/section68/handler/add_task.go @@ -0,0 +1,53 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-playground/validator/v10" + "github.com/myeunee/GolangStudy/chapter17/section68/entity" + "github.com/myeunee/GolangStudy/chapter17/section68/store" +) + +type AddTask struct { + Store *store.TaskStore + Validator *validator.Validate +} + +func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var b struct { + Title string `json:"title" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusInternalServerError) + return + } + err := validator.New().Struct(b) + if err != nil { + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + + t := &entity.Task{ + Title: b.Title, + Status: "todo", + Created: time.Now(), + } + id, err := store.Tasks.Add(t) + if err != nil { + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusInternalServerError) + return + } + rsp := struct { + ID int `json:"id"` + }{ID: int(id)} + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/chapter17/section68/handler/add_task_test.go b/chapter17/section68/handler/add_task_test.go new file mode 100644 index 0000000..178b0ea --- /dev/null +++ b/chapter17/section68/handler/add_task_test.go @@ -0,0 +1,62 @@ +package handler + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/myeunee/GolangStudy/chapter17/section68/entity" + "github.com/myeunee/GolangStudy/chapter17/section68/store" + "github.com/myeunee/GolangStudy/chapter17/section68/testutil" +) + +func TestAddTask(t *testing.T) { + type want struct { + status int + rspFile string + } + tests := map[string]struct { + reqFile string + want want + }{ + "ok": { + reqFile: "testdata/add_task/ok_req.json.golden", + want: want{ + status: http.StatusOK, + rspFile: "testdata/add_task/ok_rsp.json.golden", + }, + }, + "badRequest": { + reqFile: "testdata/add_task/bad_req.json.golden", + want: want{ + status: http.StatusBadRequest, + rspFile: "testdata/add_task/bad_rsp.json.golden", + }, + }, + } + for n, tt := range tests { + tt := tt + t.Run(n, func(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + r := httptest.NewRequest( + http.MethodPost, + "/tasks", + bytes.NewReader(testutil.LoadFile(t, tt.reqFile)), + ) + + sut := AddTask{Store: &store.TaskStore{ + Tasks: map[entity.TaskID]*entity.Task{}, + }, Validator: validator.New()} + sut.ServeHTTP(w, r) + + resp := w.Result() + testutil.AssertResponse(t, + resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile), + ) + }) + } +} diff --git a/chapter17/section68/handler/response.go b/chapter17/section68/handler/response.go new file mode 100644 index 0000000..3a0598a --- /dev/null +++ b/chapter17/section68/handler/response.go @@ -0,0 +1,34 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type ErrResponse struct { + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + bodyBytes, err := json.Marshal(body) + if err != nil { + fmt.Printf("encode response error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + rsp := ErrResponse{ + Message: http.StatusText(http.StatusInternalServerError), + } + if err := json.NewEncoder(w).Encode(rsp); err != nil { + fmt.Printf("write error response error: %v", err) + } + return + } + + w.WriteHeader(status) + if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil { + fmt.Printf("write response error: %v", err) + } +} diff --git a/chapter17/section68/handler/testdata/add_task/bad_req.json.golden b/chapter17/section68/handler/testdata/add_task/bad_req.json.golden new file mode 100644 index 0000000..2916c24 --- /dev/null +++ b/chapter17/section68/handler/testdata/add_task/bad_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} \ No newline at end of file diff --git a/chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden b/chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden new file mode 100644 index 0000000..5103587 --- /dev/null +++ b/chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag" +} \ No newline at end of file diff --git a/chapter17/section68/handler/testdata/add_task/ok_req.json.golden b/chapter17/section68/handler/testdata/add_task/ok_req.json.golden new file mode 100644 index 0000000..29cc3f9 --- /dev/null +++ b/chapter17/section68/handler/testdata/add_task/ok_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} \ No newline at end of file diff --git a/chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden b/chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden new file mode 100644 index 0000000..ab3697f --- /dev/null +++ b/chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "id": 1 +} \ No newline at end of file diff --git a/chapter17/section68/main.go b/chapter17/section68/main.go new file mode 100644 index 0000000..3af2357 --- /dev/null +++ b/chapter17/section68/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + "log" + "net" + "os" + + "github.com/myeunee/GolangStudy/chapter17/section68/config" +) + +func main() { + if err := run(context.Background()); err != nil { + log.Printf("failed to terminated server: %v", err) + os.Exit(1) + } +} + +// 리팩토링한 run 함수 +func run(ctx context.Context) error { + cfg, err := config.New() + if err != nil { + return err + } + l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port)) + if err != nil { + log.Fatalf("failed to listen port %d: %v", cfg.Port, err) + } + url := fmt.Sprintf("http://%s", l.Addr().String()) + log.Printf("start with: %v", url) + mux := NewMux() + s := NewServer(l, mux) + return s.Run(ctx) +} diff --git a/chapter17/section68/mux.go b/chapter17/section68/mux.go new file mode 100644 index 0000000..d6216d0 --- /dev/null +++ b/chapter17/section68/mux.go @@ -0,0 +1,13 @@ +package main + +import "net/http" + +func NewMux() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // 정적 분석 오류를 회피하기 위해 명시적으로 반환값을 버린다. + _, _ = w.Write([]byte(`{"status": "ok"}`)) + }) + return mux +} diff --git a/chapter17/section68/mux_test.go b/chapter17/section68/mux_test.go new file mode 100644 index 0000000..f2892e9 --- /dev/null +++ b/chapter17/section68/mux_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewMux(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/health", nil) + sut := NewMux() + sut.ServeHTTP(w, r) + resp := w.Result() + t.Cleanup(func() { _ = resp.Body.Close() }) + if resp.StatusCode != http.StatusOK { + t.Error("want status code 200, but", resp.StatusCode) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + want := `{"status": "ok"}` + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } +} diff --git a/chapter17/section68/server.go b/chapter17/section68/server.go new file mode 100644 index 0000000..26370f6 --- /dev/null +++ b/chapter17/section68/server.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "golang.org/x/sync/errgroup" +) + +type Server struct { + srv *http.Server + l net.Listener +} + +func NewServer(l net.Listener, mux http.Handler) *Server { + return &Server{ + srv: &http.Server{Handler: mux}, + l: l, + } +} + +func (s *Server) Run(ctx context.Context) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + // http.ErrServerClosed는 + // http.Server.Shutdown()이 정상 종료한 것을 보여주는 것으로 아무 문제 없음 + if err := s.srv.Serve(s.l); err != nil && + err != http.ErrServerClosed { + log.Printf("failed to close: %+v", err) + return err + } + return nil + }) + + <-ctx.Done() + if err := s.srv.Shutdown(context.Background()); err != nil { + log.Printf("failed to shutdown: %+v", err) + } + // 정상 종료를 기다림 + return eg.Wait() +} diff --git a/chapter17/section68/server_test.go b/chapter17/section68/server_test.go new file mode 100644 index 0000000..05f99d2 --- /dev/null +++ b/chapter17/section68/server_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "testing" + + "golang.org/x/sync/errgroup" +) + +func TestServer_Run(t *testing.T) { + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("failed to listen port %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + eg, ctx := errgroup.WithContext(ctx) + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:]) + }) + + eg.Go(func() error { + s := NewServer(l, mux) + return s.Run(ctx) + }) + + in := "message" + url := fmt.Sprintf("http://%s/%s", l.Addr().String(), in) + // 어떤 포트로 리슨하고 있는지 확인 + t.Logf("try request to %q", url) + rsp, err := http.Get(url) + if err != nil { + t.Errorf("failed to get: %+v", err) + } + defer rsp.Body.Close() + got, err := io.ReadAll(rsp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + // http 서버의 반환값을 검증 + want := fmt.Sprintf("Hello, %s!", in) + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } + // run함수에 종료 알림을 전송 + cancel() + // run함수의 반환값을 검증 + if err := eg.Wait(); err != nil { + t.Fatal(err) + } +} diff --git a/chapter17/section68/store/store.go b/chapter17/section68/store/store.go new file mode 100644 index 0000000..a029893 --- /dev/null +++ b/chapter17/section68/store/store.go @@ -0,0 +1,34 @@ +package store + +import ( + "errors" + + "github.com/myeunee/GolangStudy/chapter17/section68/entity" +) + +var ( + Tasks = &TaskStore{Tasks: map[entity.TaskID]*entity.Task{}} + + ErrNotFound = errors.New("not found") +) + +type TaskStore struct { + // 동작 확인용이므로 일부러 export하고 있음 + LastID entity.TaskID + Tasks map[entity.TaskID]*entity.Task +} + +func (ts *TaskStore) Add(t *entity.Task) (entity.TaskID, error) { + ts.LastID++ + t.ID = ts.LastID + ts.Tasks[t.ID] = t + return t.ID, nil +} + +// All은 정렬이 끝난 태스크 목록을 반환함 +func (ts *TaskStore) Get(id entity.TaskID) (*entity.Task, error) { + if ts, ok := ts.Tasks[id]; ok { + return ts, nil + } + return nil, ErrNotFound +} diff --git a/chapter17/section68/testutil/handler.go b/chapter17/section68/testutil/handler.go new file mode 100644 index 0000000..a4df494 --- /dev/null +++ b/chapter17/section68/testutil/handler.go @@ -0,0 +1,53 @@ +package testutil + +import ( + "encoding/json" + "io" + "net/http" + "os" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func AssertJSON(t *testing.T, want, got []byte) { + t.Helper() + + var jw, jg any + if err := json.Unmarshal(want, &jw); err != nil { + t.Fatalf("cannot unmarshal want %q: %v", want, err) + } + if err := json.Unmarshal(got, &jg); err != nil { + t.Fatalf("cannot unmarshal got %q: %v", got, err) + } + if diff := cmp.Diff(jg, jw); diff != "" { + t.Errorf("got differs: (-got +want)\n%s", diff) + } +} +func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) { + t.Helper() + t.Cleanup(func() { _ = got.Body.Close() }) + gb, err := io.ReadAll(got.Body) + if err != nil { + t.Fatal(err) + } + if got.StatusCode != status { + t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb) + } + + if len(gb) == 0 && len(body) == 0 { + // 어느쪽이든 응답바디가 없으므로 AssertJSON을 호출할 필요가 없음 + return + } + AssertJSON(t, body, gb) +} + +func LoadFile(t *testing.T, path string) []byte { + t.Helper() + + bt, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read from %q: %v", path, err) + } + return bt +}