package timezone import ( "encoding/json" "fmt" "io" "math" "net/http" "net/url" "strconv" "strings" "sync" "github.com/pkg/errors" "fiskerinc.com/modules/redis" "fiskerinc.com/modules/utils/envtool" ) type Timezone struct { ID string Name string Offset int DST bool } type TimezoneServiceInterface interface { GetTimezoneByCoordinate(latitude float32, longitude float32) (*Timezone, error) GetCachedTimezoneByQuadkey(conn redis.Client, quadkey string) (*Timezone, error) SetCachedTimezoneByQuadkey(conn redis.Client, quadkey string, timezone *Timezone) error } type HTTPClient interface { Do(req *http.Request) (*http.Response, error) } type TimezoneService struct { apiUrl string apiKey string Client HTTPClient } var ( timezoneService TimezoneServiceInterface timezoneOnce sync.Once ) func GetTimezoneService() TimezoneServiceInterface { timezoneOnce.Do(func() { if timezoneService != nil { return } timezoneService = NewTimezoneService() }) return timezoneService } func SetTimezoneService(timezoneInterface TimezoneServiceInterface) { timezoneService = timezoneInterface } func NewTimezoneService() TimezoneServiceInterface { return &TimezoneService{ apiUrl: "https://atlas.microsoft.com/timezone/byCoordinates/json", apiKey: envtool.GetEnv("AZURE_TIMEZONE_API_KEY", "REPLACE_ME"), Client: http.DefaultClient, } } func (timezoneService *TimezoneService) GetTimezoneByCoordinate(latitude float32, longitude float32) (*Timezone, error) { timezone := &Timezone{} err := verifyCoordinate(latitude, longitude) if err != nil { return nil, errors.WithStack(err) } request := timezoneService.createRequest(latitude, longitude) azureTimezone, err := getAzureTimezoneByCoordinate(timezoneService.Client, request) if err != nil { return nil, errors.WithStack(err) } standardOffset, err := getOffsetInSeconds(azureTimezone.ReferenceTime.StandardOffset) if err != nil { return nil, errors.New("could not parse UTC") } dstOffset, _ := getOffsetInSeconds(azureTimezone.ReferenceTime.DaylightSavings) if err != nil { return nil, errors.New("could not parse DST") } timezone.ID = azureTimezone.ReferenceTime.Tag timezone.Name = azureTimezone.ID timezone.Offset = standardOffset + dstOffset timezone.DST = dstOffset != 0 return timezone, nil } func getAzureTimezoneByCoordinate(client HTTPClient, url string) (*AzureTimezone, error) { request, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, errors.WithStack(err) } request.Header.Add("accept", "application/json") request.Header.Add("Content-Type", "application/json") resp, err := client.Do(request) if err != nil { return nil, errors.WithStack(err) } respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, errors.WithStack(err) } timezoneResult := &AzureTimezoneResult{} err = json.Unmarshal(respBody, timezoneResult) if err != nil { return nil, err } if len(timezoneResult.Timezones) == 0 { return nil, errors.WithStack(errors.New("no timezone found")) } return &timezoneResult.Timezones[0], nil } func getOffsetInSeconds(offset string) (int, error) { var result int parts := strings.Split(offset, ":") // "-10:30:00" --> []string{"-10", "30", "00"} power := len(parts) - 1 for _, part := range parts { digit, err := strconv.Atoi(part) if err != nil { return 0, errors.New("could not convert offset") } if power > 0 { // Seconds = Hours * 60^2 // Seconds = Minutes * 60^1 // Seconds = Seconds * 60^0 (there are no timezones off by seconds) digit = digit * int(math.Pow(60, float64(power))) } if result < 0 { // Honor existing negative value digit *= -1 } result += digit power -= 1 } return result, nil } func (timezoneService *TimezoneService) createRequest(latitude float32, longitude float32) string { queryParams := url.Values{ "api-version": {fmt.Sprintf("%f", 1.0)}, "query": {fmt.Sprintf("%f,%f", latitude, longitude)}, "subscription-key": {timezoneService.apiKey}, } return fmt.Sprintf("%s?%s", timezoneService.apiUrl, queryParams.Encode()) } func verifyCoordinate(latitude, longitude float32) error { if latitude > 90 || latitude < -90 { return fmt.Errorf("latitude %f is out of bounds", latitude) } if longitude > 180 || longitude < -180 { return fmt.Errorf("longitude %f is out of bounds", longitude) } return nil } func (timezoneService *TimezoneService) GetCachedTimezoneByQuadkey(conn redis.Client, quadkey string) (*Timezone, error) { timezone := &Timezone{} key := redis.TimezoneQuadKey(quadkey, 13) err := conn.GetObject(key, timezone) if err != nil { return nil, err } return timezone, nil } func (timezoneService *TimezoneService) SetCachedTimezoneByQuadkey(conn redis.Client, quadkey string, timezone *Timezone) error { dayInSeconds := 86400 key := redis.TimezoneQuadKey(quadkey, 13) return conn.SetObject(key, timezone, dayInSeconds*7) }