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:
@@ -394,6 +394,148 @@ func TestForecastPrecisionTwo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastTimezoneOffsetUppercaseTZConvertsAllTimes(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC)
|
||||
updatedAt := issuedAt.Add(30 * time.Minute)
|
||||
periodStart := issuedAt.Add(time.Hour)
|
||||
periodEnd := periodStart.Add(time.Hour)
|
||||
|
||||
h := newHandler(t, &fakeService{
|
||||
forecast: &model.WeatherForecastRun{
|
||||
Product: model.ForecastProductHourly,
|
||||
IssuedAt: issuedAt,
|
||||
UpdatedAt: &updatedAt,
|
||||
Periods: []model.WeatherForecastPeriod{{
|
||||
StartTime: periodStart,
|
||||
EndTime: periodEnd,
|
||||
ConditionCode: model.WMOUnknown,
|
||||
}},
|
||||
},
|
||||
}, "/forecast/hourly")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?TZ=-5", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
payload := decodeForecastTimePayload(t, w)
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
|
||||
if payload.Data.UpdatedAt == nil {
|
||||
t.Fatalf("expected updatedAt in payload")
|
||||
}
|
||||
assertOffsetSeconds(t, *payload.Data.UpdatedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Periods[0].EndTime, -5*60*60)
|
||||
if !payload.Data.IssuedAt.UTC().Equal(issuedAt) {
|
||||
t.Fatalf("expected issuedAt instant to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastTimezoneAbbreviationCDT(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC)
|
||||
h := newHandler(t, &fakeService{
|
||||
forecast: &model.WeatherForecastRun{
|
||||
Product: model.ForecastProductHourly,
|
||||
IssuedAt: issuedAt,
|
||||
Periods: []model.WeatherForecastPeriod{{
|
||||
StartTime: issuedAt,
|
||||
EndTime: issuedAt.Add(time.Hour),
|
||||
ConditionCode: model.WMOUnknown,
|
||||
}},
|
||||
},
|
||||
}, "/forecast/hourly")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
payload := decodeForecastTimePayload(t, w)
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
|
||||
}
|
||||
|
||||
func TestForecastTimezoneCityAliasChicago(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
h := newHandler(t, &fakeService{
|
||||
forecast: &model.WeatherForecastRun{
|
||||
Product: model.ForecastProductHourly,
|
||||
IssuedAt: issuedAt,
|
||||
Periods: []model.WeatherForecastPeriod{{
|
||||
StartTime: issuedAt,
|
||||
EndTime: issuedAt.Add(time.Hour),
|
||||
ConditionCode: model.WMOUnknown,
|
||||
}},
|
||||
},
|
||||
}, "/forecast/hourly")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=Chicago", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
payload := decodeForecastTimePayload(t, w)
|
||||
assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60)
|
||||
}
|
||||
|
||||
func TestForecastTimezoneInvalidValueRejected(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{
|
||||
forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
|
||||
}, "/forecast/hourly")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=not-a-timezone", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var env apierrors.Envelope
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter {
|
||||
t.Fatalf("expected invalid_parameter code, got %+v", env.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastTimezoneConflictingKeyValuesRejected(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{
|
||||
forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()},
|
||||
}, "/forecast/hourly")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT&TZ=EST", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsRejectTimezoneQueryParameter(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{}, "/conditions/current")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/conditions/current?tz=CDT", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||
h := newHandler(t, &fakeService{}, "/conditions/current")
|
||||
|
||||
@@ -629,3 +771,35 @@ func testRenderers(t *testing.T) *render.Registry {
|
||||
func float64Ptr(v float64) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
type forecastTimePayload struct {
|
||||
Data struct {
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
Periods []struct {
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
} `json:"periods"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload {
|
||||
t.Helper()
|
||||
|
||||
var payload forecastTimePayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode forecast payload: %v", err)
|
||||
}
|
||||
if len(payload.Data.Periods) == 0 {
|
||||
t.Fatalf("expected non-empty periods")
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
|
||||
t.Helper()
|
||||
_, got := ts.Zone()
|
||||
if got != want {
|
||||
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
func forecastDefinition(svc Service) endpoint.Definition {
|
||||
return endpoint.GET(
|
||||
"/forecast/hourly",
|
||||
bindPrecisionQuery,
|
||||
bindForecastPrecisionQuery,
|
||||
func(ctx context.Context, req precisionQueryRequest) (any, error) {
|
||||
run, err := svc.LatestHourlyForecast(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision)}, nil
|
||||
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil
|
||||
},
|
||||
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),
|
||||
|
||||
@@ -51,7 +51,7 @@ type WeatherForecastPeriodUS struct {
|
||||
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
|
||||
}
|
||||
|
||||
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) any {
|
||||
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any {
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
||||
out := WeatherForecastRunUS{
|
||||
LocationID: run.LocationID,
|
||||
LocationName: run.LocationName,
|
||||
IssuedAt: run.IssuedAt,
|
||||
UpdatedAt: copyTimePtr(run.UpdatedAt),
|
||||
IssuedAt: inLocationTime(run.IssuedAt, tz),
|
||||
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
|
||||
Product: run.Product,
|
||||
Latitude: copyFloat64Ptr(run.Latitude),
|
||||
Longitude: copyFloat64Ptr(run.Longitude),
|
||||
@@ -69,8 +69,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
||||
}
|
||||
for _, p := range run.Periods {
|
||||
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
|
||||
StartTime: p.StartTime,
|
||||
EndTime: p.EndTime,
|
||||
StartTime: inLocationTime(p.StartTime, tz),
|
||||
EndTime: inLocationTime(p.EndTime, tz),
|
||||
Name: p.Name,
|
||||
IsDay: copyBoolPtr(p.IsDay),
|
||||
ConditionCode: p.ConditionCode,
|
||||
@@ -103,8 +103,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
||||
out := model.WeatherForecastRun{
|
||||
LocationID: run.LocationID,
|
||||
LocationName: run.LocationName,
|
||||
IssuedAt: run.IssuedAt,
|
||||
UpdatedAt: copyTimePtr(run.UpdatedAt),
|
||||
IssuedAt: inLocationTime(run.IssuedAt, tz),
|
||||
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
|
||||
Product: run.Product,
|
||||
Latitude: copyFloat64Ptr(run.Latitude),
|
||||
Longitude: copyFloat64Ptr(run.Longitude),
|
||||
@@ -114,8 +114,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
||||
|
||||
for _, p := range run.Periods {
|
||||
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
|
||||
StartTime: p.StartTime,
|
||||
EndTime: p.EndTime,
|
||||
StartTime: inLocationTime(p.StartTime, tz),
|
||||
EndTime: inLocationTime(p.EndTime, tz),
|
||||
Name: p.Name,
|
||||
IsDay: copyBoolPtr(p.IsDay),
|
||||
ConditionCode: p.ConditionCode,
|
||||
|
||||
@@ -47,6 +47,21 @@ func copyTimePtr(v *time.Time) *time.Time {
|
||||
return &out
|
||||
}
|
||||
|
||||
func inLocationTime(v time.Time, loc *time.Location) time.Time {
|
||||
if loc == nil {
|
||||
return v
|
||||
}
|
||||
return v.In(loc)
|
||||
}
|
||||
|
||||
func inLocationTimePtr(v *time.Time, loc *time.Location) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
out := inLocationTime(*v, loc)
|
||||
return &out
|
||||
}
|
||||
|
||||
func boolText(v *bool) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestForecastPayloadUS(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
payload := ForecastPayload(run, UnitsUS, 2)
|
||||
payload := ForecastPayload(run, UnitsUS, 2, nil)
|
||||
converted, ok := payload.(WeatherForecastRunUS)
|
||||
if !ok {
|
||||
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
|
||||
@@ -83,6 +83,81 @@ func TestForecastPayloadUS(t *testing.T) {
|
||||
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
|
||||
}
|
||||
|
||||
func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
|
||||
loc := time.FixedZone("UTC-05:00", -5*60*60)
|
||||
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
updatedAt := issuedAt.Add(30 * time.Minute)
|
||||
run := &model.WeatherForecastRun{
|
||||
Product: model.ForecastProductHourly,
|
||||
IssuedAt: issuedAt,
|
||||
UpdatedAt: &updatedAt,
|
||||
Periods: []model.WeatherForecastPeriod{{
|
||||
StartTime: issuedAt.Add(1 * time.Hour),
|
||||
EndTime: issuedAt.Add(2 * time.Hour),
|
||||
ConditionCode: model.WMOUnknown,
|
||||
}},
|
||||
}
|
||||
|
||||
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
|
||||
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
||||
if !ok {
|
||||
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
||||
}
|
||||
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
|
||||
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
|
||||
if !metric.IssuedAt.UTC().Equal(issuedAt) {
|
||||
t.Fatalf("expected metric issuedAt to preserve instant")
|
||||
}
|
||||
|
||||
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
|
||||
us, ok := usPayload.(WeatherForecastRunUS)
|
||||
if !ok {
|
||||
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
|
||||
}
|
||||
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
|
||||
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
|
||||
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
|
||||
|
||||
// Source model remains untouched.
|
||||
assertOffsetSeconds(t, run.IssuedAt, 0)
|
||||
assertOffsetSeconds(t, *run.UpdatedAt, 0)
|
||||
}
|
||||
|
||||
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
|
||||
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
updatedAt := issuedAt.Add(time.Hour)
|
||||
run := &model.WeatherForecastRun{
|
||||
Product: model.ForecastProductHourly,
|
||||
IssuedAt: issuedAt,
|
||||
UpdatedAt: &updatedAt,
|
||||
Periods: []model.WeatherForecastPeriod{{
|
||||
StartTime: issuedAt,
|
||||
EndTime: issuedAt.Add(time.Hour),
|
||||
ConditionCode: model.WMOUnknown,
|
||||
}},
|
||||
}
|
||||
|
||||
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
|
||||
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
||||
if !ok {
|
||||
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
||||
}
|
||||
if metric == run {
|
||||
t.Fatalf("expected metric payload to be copied")
|
||||
}
|
||||
if metric.UpdatedAt == run.UpdatedAt {
|
||||
t.Fatalf("expected updatedAt pointer to be copied")
|
||||
}
|
||||
if !metric.IssuedAt.Equal(run.IssuedAt) {
|
||||
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
|
||||
}
|
||||
assertOffsetSeconds(t, metric.IssuedAt, 0)
|
||||
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
|
||||
}
|
||||
|
||||
func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||
obs := &model.WeatherObservation{
|
||||
TemperatureC: float64Ptr(20.6),
|
||||
@@ -104,7 +179,7 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||
if ObservationPayload(nil, UnitsUS, 0) != nil {
|
||||
t.Fatalf("expected nil observation input to return nil payload")
|
||||
}
|
||||
if ForecastPayload(nil, UnitsUS, 0) != nil {
|
||||
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
|
||||
t.Fatalf("expected nil forecast input to return nil payload")
|
||||
}
|
||||
if AlertsPayload(nil, UnitsUS) != nil {
|
||||
@@ -190,7 +265,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
|
||||
IssuedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
metricPayload := ForecastPayload(run, UnitsMetric, 0)
|
||||
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
|
||||
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
||||
if !ok {
|
||||
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
||||
@@ -199,7 +274,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
|
||||
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
|
||||
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
|
||||
|
||||
usPayload := ForecastPayload(run, UnitsUS, 0)
|
||||
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
|
||||
us, ok := usPayload.(WeatherForecastRunUS)
|
||||
if !ok {
|
||||
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
|
||||
@@ -225,3 +300,11 @@ func assertApprox(t *testing.T, got *float64, want, eps float64) {
|
||||
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
|
||||
t.Helper()
|
||||
_, got := ts.Zone()
|
||||
if got != want {
|
||||
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedapi/bind"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||
@@ -17,6 +18,7 @@ type queryRequest struct {
|
||||
type precisionQueryRequest struct {
|
||||
Units presenter.Units
|
||||
Precision int
|
||||
Timezone *time.Location
|
||||
}
|
||||
|
||||
func bindQuery(r *http.Request) (queryRequest, error) {
|
||||
@@ -41,16 +43,29 @@ func bindQuery(r *http.Request) (queryRequest, error) {
|
||||
}
|
||||
|
||||
func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
|
||||
return bindPrecisionQueryInternal(r, false)
|
||||
}
|
||||
|
||||
func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
|
||||
return bindPrecisionQueryInternal(r, true)
|
||||
}
|
||||
|
||||
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
|
||||
normalizeCommonQueryValue(r, "units")
|
||||
normalizeCommonQueryValue(r, "format")
|
||||
normalizeCommonQueryValue(r, "precision")
|
||||
|
||||
allowedExtra := []string{"precision"}
|
||||
if allowTimezone {
|
||||
allowedExtra = append(allowedExtra, "tz", "TZ")
|
||||
}
|
||||
|
||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||
AllowUnits: true,
|
||||
AllowFormat: true,
|
||||
DefaultUnits: string(presenter.UnitsMetric),
|
||||
RejectUnknown: true,
|
||||
}, "precision")
|
||||
}, allowedExtra...)
|
||||
if err != nil {
|
||||
return precisionQueryRequest{}, err
|
||||
}
|
||||
@@ -70,5 +85,18 @@ func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
|
||||
if units == "" {
|
||||
units = presenter.UnitsMetric
|
||||
}
|
||||
return precisionQueryRequest{Units: units, Precision: precision}, nil
|
||||
|
||||
var tz *time.Location
|
||||
if allowTimezone {
|
||||
tz, err = parseTimezoneQuery(r)
|
||||
if err != nil {
|
||||
return precisionQueryRequest{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return precisionQueryRequest{
|
||||
Units: units,
|
||||
Precision: precision,
|
||||
Timezone: tz,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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