Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal file
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user