242 lines
5.2 KiB
Go
242 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|