diff --git a/cmd/main.go b/cmd/main.go index ee9482c..90fc7e1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,10 +2,11 @@ package main import ( "database/sql" - "fmt" "log" + "net/http" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" + todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler" todosqliterepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/sqlite" todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" _ "github.com/mattn/go-sqlite3" @@ -21,19 +22,28 @@ func main() { defer db.Close() // run migrations - migrate.Migrate(db); + migrate.Migrate(db) // create repos todoRepository := todosqliterepository.NewSqliteTodoRepository(db) // create services - todoService := todoservice.NewTodoService(&todoRepository) + todoService := todoservice.NewTodoService(todoRepository) - // create todo - todo := todoservice.NewTodo("clean dishes", false) - newTodo, err := todoService.CreateTodo(todo) - if err != nil { - log.Fatal(err) + // create mux + mux := http.NewServeMux() + + // register handlers + mux.Handle("GET /todo/{id}", todohandler.HandleTodoGet(todoService)) + mux.Handle("POST /todo", todohandler.HandleTodoCreate(todoService)) + mux.Handle("DELETE /todo/{id}", todohandler.HandleTodoDelete(todoService)) + mux.Handle("PUT /todo/{id}", todohandler.HandleTodoUpdate(todoService)) + + // create server + server := &http.Server{ + Addr: ":8080", + Handler: mux, } - fmt.Printf("ID of created todo: %d\n", newTodo.Id) + + server.ListenAndServe() } diff --git a/internal/todo/handler/create.go b/internal/todo/handler/create.go new file mode 100644 index 0000000..e28e928 --- /dev/null +++ b/internal/todo/handler/create.go @@ -0,0 +1,68 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type CreateTodoRequest struct { + Name string `json:"name"` + Done bool `json:"done"` +} + +func TodoFromCreateTodoRequest(todo CreateTodoRequest) service.Todo { + return service.Todo{Id: 0, Name: todo.Name, Done: todo.Done} +} + +type CreateTodoResponse struct { + Id int64 `json:"id"` + Name string `json:"name"` + Done bool `json:"done"` +} + +func CreateTodoResponseFromTodo(todo service.Todo) CreateTodoResponse { + return CreateTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done} +} + +func HandleTodoCreate(todoService TodoCreater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + createTodoRequest := CreateTodoRequest{} + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&createTodoRequest) + + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + todo, err := todoService.CreateTodo(TodoFromCreateTodoRequest(createTodoRequest)) + + if err != nil { + if err == service.ErrNotFound { + http.Error(w, "", http.StatusNotFound) + return + } + + http.Error(w, "", http.StatusInternalServerError) + return + } + + response := CreateTodoResponseFromTodo(todo) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", fmt.Sprintf("/todo/%d", response.Id)) + w.WriteHeader(http.StatusCreated) + + err = json.NewEncoder(w).Encode(response) + + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + return + } + + } +} diff --git a/internal/todo/handler/create_test.go b/internal/todo/handler/create_test.go new file mode 100644 index 0000000..8706a79 --- /dev/null +++ b/internal/todo/handler/create_test.go @@ -0,0 +1,121 @@ +package handler + +import ( + "bytes" + "encoding/json" + + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type MockTodoCreater struct { + CreateTodoFunc func(todo service.Todo) (service.Todo, error) +} + +func (tg *MockTodoCreater) CreateTodo(todo service.Todo) (service.Todo, error) { + return tg.CreateTodoFunc(todo) +} + +func TestCreateTodo(t *testing.T) { + t.Run("create todo", func(t *testing.T) { + createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false} + createdTodo := service.Todo{Id: 1, Name: "clean dishes", Done: false} + want := CreateTodoResponse{Id: 1, Name: "clean dishes", Done: false} + + service := MockTodoCreater{ + CreateTodoFunc: func(todo service.Todo) (service.Todo, error) { + return createdTodo, nil + }, + } + + handler := HandleTodoCreate(&service) + + requestBody, err := json.Marshal(createTodoRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", createTodoRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + if res.Code != http.StatusCreated { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusCreated) + } + + if res.Header().Get("Location") != "/todo/1" { + t.Errorf("did not get correct Location, got %q, want %q", res.Header().Get("Location"), "/todo/1") + } + + var got CreateTodoResponse + err = json.NewDecoder(res.Body).Decode(&got) + + if err != nil { + t.Fatalf("Unable to decode response from server %q into GetTodoResponse: %v", res.Body, err) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("got %+v, want %+v", got, want) + } + }) + + t.Run("returns 400 with bad json", func(t *testing.T) { + handler := HandleTodoCreate(nil) + + badStruct := struct { + Foo string + }{ + Foo: "bar", + } + + requestBody, err := json.Marshal(badStruct) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", badStruct, err) + } + + req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + if res.Code != http.StatusBadRequest { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 500 arbitrary errors", func(t *testing.T) { + createTodoRequest := CreateTodoRequest{Name: "clean dishes", Done: false} + + service := MockTodoCreater{ + CreateTodoFunc: func(todo service.Todo) (service.Todo, error) { + return service.Todo{}, errors.New("foo bar") + }, + } + + handler := HandleTodoCreate(&service) + + requestBody, err := json.Marshal(createTodoRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", createTodoRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/todo", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + if res.Code != http.StatusInternalServerError { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + +} diff --git a/internal/todo/handler/delete.go b/internal/todo/handler/delete.go new file mode 100644 index 0000000..a896f00 --- /dev/null +++ b/internal/todo/handler/delete.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + "strconv" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +func HandleTodoDelete(todoService TodoDeleter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idString := r.PathValue("id") + + id, err := strconv.ParseInt(idString, 10, 64) + + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + err = todoService.DeleteTodo(id) + + if err != nil { + if err == service.ErrNotFound { + http.Error(w, "", http.StatusNotFound) + return + } + + http.Error(w, "", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/todo/handler/delete_test.go b/internal/todo/handler/delete_test.go new file mode 100644 index 0000000..2ee2ee1 --- /dev/null +++ b/internal/todo/handler/delete_test.go @@ -0,0 +1,95 @@ +package handler + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type MockTodoDeleter struct { + DeleteTodoFunc func(id int64) error +} + +func (tg *MockTodoDeleter) DeleteTodo(id int64) error { + return tg.DeleteTodoFunc(id) +} + +func TestDeleteTodo(t *testing.T) { + t.Run("deletes existing todo", func(t *testing.T) { + service := MockTodoDeleter{ + DeleteTodoFunc: func(id int64) error { + return nil + }, + } + + handler := HandleTodoDelete(&service) + + req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusNoContent { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 400 with bad id", func(t *testing.T) { + handler := HandleTodoDelete(nil) + + req := httptest.NewRequest(http.MethodDelete, "/todo/hello", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "hello") + + handler(res, req) + + if res.Code != http.StatusBadRequest { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 404 for not found todo", func(t *testing.T) { + service := MockTodoDeleter{ + DeleteTodoFunc: func(id int64) error { + return service.ErrNotFound + }, + } + + handler := HandleTodoDelete(&service) + + req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusNotFound { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 500 for arbitrary errors", func(t *testing.T) { + service := MockTodoDeleter{ + DeleteTodoFunc: func(id int64) error { + return errors.New("foo bar") + }, + } + + handler := HandleTodoDelete(&service) + + req := httptest.NewRequest(http.MethodDelete, "/todo/1", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusInternalServerError { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + +} diff --git a/internal/todo/handler/get.go b/internal/todo/handler/get.go new file mode 100644 index 0000000..3da8cca --- /dev/null +++ b/internal/todo/handler/get.go @@ -0,0 +1,56 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type GetTodoResponse struct { + Id int64 `json:"id"` + Name string `json:"name"` + Done bool `json:"done"` +} + +func GetTodoResponseFromTodo(todo service.Todo) GetTodoResponse { + return GetTodoResponse{Id: todo.Id, Name: todo.Name, Done: todo.Done} +} + +func HandleTodoGet(todoService TodoGetter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idString := r.PathValue("id") + + id, err := strconv.ParseInt(idString, 10, 64) + + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + todo, err := todoService.GetTodo(id) + + if err != nil { + if err == service.ErrNotFound { + http.Error(w, "", http.StatusNotFound) + return + } + + http.Error(w, "", http.StatusInternalServerError) + return + } + + response := GetTodoResponseFromTodo(todo) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + err = json.NewEncoder(w).Encode(response) + + if err != nil { + http.Error(w, "", http.StatusInternalServerError) + return + } + } +} diff --git a/internal/todo/handler/get_test.go b/internal/todo/handler/get_test.go new file mode 100644 index 0000000..8e11ccb --- /dev/null +++ b/internal/todo/handler/get_test.go @@ -0,0 +1,112 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type MockTodoGetter struct { + GetTodoFunc func(id int64) (service.Todo, error) +} + +func (tg *MockTodoGetter) GetTodo(id int64) (service.Todo, error) { + return tg.GetTodoFunc(id) +} + +func TestGetTodo(t *testing.T) { + t.Run("gets existing todo", func(t *testing.T) { + todo := service.Todo{Id: 1, Name: "clean dishes", Done: false} + wantedTodo := GetTodoResponse{Id: 1, Name: "clean dishes", Done: false} + + service := MockTodoGetter{ + GetTodoFunc: func(id int64) (service.Todo, error) { + return todo, nil + }, + } + + handler := HandleTodoGet(&service) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/todo/%d", todo.Id), nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusOK { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + + var got GetTodoResponse + err := json.NewDecoder(res.Body).Decode(&got) + + if err != nil { + t.Fatalf("Unable to parse response from server %q into GetTodoResponse: %v", res.Body, err) + } + + if !reflect.DeepEqual(got, wantedTodo) { + t.Errorf("got %+v, want %+v", got, wantedTodo) + } + }) + + t.Run("returns 400 with bad id", func(t *testing.T) { + handler := HandleTodoGet(nil) + + req := httptest.NewRequest(http.MethodGet, "/todo/hello", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "hello") + + handler(res, req) + + if res.Code != http.StatusBadRequest { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 404 for not found todo", func(t *testing.T) { + service := MockTodoGetter{ + GetTodoFunc: func(id int64) (service.Todo, error) { + return service.Todo{}, service.ErrNotFound + }, + } + + handler := HandleTodoGet(&service) + + req := httptest.NewRequest(http.MethodGet, "/todo/1", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusNotFound { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 500 for arbitrary errors", func(t *testing.T) { + service := MockTodoGetter{ + GetTodoFunc: func(id int64) (service.Todo, error) { + return service.Todo{}, errors.New("foo bar") + }, + } + + handler := HandleTodoGet(&service) + + req := httptest.NewRequest(http.MethodGet, "/todo/1", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusInternalServerError { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + +} diff --git a/internal/todo/handler/handler.go b/internal/todo/handler/handler.go new file mode 100644 index 0000000..907ecb1 --- /dev/null +++ b/internal/todo/handler/handler.go @@ -0,0 +1,21 @@ +package handler + +import ( + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type TodoGetter interface { + GetTodo(id int64) (service.Todo, error) +} + +type TodoCreater interface { + CreateTodo(todo service.Todo) (service.Todo, error) +} + +type TodoDeleter interface { + DeleteTodo(id int64) error +} + +type TodoUpdater interface { + UpdateTodo(todo service.Todo) error +} diff --git a/internal/todo/handler/update.go b/internal/todo/handler/update.go new file mode 100644 index 0000000..d5a6f15 --- /dev/null +++ b/internal/todo/handler/update.go @@ -0,0 +1,56 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type UpdateTodoRequest struct { + Name string `json:"name"` + Done bool `json:"done"` +} + +func TodoFromUpdateTodoRequest(todo UpdateTodoRequest, id int64) service.Todo { + return service.Todo{Id: id, Name: todo.Name, Done: todo.Done} +} + +func HandleTodoUpdate(todoService TodoUpdater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + updateTodoRequest := UpdateTodoRequest{} + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&updateTodoRequest) + + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + idString := r.PathValue("id") + + id, err := strconv.ParseInt(idString, 10, 64) + + if err != nil { + http.Error(w, "", http.StatusBadRequest) + return + } + + err = todoService.UpdateTodo(TodoFromUpdateTodoRequest(updateTodoRequest, id)) + + if err != nil { + if err == service.ErrNotFound { + http.Error(w, "", http.StatusNotFound) + return + } + + http.Error(w, "", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/todo/handler/update_test.go b/internal/todo/handler/update_test.go new file mode 100644 index 0000000..b8db30b --- /dev/null +++ b/internal/todo/handler/update_test.go @@ -0,0 +1,119 @@ +package handler + +import ( + "bytes" + "encoding/json" + + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" +) + +type MockTodoUpdater struct { + UpdateTodoFunc func(todo service.Todo) error +} + +func (tg *MockTodoUpdater) UpdateTodo(todo service.Todo) error { + return tg.UpdateTodoFunc(todo) +} + +func TestUpdateTodo(t *testing.T) { + t.Run("update todo", func(t *testing.T) { + updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false} + + service := MockTodoUpdater{ + UpdateTodoFunc: func(todo service.Todo) error { + return nil + }, + } + + handler := HandleTodoUpdate(&service) + + requestBody, err := json.Marshal(updateTodoRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", updateTodoRequest, err) + } + + req := httptest.NewRequest(http.MethodPut, "/todo/1", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusOK { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 400 with bad json", func(t *testing.T) { + handler := HandleTodoUpdate(nil) + + badStruct := struct { + Foo string + }{ + Foo: "bar", + } + + requestBody, err := json.Marshal(badStruct) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", badStruct, err) + } + + req := httptest.NewRequest(http.MethodPut, "/todo/1", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + if res.Code != http.StatusBadRequest { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 400 with bad id", func(t *testing.T) { + handler := HandleTodoUpdate(nil) + + req := httptest.NewRequest(http.MethodPut, "/todo/hello", nil) + res := httptest.NewRecorder() + req.SetPathValue("id", "hello") + + handler(res, req) + + if res.Code != http.StatusBadRequest { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + + t.Run("returns 500 arbitrary errors", func(t *testing.T) { + updateTodoRequest := UpdateTodoRequest{Name: "clean dishes", Done: false} + + service := MockTodoUpdater{ + UpdateTodoFunc: func(todo service.Todo) error { + return errors.New("foo bar") + }, + } + + handler := HandleTodoUpdate(&service) + + requestBody, err := json.Marshal(updateTodoRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", updateTodoRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/todo/1", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + req.SetPathValue("id", "1") + + handler(res, req) + + if res.Code != http.StatusInternalServerError { + t.Errorf("did not get correct status, got %d, want %d", res.Code, http.StatusOK) + } + }) + +} diff --git a/internal/todo/repository/repository.go b/internal/todo/repository/repository.go index 172c0ba..81fb24f 100644 --- a/internal/todo/repository/repository.go +++ b/internal/todo/repository/repository.go @@ -17,10 +17,3 @@ type TodoRow struct { func NewTodoRow(name string, done bool) TodoRow { return TodoRow{Name: name, Done: done} } - -type TodoRepository interface { - Create(todo TodoRow) (TodoRow, error) - GetById(id int64) (TodoRow, error) - Update(todo TodoRow) error - Delete(id int64) error -} diff --git a/internal/todo/repository/sqlite/sqlite.go b/internal/todo/repository/sqlite/sqlite.go index 2d72d2c..5988b5c 100644 --- a/internal/todo/repository/sqlite/sqlite.go +++ b/internal/todo/repository/sqlite/sqlite.go @@ -10,8 +10,8 @@ type SqliteTodoRepository struct { db *sql.DB } -func NewSqliteTodoRepository(db *sql.DB) SqliteTodoRepository { - return SqliteTodoRepository{ +func NewSqliteTodoRepository(db *sql.DB) *SqliteTodoRepository { + return &SqliteTodoRepository{ db: db, } } diff --git a/internal/todo/service/service.go b/internal/todo/service/service.go index 3e55ab4..ccc3c8a 100644 --- a/internal/todo/service/service.go +++ b/internal/todo/service/service.go @@ -32,27 +32,18 @@ func (t Todo) Equal(todo Todo) bool { return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done } -type TodoGetter interface { - GetTodo(id int64) (Todo, error) -} - -type TodoCreater interface { - CreateTodo(todo Todo) (Todo, error) -} - -type TodoDeleter interface { - DeleteTodo(id int64) error -} - -type TodoUpdater interface { - UpdateTodo(todo Todo) error +type TodoRepository interface { + Create(todo repository.TodoRow) (repository.TodoRow, error) + GetById(id int64) (repository.TodoRow, error) + Update(todo repository.TodoRow) error + Delete(id int64) error } type TodoService struct { - repo repository.TodoRepository + repo TodoRepository } -func NewTodoService(todoRepo repository.TodoRepository) *TodoService { +func NewTodoService(todoRepo TodoRepository) *TodoService { return &TodoService{todoRepo} } diff --git a/makefile b/makefile index 198e0ab..ba5e5e3 100644 --- a/makefile +++ b/makefile @@ -4,5 +4,8 @@ run: build: go build -o tmp/main cmd/main.go +test: + go test ./... + clean: rm tmp/main habits.db