Initial commit: Task Manager REST API
This commit is contained in:
commit
82d1bba3f0
241
main.go
Normal file
241
main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user