Implement HTTP REST CRUD API

This commit is contained in:
2026-02-02 21:17:03 +02:00
parent b309ccd9cd
commit d3f29e3927
4 changed files with 316 additions and 0 deletions

249
api/api.go Normal file
View File

@@ -0,0 +1,249 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"woke/db"
)
// Notifier is called after mutating operations to notify the TUI.
type Notifier func()
// Server provides a REST API for alarm CRUD.
type Server struct {
store *db.Store
mux *http.ServeMux
notify Notifier
}
func New(store *db.Store, notify Notifier) *Server {
s := &Server{store: store, notify: notify}
s.mux = http.NewServeMux()
s.mux.HandleFunc("GET /api/alarms", s.listAlarms)
s.mux.HandleFunc("GET /api/alarms/{id}", s.getAlarm)
s.mux.HandleFunc("POST /api/alarms", s.createAlarm)
s.mux.HandleFunc("PUT /api/alarms/{id}", s.updateAlarm)
s.mux.HandleFunc("DELETE /api/alarms/{id}", s.deleteAlarm)
s.mux.HandleFunc("PATCH /api/alarms/{id}/toggle", s.toggleAlarm)
return s
}
func (s *Server) Handler() http.Handler {
return s.mux
}
type alarmRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Time string `json:"time"`
Trigger string `json:"trigger"`
SoundPath string `json:"sound_path"`
Enabled *bool `json:"enabled"`
}
type alarmResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Time string `json:"time"`
Trigger string `json:"trigger"`
SoundPath string `json:"sound_path"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastTriggered *string `json:"last_triggered"`
SnoozeCount int `json:"snooze_count"`
}
func toResponse(a db.Alarm) alarmResponse {
r := alarmResponse{
ID: a.ID,
Name: a.Name,
Description: a.Description,
Time: a.Time,
Trigger: a.Trigger,
SoundPath: a.SoundPath,
Enabled: a.Enabled,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
SnoozeCount: a.SnoozeCount,
}
if a.LastTriggered != nil {
t := a.LastTriggered.Format("2006-01-02T15:04:05Z")
r.LastTriggered = &t
}
return r
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func (s *Server) listAlarms(w http.ResponseWriter, r *http.Request) {
alarms, err := s.store.ListAlarms()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list alarms")
return
}
resp := make([]alarmResponse, len(alarms))
for i, a := range alarms {
resp[i] = toResponse(a)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) getAlarm(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
alarm, err := s.store.GetAlarm(id)
if err != nil {
writeError(w, http.StatusNotFound, "alarm not found")
return
}
writeJSON(w, http.StatusOK, toResponse(alarm))
}
func (s *Server) createAlarm(w http.ResponseWriter, r *http.Request) {
var req alarmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.Trigger == "" {
writeError(w, http.StatusBadRequest, "trigger is required")
return
}
alarm := db.Alarm{
Name: req.Name,
Description: req.Description,
Time: req.Time,
Trigger: req.Trigger,
SoundPath: req.SoundPath,
Enabled: true,
}
if alarm.SoundPath == "" {
alarm.SoundPath = "default"
}
if req.Enabled != nil {
alarm.Enabled = *req.Enabled
}
id, err := s.store.CreateAlarm(alarm)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create alarm")
return
}
created, err := s.store.GetAlarm(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read created alarm")
return
}
s.notify()
writeJSON(w, http.StatusCreated, toResponse(created))
}
func (s *Server) updateAlarm(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := s.store.GetAlarm(id)
if err != nil {
writeError(w, http.StatusNotFound, "alarm not found")
return
}
var req alarmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.Name != "" {
existing.Name = req.Name
}
if req.Description != "" {
existing.Description = req.Description
}
if req.Time != "" {
existing.Time = req.Time
}
if req.Trigger != "" {
existing.Trigger = req.Trigger
}
if req.SoundPath != "" {
existing.SoundPath = req.SoundPath
}
if req.Enabled != nil {
existing.Enabled = *req.Enabled
}
if err := s.store.UpdateAlarm(existing); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update alarm")
return
}
updated, _ := s.store.GetAlarm(id)
s.notify()
writeJSON(w, http.StatusOK, toResponse(updated))
}
func (s *Server) deleteAlarm(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if _, err := s.store.GetAlarm(id); err != nil {
writeError(w, http.StatusNotFound, "alarm not found")
return
}
if err := s.store.DeleteAlarm(id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete alarm")
return
}
s.notify()
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) toggleAlarm(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if _, err := s.store.GetAlarm(id); err != nil {
writeError(w, http.StatusNotFound, "alarm not found")
return
}
if err := s.store.ToggleAlarm(id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to toggle alarm")
return
}
toggled, _ := s.store.GetAlarm(id)
s.notify()
writeJSON(w, http.StatusOK, toResponse(toggled))
}