Added a /conditions/current endpoint with computed best-guess values for current conditions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-17 15:51:00 -05:00
parent e3aab86376
commit cb316c228a
19 changed files with 1146 additions and 358 deletions

View File

@@ -3,21 +3,30 @@ package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/conditions"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts"
"gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/timeparse"
)
type ObservationService interface {
GetCurrent(ctx context.Context) (observations.CurrentResponse, error)
GetRecent(ctx context.Context, count int, unitSystem string) (observations.Response, error)
}
type ConditionsService interface {
GetCurrent(ctx context.Context, unitSystem string) (conditions.Response, error)
}
type ForecastService interface {
GetByTimestamp(ctx context.Context, ts time.Time) (forecasts.Response, error)
GetByTimestamp(ctx context.Context, ts time.Time, unitSystem string) (forecasts.Response, error)
}
type AlertService interface {
@@ -25,32 +34,67 @@ type AlertService interface {
}
type Server struct {
obsSvc ObservationService
fcSvc ForecastService
alertsSvc AlertService
obsSvc ObservationService
conditionsSvc ConditionsService
fcSvc ForecastService
alertsSvc AlertService
}
func NewServer(obsSvc ObservationService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
func NewServer(obsSvc ObservationService, conditionsSvc ConditionsService, fcSvc ForecastService, alertsSvc AlertService) *Server {
return &Server{obsSvc: obsSvc, conditionsSvc: conditionsSvc, fcSvc: fcSvc, alertsSvc: alertsSvc}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/observations/current", s.handleObservationsCurrent)
mux.HandleFunc("/observations", s.handleObservations)
mux.HandleFunc("/conditions/current", s.handleConditionsCurrent)
mux.HandleFunc("/forecast", s.handleForecast)
mux.HandleFunc("/alerts/current", s.handleAlertsCurrent)
return mux
}
func (s *Server) handleObservationsCurrent(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleConditionsCurrent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
resp, err := s.obsSvc.GetCurrent(r.Context())
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current observations")
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.conditionsSvc.GetCurrent(r.Context(), unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch current conditions")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleObservations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET is supported")
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemMetric)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
count, err := parseCount(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
resp, err := s.obsSvc.GetRecent(r.Context(), count, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch observations")
return
}
@@ -63,6 +107,12 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
return
}
unitSystem, err := parseUnits(r, constants.UnitSystemUS)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
raw := r.URL.Query().Get("timestamp")
ts, err := timeparse.ParseTimestamp(raw)
if err != nil {
@@ -70,7 +120,7 @@ func (s *Server) handleForecast(w http.ResponseWriter, r *http.Request) {
return
}
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts)
resp, err := s.fcSvc.GetByTimestamp(r.Context(), ts, unitSystem)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch forecast")
return
@@ -94,6 +144,35 @@ func (s *Server) handleAlertsCurrent(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func parseCount(r *http.Request) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("count"))
if raw == "" {
return constants.DefaultObservationCount, nil
}
count, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
if count < 1 || count > constants.MaxObservationCount {
return 0, fmt.Errorf("count must be an integer between 1 and %d", constants.MaxObservationCount)
}
return count, nil
}
func parseUnits(r *http.Request, defaultSystem string) (string, error) {
raw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("units")))
if raw == "" {
return defaultSystem, nil
}
switch raw {
case constants.UnitSystemUS, constants.UnitSystemMetric:
return raw, nil
default:
return "", fmt.Errorf("units must be one of: us, metric")
}
}
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`