From 82d1bba3f0b28be844a17388bff0d21afa42e13a Mon Sep 17 00:00:00 2001 From: Zagar Date: Wed, 2 Sep 2026 07:55:42 +0700 Subject: [PATCH] Initial commit: Task Manager REST API --- go.mod | 3 + main.go | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 go.mod create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..efc76b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module taskmanager + +go 1.20 diff --git a/main.go b/main.go new file mode 100644 index 0000000..07c9ed3 --- /dev/null +++ b/main.go @@ -0,0 +1,241 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" +) + +// Task represents a single to-do item. +// Exactly two fields as required by the assignment schema. +type Task struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// store is a thread-safe in-memory collection of tasks. +type store struct { + mu sync.Mutex + tasks map[string]Task +} + +func newStore() *store { + return &store{ + tasks: make(map[string]Task), + } +} + +// generateID creates a random hex string to use as a task ID. +func generateID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + // Extremely unlikely, but fall back to something still unique-ish. + return fmt.Sprintf("id-%p", b) + } + return fmt.Sprintf("%x", b) +} + +func (s *store) create(name string) Task { + s.mu.Lock() + defer s.mu.Unlock() + + id := generateID() + // Guard against the (astronomically unlikely) case of a collision. + for { + if _, exists := s.tasks[id]; !exists { + break + } + id = generateID() + } + + t := Task{ID: id, Name: name} + s.tasks[id] = t + return t +} + +func (s *store) list() []Task { + s.mu.Lock() + defer s.mu.Unlock() + + // IMPORTANT: initialize as an empty slice, never a nil slice. + // A nil slice marshals to `null`, an empty slice marshals to `[]`. + result := []Task{} + for _, t := range s.tasks { + result = append(result, t) + } + return result +} + +func (s *store) get(id string) (Task, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + t, ok := s.tasks[id] + return t, ok +} + +func (s *store) update(id, name string) (Task, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + t, ok := s.tasks[id] + if !ok { + return Task{}, false + } + t.Name = name + s.tasks[id] = t + return t, true +} + +func (s *store) delete(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.tasks[id]; !ok { + return false + } + delete(s.tasks, id) + return true +} + +// ---- HTTP helpers ---- + +func writeJSON(w http.ResponseWriter, status int, body interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + // Encoding a well-known struct/slice here never fails in practice, + // so we don't need to handle an error beyond logging. + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("failed to encode response: %v", err) + } + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +type createOrUpdateRequest struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ---- Handlers ---- + +type api struct { + s *store +} + +// handleTasks handles requests to /tasks (no id). +func (a *api) handleTasks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, a.s.list()) + case http.MethodPost: + a.createTask(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// handleTaskByID handles requests to /tasks/{id}. +func (a *api) handleTaskByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/tasks/") + id = strings.Trim(id, "/") + if id == "" { + writeError(w, http.StatusNotFound, "task id required") + return + } + + switch r.Method { + case http.MethodGet: + a.getTask(w, id) + case http.MethodPut: + a.updateTask(w, r, id) + case http.MethodDelete: + a.deleteTask(w, id) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (a *api) createTask(w http.ResponseWriter, r *http.Request) { + var req createOrUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + // The client may not supply an id; if they do, it's ignored and + // a fresh one is generated regardless. + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + t := a.s.create(req.Name) + writeJSON(w, http.StatusCreated, t) +} + +func (a *api) getTask(w http.ResponseWriter, id string) { + t, ok := a.s.get(id) + if !ok { + writeError(w, http.StatusNotFound, "task not found") + return + } + writeJSON(w, http.StatusOK, t) +} + +func (a *api) updateTask(w http.ResponseWriter, r *http.Request, id string) { + var req createOrUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if strings.TrimSpace(req.Name) == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + t, ok := a.s.update(id, req.Name) + if !ok { + writeError(w, http.StatusNotFound, "task not found") + return + } + writeJSON(w, http.StatusOK, t) +} + +func (a *api) deleteTask(w http.ResponseWriter, id string) { + if !a.s.delete(id) { + writeError(w, http.StatusNotFound, "task not found") + return + } + // 204 No Content: no body, no Content-Type needed (rubric excludes it). + w.WriteHeader(http.StatusNoContent) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + a := &api{s: newStore()} + + mux := http.NewServeMux() + mux.HandleFunc("/tasks", a.handleTasks) + mux.HandleFunc("/tasks/", a.handleTaskByID) + + addr := "localhost:" + port + log.Printf("Task Manager API listening on http://%s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +}