Files
weatherapi/internal/adapters/inbound/httpapi/query_timezone.go
Eric Rakestraw b3ac19a65d
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
Add timezone support to /forecast/hourly endpoint
2026-03-26 20:06:17 -05:00

141 lines
3.3 KiB
Go

// query_timezone.go parses and validates timezone query parameters.
// Layer: adapters/inbound/httpapi request binding helpers.
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
)
const maxUTCOffsetSeconds = 14 * 60 * 60
var usTimezoneAbbreviations = map[string]int{
"CDT": -5 * 60 * 60,
"CST": -6 * 60 * 60,
"EDT": -4 * 60 * 60,
"EST": -5 * 60 * 60,
"MDT": -6 * 60 * 60,
"MST": -7 * 60 * 60,
"PDT": -7 * 60 * 60,
"PST": -8 * 60 * 60,
}
var timezoneAliases = map[string]string{
"chicago": "America/Chicago",
"stl": "America/Chicago",
}
func parseTimezoneQuery(r *http.Request) (*time.Location, error) {
lower := strings.TrimSpace(r.URL.Query().Get("tz"))
upper := strings.TrimSpace(r.URL.Query().Get("TZ"))
if lower != "" && upper != "" && !strings.EqualFold(lower, upper) {
return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided")
}
raw := lower
if raw == "" {
raw = upper
}
if raw == "" {
return nil, nil
}
loc, err := parseTimezoneValue(raw)
if err != nil {
return nil, apierrors.InvalidParameter(err.Error())
}
return loc, nil
}
func parseTimezoneValue(raw string) (*time.Location, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok {
if err != nil {
return nil, err
}
return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil
}
if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok {
return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil
}
if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok {
raw = alias
}
loc, err := time.LoadLocation(raw)
if err != nil {
return nil, fmt.Errorf("tz must be a valid timezone")
}
return loc, nil
}
func parseUTCOffsetSeconds(raw string) (int, bool, error) {
if len(raw) < 2 {
return 0, false, nil
}
sign := raw[0]
if sign != '+' && sign != '-' {
return 0, false, nil
}
remainder := raw[1:]
parts := strings.Split(remainder, ":")
if len(parts) > 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
hours, err := strconv.Atoi(parts[0])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
if hours < 0 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes := 0
if len(parts) == 2 {
if len(parts[1]) != 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes, err = strconv.Atoi(parts[1])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
}
if minutes < 0 || minutes > 59 {
return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59")
}
offset := (hours * 60 * 60) + (minutes * 60)
if sign == '-' {
offset = -offset
}
if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds {
return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours")
}
return offset, true, nil
}
func formatUTCOffsetName(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes)
}