package api import ( "encoding/json" "errors" "fmt" "net/http" "time" "github.com/michaeldileo/SafelyYouCodingChallenge/internal/store" ) // Server wires HTTP handlers to the in-memory store. // Constructed in main and passed dependencies explicitly (no DI container). type Server struct { store *store.Store } func NewServer(s *store.Store) *Server { return &Server{store: s} } // Handler returns the HTTP routes for this API. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.healthz) mux.HandleFunc("POST /api/v1/devices/{device_id}/heartbeat", s.postHeartbeat) mux.HandleFunc("POST /api/v1/devices/{device_id}/stats", s.postStats) mux.HandleFunc("GET /api/v1/devices/{device_id}/stats", s.getStats) return mux } type heartbeatRequest struct { SentAt time.Time `json:"sent_at"` } type uploadStatsRequest struct { SentAt time.Time `json:"sent_at"` // accepted for contract; ignored in average UploadTime int64 `json:"upload_time"` // nanoseconds } type getDeviceStatsResponse struct { AvgUploadTime string `json:"avg_upload_time"` Uptime float64 `json:"uptime"` } // OpenAPI NotFoundResponse / ErrorResponse both use {"msg": "..."}. type msgResponse struct { Msg string `json:"msg"` } func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } func (s *Server) postHeartbeat(w http.ResponseWriter, r *http.Request) { id := store.DeviceID(r.PathValue("device_id")) if !s.store.Known(id) { writeJSON(w, http.StatusNotFound, msgResponse{ Msg: fmt.Sprintf("Device ID %s not found", id), }) return } var req heartbeatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusInternalServerError, msgResponse{ Msg: "invalid request body", }) return } if err := s.store.AddHeartbeat(id, req.SentAt); err != nil { if errors.Is(err, store.ErrOutOfSequence) { writeJSON(w, http.StatusInternalServerError, msgResponse{ Msg: "Attempted to add a timestamp that was out of sequence or invalid.", }) return } writeJSON(w, http.StatusInternalServerError, msgResponse{Msg: err.Error()}) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) postStats(w http.ResponseWriter, r *http.Request) { id := store.DeviceID(r.PathValue("device_id")) if !s.store.Known(id) { writeJSON(w, http.StatusNotFound, msgResponse{ Msg: fmt.Sprintf("Device ID %s not found", id), }) return } var req uploadStatsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusInternalServerError, msgResponse{ Msg: "invalid request body", }) return } // sent_at is decoded above but intentionally unused for the average. s.store.AddUpload(id, time.Duration(req.UploadTime)) w.WriteHeader(http.StatusNoContent) } func (s *Server) getStats(w http.ResponseWriter, r *http.Request) { id := store.DeviceID(r.PathValue("device_id")) if !s.store.Known(id) { writeJSON(w, http.StatusNotFound, msgResponse{ Msg: fmt.Sprintf("Device ID %s not found", id), }) return } avg := s.store.AverageUploadTime(id) writeJSON(w, http.StatusOK, getDeviceStatsResponse{ AvgUploadTime: avg.String(), // HTTP-edge formatting (Q13) Uptime: s.store.Uptime(id), }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }