From 6806de3c0a1a35da441c787dbe7a4c0b517f089c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 20 Mar 2026 11:30:11 -0500 Subject: [PATCH] Added support for rounding of values by default in API responses --- .../inbound/httpapi/conditions_endpoint.go | 6 +- .../inbound/httpapi/endpoints_test.go | 136 ++++++++++++++++++ .../inbound/httpapi/forecast_endpoint.go | 6 +- .../inbound/httpapi/observations_endpoint.go | 6 +- .../inbound/httpapi/presenter/conditions.go | 22 +-- .../inbound/httpapi/presenter/forecast.go | 81 ++++++++--- .../inbound/httpapi/presenter/helpers.go | 21 ++- .../inbound/httpapi/presenter/observation.go | 41 ++++-- .../inbound/httpapi/presenter/payload_test.go | 80 ++++++++--- .../adapters/inbound/httpapi/query_bind.go | 38 +++++ 10 files changed, 369 insertions(+), 68 deletions(-) diff --git a/internal/adapters/inbound/httpapi/conditions_endpoint.go b/internal/adapters/inbound/httpapi/conditions_endpoint.go index 69e401b..b48d347 100644 --- a/internal/adapters/inbound/httpapi/conditions_endpoint.go +++ b/internal/adapters/inbound/httpapi/conditions_endpoint.go @@ -14,13 +14,13 @@ import ( func conditionsDefinition(svc Service) endpoint.Definition { return endpoint.GET( "/conditions/current", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { + bindPrecisionQuery, + func(ctx context.Context, req precisionQueryRequest) (any, error) { conditions, err := svc.CurrentConditions(ctx) if err != nil { return nil, err } - return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units)}, nil + return response.Envelope{Data: presenter.CurrentConditionsPayload(conditions, req.Units, req.Precision)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("conditions_current.txt.tmpl"), diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 48b6f42..cca3cda 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -119,6 +119,70 @@ func TestObservationsPopulatedJSONEnvelope(t *testing.T) { } } +func TestObservationsDefaultPrecisionRoundsToInteger(t *testing.T) { + h := newHandler(t, &fakeService{ + observation: &model.WeatherObservation{ + Timestamp: time.Now().UTC(), + ConditionCode: model.WMOUnknown, + TemperatureC: float64Ptr(20.6), + }, + }, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + got, ok := payload.Data["temperatureC"].(float64) + if !ok { + t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"]) + } + if got != 21 { + t.Fatalf("expected rounded temperatureC 21, got %v", got) + } +} + +func TestObservationsPrecisionTwo(t *testing.T) { + h := newHandler(t, &fakeService{ + observation: &model.WeatherObservation{ + Timestamp: time.Now().UTC(), + ConditionCode: model.WMOUnknown, + TemperatureC: float64Ptr(20.678), + }, + }, "/observations") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/observations?precision=2", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + got, ok := payload.Data["temperatureC"].(float64) + if !ok { + t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"]) + } + if math.Abs(got-20.68) > 0.00001 { + t.Fatalf("expected rounded temperatureC 20.68, got %v", got) + } +} + func TestFormatNegotiationCaseInsensitive(t *testing.T) { hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active") @@ -285,6 +349,51 @@ func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) { } } +func TestForecastPrecisionTwo(t *testing.T) { + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: time.Now().UTC(), + Periods: []model.WeatherForecastPeriod{{ + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(time.Hour), + ConditionCode: model.WMOUnknown, + TemperatureC: float64Ptr(12.345), + }}, + }, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?precision=2", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + periods, ok := payload.Data["periods"].([]any) + if !ok || len(periods) == 0 { + t.Fatalf("expected non-empty periods, got %#v", payload.Data["periods"]) + } + first, ok := periods[0].(map[string]any) + if !ok { + t.Fatalf("expected first period object, got %#v", periods[0]) + } + got, ok := first["temperatureC"].(float64) + if !ok { + t.Fatalf("expected temperatureC float, got %#v", first["temperatureC"]) + } + if math.Abs(got-12.35) > 0.00001 { + t.Fatalf("expected rounded temperatureC 12.35, got %v", got) + } +} + func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") @@ -436,6 +545,33 @@ func TestCurrentConditionsRejectUnknownQueryParameter(t *testing.T) { } } +func TestPrecisionValidationRange(t *testing.T) { + h := newHandler(t, &fakeService{}, "/conditions/current") + + for _, raw := range []string{"-1", "3", "abc"} { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current?precision="+raw, nil) + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for precision=%s, got %d", raw, w.Code) + } + } +} + +func TestAlertsRejectPrecisionQueryParameter(t *testing.T) { + h := newHandler(t, &fakeService{ + alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}, + }, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func newHandler(t *testing.T, svc Service, path string) http.Handler { t.Helper() diff --git a/internal/adapters/inbound/httpapi/forecast_endpoint.go b/internal/adapters/inbound/httpapi/forecast_endpoint.go index 0a6bed8..86e7cde 100644 --- a/internal/adapters/inbound/httpapi/forecast_endpoint.go +++ b/internal/adapters/inbound/httpapi/forecast_endpoint.go @@ -14,13 +14,13 @@ import ( func forecastDefinition(svc Service) endpoint.Definition { return endpoint.GET( "/forecast/hourly", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { + bindPrecisionQuery, + 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)}, nil + return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("forecast_hourly.txt.tmpl"), diff --git a/internal/adapters/inbound/httpapi/observations_endpoint.go b/internal/adapters/inbound/httpapi/observations_endpoint.go index e2f0222..6b8762a 100644 --- a/internal/adapters/inbound/httpapi/observations_endpoint.go +++ b/internal/adapters/inbound/httpapi/observations_endpoint.go @@ -14,13 +14,13 @@ import ( func observationDefinition(svc Service) endpoint.Definition { return endpoint.GET( "/observations", - bindQuery, - func(ctx context.Context, req queryRequest) (any, error) { + bindPrecisionQuery, + func(ctx context.Context, req precisionQueryRequest) (any, error) { obs, err := svc.LatestObservation(ctx) if err != nil { return nil, err } - return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units)}, nil + return response.Envelope{Data: presenter.ObservationPayload(obs, req.Units, req.Precision)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("observations.txt.tmpl"), diff --git a/internal/adapters/inbound/httpapi/presenter/conditions.go b/internal/adapters/inbound/httpapi/presenter/conditions.go index 1173780..6421b94 100644 --- a/internal/adapters/inbound/httpapi/presenter/conditions.go +++ b/internal/adapters/inbound/httpapi/presenter/conditions.go @@ -25,30 +25,30 @@ type CurrentConditionsResponse struct { IsDayText string `json:"-" xml:"-"` } -func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units) any { +func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any { if conditions == nil { return nil } out := CurrentConditionsResponse{ - RelativeHumidityPercent: copyFloat64Ptr(conditions.RelativeHumidityPercent), - WindDirectionDegrees: copyFloat64Ptr(conditions.WindDirectionDegrees), + RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision), + WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision), ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), IsDay: copyBoolPtr(conditions.IsDay), IsDayText: boolText(conditions.IsDay), } if units == UnitsUS { - out.TemperatureF = celsiusToFahrenheitPtr(conditions.TemperatureC) - out.ApparentTemperatureF = celsiusToFahrenheitPtr(conditions.ApparentTemperatureC) - out.DewpointF = celsiusToFahrenheitPtr(conditions.DewpointC) - out.WindSpeedMph = scalePtr(conditions.WindSpeedKmh, kmhToMphFactor) + out.TemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.TemperatureC), precision) + out.ApparentTemperatureF = roundedPtr(celsiusToFahrenheitPtr(conditions.ApparentTemperatureC), precision) + out.DewpointF = roundedPtr(celsiusToFahrenheitPtr(conditions.DewpointC), precision) + out.WindSpeedMph = roundedPtr(scalePtr(conditions.WindSpeedKmh, kmhToMphFactor), precision) return out } - out.TemperatureC = copyFloat64Ptr(conditions.TemperatureC) - out.ApparentTemperatureC = copyFloat64Ptr(conditions.ApparentTemperatureC) - out.DewpointC = copyFloat64Ptr(conditions.DewpointC) - out.WindSpeedKmh = copyFloat64Ptr(conditions.WindSpeedKmh) + out.TemperatureC = roundedPtr(copyFloat64Ptr(conditions.TemperatureC), precision) + out.ApparentTemperatureC = roundedPtr(copyFloat64Ptr(conditions.ApparentTemperatureC), precision) + out.DewpointC = roundedPtr(copyFloat64Ptr(conditions.DewpointC), precision) + out.WindSpeedKmh = roundedPtr(copyFloat64Ptr(conditions.WindSpeedKmh), precision) return out } diff --git a/internal/adapters/inbound/httpapi/presenter/forecast.go b/internal/adapters/inbound/httpapi/presenter/forecast.go index 5da8e9e..d929b93 100644 --- a/internal/adapters/inbound/httpapi/presenter/forecast.go +++ b/internal/adapters/inbound/httpapi/presenter/forecast.go @@ -51,7 +51,7 @@ type WeatherForecastPeriodUS struct { UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` } -func ForecastPayload(run *model.WeatherForecastRun, units Units) any { +func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) any { if run == nil { return nil } @@ -64,7 +64,7 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units) any { Product: run.Product, Latitude: copyFloat64Ptr(run.Latitude), Longitude: copyFloat64Ptr(run.Longitude), - ElevationFeet: scalePtr(run.ElevationMeters, metersToFeetFactor), + ElevationFeet: roundedPtr(scalePtr(run.ElevationMeters, metersToFeetFactor), precision), Periods: make([]WeatherForecastPeriodUS, 0, len(run.Periods)), } for _, p := range run.Periods { @@ -79,25 +79,68 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units) any { TextDescription: p.TextDescription, DetailedText: p.DetailedText, IconURL: p.IconURL, - TemperatureF: celsiusToFahrenheitPtr(p.TemperatureC), - TemperatureFMin: celsiusToFahrenheitPtr(p.TemperatureCMin), - TemperatureFMax: celsiusToFahrenheitPtr(p.TemperatureCMax), - DewpointF: celsiusToFahrenheitPtr(p.DewpointC), - RelativeHumidityPercent: copyFloat64Ptr(p.RelativeHumidityPercent), - WindDirectionDegrees: copyFloat64Ptr(p.WindDirectionDegrees), - WindSpeedMph: scalePtr(p.WindSpeedKmh, kmhToMphFactor), - WindGustMph: scalePtr(p.WindGustKmh, kmhToMphFactor), - BarometricPressureInHg: scalePtr(p.BarometricPressurePa, paToInHgFactor), - VisibilityMiles: scalePtr(p.VisibilityMeters, metersToMilesFactor), - ApparentTemperatureF: celsiusToFahrenheitPtr(p.ApparentTemperatureC), - CloudCoverPercent: copyFloat64Ptr(p.CloudCoverPercent), - ProbabilityOfPrecipitationPercent: copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), - PrecipitationAmountIn: scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), - SnowfallDepthIn: scalePtr(p.SnowfallDepthMM, mmToInchesFactor), - UVIndex: copyFloat64Ptr(p.UVIndex), + TemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureC), precision), + TemperatureFMin: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMin), precision), + TemperatureFMax: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMax), precision), + DewpointF: roundedPtr(celsiusToFahrenheitPtr(p.DewpointC), precision), + RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision), + WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision), + WindSpeedMph: roundedPtr(scalePtr(p.WindSpeedKmh, kmhToMphFactor), precision), + WindGustMph: roundedPtr(scalePtr(p.WindGustKmh, kmhToMphFactor), precision), + BarometricPressureInHg: roundedPtr(scalePtr(p.BarometricPressurePa, paToInHgFactor), precision), + VisibilityMiles: roundedPtr(scalePtr(p.VisibilityMeters, metersToMilesFactor), precision), + ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.ApparentTemperatureC), precision), + CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision), + ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision), + PrecipitationAmountIn: roundedPtr(scalePtr(p.PrecipitationAmountMm, mmToInchesFactor), precision), + SnowfallDepthIn: roundedPtr(scalePtr(p.SnowfallDepthMM, mmToInchesFactor), precision), + UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision), }) } return out } - return run + + out := model.WeatherForecastRun{ + LocationID: run.LocationID, + LocationName: run.LocationName, + IssuedAt: run.IssuedAt, + UpdatedAt: copyTimePtr(run.UpdatedAt), + Product: run.Product, + Latitude: copyFloat64Ptr(run.Latitude), + Longitude: copyFloat64Ptr(run.Longitude), + ElevationMeters: roundedPtr(copyFloat64Ptr(run.ElevationMeters), precision), + Periods: make([]model.WeatherForecastPeriod, 0, len(run.Periods)), + } + + for _, p := range run.Periods { + out.Periods = append(out.Periods, model.WeatherForecastPeriod{ + StartTime: p.StartTime, + EndTime: p.EndTime, + Name: p.Name, + IsDay: copyBoolPtr(p.IsDay), + ConditionCode: p.ConditionCode, + ConditionText: p.ConditionText, + ProviderRawDescription: p.ProviderRawDescription, + TextDescription: p.TextDescription, + DetailedText: p.DetailedText, + IconURL: p.IconURL, + TemperatureC: roundedPtr(copyFloat64Ptr(p.TemperatureC), precision), + TemperatureCMin: roundedPtr(copyFloat64Ptr(p.TemperatureCMin), precision), + TemperatureCMax: roundedPtr(copyFloat64Ptr(p.TemperatureCMax), precision), + DewpointC: roundedPtr(copyFloat64Ptr(p.DewpointC), precision), + RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(p.RelativeHumidityPercent), precision), + WindDirectionDegrees: roundedPtr(copyFloat64Ptr(p.WindDirectionDegrees), precision), + WindSpeedKmh: roundedPtr(copyFloat64Ptr(p.WindSpeedKmh), precision), + WindGustKmh: roundedPtr(copyFloat64Ptr(p.WindGustKmh), precision), + BarometricPressurePa: roundedPtr(copyFloat64Ptr(p.BarometricPressurePa), precision), + VisibilityMeters: roundedPtr(copyFloat64Ptr(p.VisibilityMeters), precision), + ApparentTemperatureC: roundedPtr(copyFloat64Ptr(p.ApparentTemperatureC), precision), + CloudCoverPercent: roundedPtr(copyFloat64Ptr(p.CloudCoverPercent), precision), + ProbabilityOfPrecipitationPercent: roundedPtr(copyFloat64Ptr(p.ProbabilityOfPrecipitationPercent), precision), + PrecipitationAmountMm: roundedPtr(copyFloat64Ptr(p.PrecipitationAmountMm), precision), + SnowfallDepthMM: roundedPtr(copyFloat64Ptr(p.SnowfallDepthMM), precision), + UVIndex: roundedPtr(copyFloat64Ptr(p.UVIndex), precision), + }) + } + return &out } diff --git a/internal/adapters/inbound/httpapi/presenter/helpers.go b/internal/adapters/inbound/httpapi/presenter/helpers.go index 8118255..35cc532 100644 --- a/internal/adapters/inbound/httpapi/presenter/helpers.go +++ b/internal/adapters/inbound/httpapi/presenter/helpers.go @@ -2,7 +2,10 @@ // Layer: adapters/inbound/httpapi/presenter helper functions. package presenter -import "time" +import ( + "math" + "time" +) func celsiusToFahrenheitPtr(v *float64) *float64 { if v == nil { @@ -53,3 +56,19 @@ func boolText(v *bool) string { } return "false" } + +func roundedPtr(v *float64, precision int) *float64 { + if v == nil { + return nil + } + out := roundFloat(*v, precision) + return &out +} + +func roundFloat(v float64, precision int) float64 { + if precision <= 0 { + return math.Round(v) + } + factor := math.Pow10(precision) + return math.Round(v*factor) / factor +} diff --git a/internal/adapters/inbound/httpapi/presenter/observation.go b/internal/adapters/inbound/httpapi/presenter/observation.go index edc2207..84bd51b 100644 --- a/internal/adapters/inbound/httpapi/presenter/observation.go +++ b/internal/adapters/inbound/httpapi/presenter/observation.go @@ -28,7 +28,7 @@ type WeatherObservationUS struct { PresentWeather []model.PresentWeather `json:"presentWeather,omitempty" xml:"presentWeather,omitempty"` } -func ObservationPayload(obs *model.WeatherObservation, units Units) any { +func ObservationPayload(obs *model.WeatherObservation, units Units, precision int) any { if obs == nil { return nil } @@ -40,18 +40,37 @@ func ObservationPayload(obs *model.WeatherObservation, units Units) any { ConditionCode: obs.ConditionCode, IsDay: copyBoolPtr(obs.IsDay), TextDescription: obs.TextDescription, - TemperatureF: celsiusToFahrenheitPtr(obs.TemperatureC), - DewpointF: celsiusToFahrenheitPtr(obs.DewpointC), - WindDirectionDegrees: copyFloat64Ptr(obs.WindDirectionDegrees), - WindSpeedMph: scalePtr(obs.WindSpeedKmh, kmhToMphFactor), - WindGustMph: scalePtr(obs.WindGustKmh, kmhToMphFactor), - BarometricPressureInHg: scalePtr(obs.BarometricPressurePa, paToInHgFactor), - VisibilityMiles: scalePtr(obs.VisibilityMeters, metersToMilesFactor), - RelativeHumidityPercent: copyFloat64Ptr(obs.RelativeHumidityPercent), - ApparentTemperatureF: celsiusToFahrenheitPtr(obs.ApparentTemperatureC), + TemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.TemperatureC), precision), + DewpointF: roundedPtr(celsiusToFahrenheitPtr(obs.DewpointC), precision), + WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision), + WindSpeedMph: roundedPtr(scalePtr(obs.WindSpeedKmh, kmhToMphFactor), precision), + WindGustMph: roundedPtr(scalePtr(obs.WindGustKmh, kmhToMphFactor), precision), + BarometricPressureInHg: roundedPtr(scalePtr(obs.BarometricPressurePa, paToInHgFactor), precision), + VisibilityMiles: roundedPtr(scalePtr(obs.VisibilityMeters, metersToMilesFactor), precision), + RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision), + ApparentTemperatureF: roundedPtr(celsiusToFahrenheitPtr(obs.ApparentTemperatureC), precision), PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), } return converted } - return obs + + rounded := model.WeatherObservation{ + StationID: obs.StationID, + StationName: obs.StationName, + Timestamp: obs.Timestamp, + ConditionCode: obs.ConditionCode, + IsDay: copyBoolPtr(obs.IsDay), + TextDescription: obs.TextDescription, + TemperatureC: roundedPtr(copyFloat64Ptr(obs.TemperatureC), precision), + DewpointC: roundedPtr(copyFloat64Ptr(obs.DewpointC), precision), + WindDirectionDegrees: roundedPtr(copyFloat64Ptr(obs.WindDirectionDegrees), precision), + WindSpeedKmh: roundedPtr(copyFloat64Ptr(obs.WindSpeedKmh), precision), + WindGustKmh: roundedPtr(copyFloat64Ptr(obs.WindGustKmh), precision), + BarometricPressurePa: roundedPtr(copyFloat64Ptr(obs.BarometricPressurePa), precision), + VisibilityMeters: roundedPtr(copyFloat64Ptr(obs.VisibilityMeters), precision), + RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(obs.RelativeHumidityPercent), precision), + ApparentTemperatureC: roundedPtr(copyFloat64Ptr(obs.ApparentTemperatureC), precision), + PresentWeather: append([]model.PresentWeather(nil), obs.PresentWeather...), + } + return &rounded } diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index 59e1b15..d8f554a 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -26,15 +26,15 @@ func TestObservationPayloadUS(t *testing.T) { RelativeHumidityPercent: float64Ptr(50), } - payload := ObservationPayload(obs, UnitsUS) + payload := ObservationPayload(obs, UnitsUS, 2) converted, ok := payload.(WeatherObservationUS) if !ok { t.Fatalf("expected WeatherObservationUS payload, got %T", payload) } assertApprox(t, converted.TemperatureF, 68.0, 0.0001) - assertApprox(t, converted.WindSpeedMph, 62.1371192237, 0.0001) - assertApprox(t, converted.BarometricPressureInHg, 29.9212524019, 0.0001) + assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001) + assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001) assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001) if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil { @@ -64,13 +64,13 @@ func TestForecastPayloadUS(t *testing.T) { }}, } - payload := ForecastPayload(run, UnitsUS) + payload := ForecastPayload(run, UnitsUS, 2) converted, ok := payload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload) } - assertApprox(t, converted.ElevationFeet, 3280.839895, 0.0001) + assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001) if len(converted.Periods) != 1 { t.Fatalf("expected 1 period, got %d", len(converted.Periods)) } @@ -83,27 +83,34 @@ func TestForecastPayloadUS(t *testing.T) { assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001) } -func TestMetricPassthroughAndNilHandling(t *testing.T) { - obs := &model.WeatherObservation{} - metric := ObservationPayload(obs, UnitsMetric) +func TestMetricCopyAndNilHandling(t *testing.T) { + obs := &model.WeatherObservation{ + TemperatureC: float64Ptr(20.6), + } + metric := ObservationPayload(obs, UnitsMetric, 0) metricObs, ok := metric.(*model.WeatherObservation) if !ok { t.Fatalf("expected metric payload to remain model type, got %T", metric) } - if metricObs != obs { - t.Fatalf("expected metric payload to be original pointer") + if metricObs == obs { + t.Fatalf("expected metric payload to be copied") + } + assertApprox(t, metricObs.TemperatureC, 21, 0.0001) + assertApprox(t, obs.TemperatureC, 20.6, 0.0001) + if metricObs.TemperatureC == obs.TemperatureC { + t.Fatalf("expected temperature pointer copy, got same pointer") } - if ObservationPayload(nil, UnitsUS) != nil { + if ObservationPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil observation input to return nil payload") } - if ForecastPayload(nil, UnitsUS) != nil { + if ForecastPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil forecast input to return nil payload") } if AlertsPayload(nil, UnitsUS) != nil { t.Fatalf("expected nil alerts input to return nil payload") } - if CurrentConditionsPayload(nil, UnitsUS) != nil { + if CurrentConditionsPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil current conditions input to return nil payload") } } @@ -120,7 +127,7 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { IsDay: boolPtr(true), } - metricPayload := CurrentConditionsPayload(conditions, UnitsMetric) + metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2) metric, ok := metricPayload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload) @@ -134,13 +141,13 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText) } - usPayload := CurrentConditionsPayload(conditions, UnitsUS) + usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2) us, ok := usPayload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload) } assertApprox(t, us.TemperatureF, 68, 0.0001) - assertApprox(t, us.WindSpeedMph, 62.1371192237, 0.0001) + assertApprox(t, us.WindSpeedMph, 62.14, 0.0001) if us.TemperatureC != nil || us.WindSpeedKmh != nil { t.Fatalf("expected metric fields omitted for US payload") } @@ -151,7 +158,7 @@ func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { payload := CurrentConditionsPayload(&app.CurrentConditions{ ConditionCode: 0, IsDay: &night, - }, UnitsMetric) + }, UnitsMetric, 0) metric, ok := payload.(CurrentConditionsResponse) if !ok { @@ -162,6 +169,45 @@ func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { } } +func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) { + payload := CurrentConditionsPayload(&app.CurrentConditions{ + TemperatureC: float64Ptr(-1.5), + }, UnitsMetric, 0) + + metric, ok := payload.(CurrentConditionsResponse) + if !ok { + t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload) + } + assertApprox(t, metric.TemperatureC, -2, 0.0001) +} + +func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) { + run := &model.WeatherForecastRun{ + Latitude: float64Ptr(38.627123), + Longitude: float64Ptr(-90.199456), + ElevationMeters: float64Ptr(10.499), + Product: model.ForecastProductHourly, + IssuedAt: time.Now().UTC(), + } + + metricPayload := ForecastPayload(run, UnitsMetric, 0) + metric, ok := metricPayload.(*model.WeatherForecastRun) + if !ok { + t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) + } + assertApprox(t, metric.Latitude, 38.627123, 0.000001) + assertApprox(t, metric.Longitude, -90.199456, 0.000001) + assertApprox(t, metric.ElevationMeters, 10, 0.0001) + + usPayload := ForecastPayload(run, UnitsUS, 0) + us, ok := usPayload.(WeatherForecastRunUS) + if !ok { + t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload) + } + assertApprox(t, us.Latitude, 38.627123, 0.000001) + assertApprox(t, us.Longitude, -90.199456, 0.000001) +} + func float64Ptr(v float64) *float64 { return &v } diff --git a/internal/adapters/inbound/httpapi/query_bind.go b/internal/adapters/inbound/httpapi/query_bind.go index 4ece724..40b6bc8 100644 --- a/internal/adapters/inbound/httpapi/query_bind.go +++ b/internal/adapters/inbound/httpapi/query_bind.go @@ -14,6 +14,11 @@ type queryRequest struct { Units presenter.Units } +type precisionQueryRequest struct { + Units presenter.Units + Precision int +} + func bindQuery(r *http.Request) (queryRequest, error) { normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "format") @@ -34,3 +39,36 @@ func bindQuery(r *http.Request) (queryRequest, error) { } return queryRequest{Units: units}, nil } + +func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) { + normalizeCommonQueryValue(r, "units") + normalizeCommonQueryValue(r, "format") + normalizeCommonQueryValue(r, "precision") + + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowUnits: true, + AllowFormat: true, + DefaultUnits: string(presenter.UnitsMetric), + RejectUnknown: true, + }, "precision") + if err != nil { + return precisionQueryRequest{}, err + } + + precision, err := bind.OptionalInt(r, "precision", 0) + if err != nil { + return precisionQueryRequest{}, err + } + if err := bind.MinInt(precision, 0, "precision"); err != nil { + return precisionQueryRequest{}, err + } + if err := bind.MaxInt(precision, 2, "precision"); err != nil { + return precisionQueryRequest{}, err + } + + units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units))) + if units == "" { + units = presenter.UnitsMetric + } + return precisionQueryRequest{Units: units, Precision: precision}, nil +}