diff --git a/internal/geo/ip/ip.go b/internal/geo/ip/ip.go new file mode 100644 index 0000000..8adc2da --- /dev/null +++ b/internal/geo/ip/ip.go @@ -0,0 +1,99 @@ +package ip + +import ( + "errors" + "os" + "path" + "strconv" + "strings" + + "github.com/chubin/wttr.in/internal/config" +) + +var ( + ErrNotFound = errors.New("cache entry not found") + ErrInvalidCacheEntry = errors.New("invalid cache entry format") +) + +// Location information. +type Location struct { + CountryCode string + Country string + Region string + City string + Latitude float64 + Longitude float64 +} + +// Cache provides access to the IP Geodata cache. +type Cache struct { + config *config.Config +} + +// NewCache returns new cache reader for the specified config. +func NewCache(config *config.Config) *Cache { + return &Cache{ + config: config, + } +} + +// Read returns location information from the cache, if found, +// or ErrNotFound if not found. If the entry is found, but its format +// is invalid, ErrInvalidCacheEntry is returned. +// +// Format: +// +// [CountryCode];Country;Region;City;[Latitude];[Longitude] +// +// Example: +// +// DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782 +// +func (c *Cache) Read(addr string) (*Location, error) { + bytes, err := os.ReadFile(c.cacheFile(addr)) + if err != nil { + return nil, ErrNotFound + } + return parseCacheEntry(string(bytes)) +} + +// cacheFile retuns path to the cache entry for addr. +func (c *Cache) cacheFile(addr string) string { + return path.Join(c.config.Geo.IPCache, addr) +} + +// parseCacheEntry parses the location cache entry s, +// and return location, or error, if the cache entry is invalid. +func parseCacheEntry(s string) (*Location, error) { + var ( + lat float64 = -1000 + long float64 = -1000 + err error + ) + + parts := strings.Split(s, ";") + if len(parts) < 4 { + return nil, ErrInvalidCacheEntry + } + + if len(parts) >= 6 { + lat, err = strconv.ParseFloat(parts[4], 64) + if err != nil { + return nil, ErrInvalidCacheEntry + } + + long, err = strconv.ParseFloat(parts[5], 64) + if err != nil { + return nil, ErrInvalidCacheEntry + } + } + + return &Location{ + CountryCode: parts[0], + Country: parts[1], + Region: parts[2], + City: parts[3], + Latitude: lat, + Longitude: long, + }, nil +} diff --git a/internal/geo/ip/ip_test.go b/internal/geo/ip/ip_test.go new file mode 100644 index 0000000..8921628 --- /dev/null +++ b/internal/geo/ip/ip_test.go @@ -0,0 +1,71 @@ +package ip + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCacheEntry(t *testing.T) { + tests := []struct { + input string + expected Location + err error + }{ + { + "DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782", + Location{ + CountryCode: "DE", + Country: "Germany", + Region: "Free and Hanseatic City of Hamburg", + City: "Hamburg", + Latitude: 53.5736, + Longitude: 9.9782, + }, + nil, + }, + + { + "ES;Spain;Madrid, Comunidad de;Madrid;40.4165;-3.70256;28223;Orange Espagne SA;orange.es", + Location{ + CountryCode: "ES", + Country: "Spain", + Region: "Madrid, Comunidad de", + City: "Madrid", + Latitude: 40.4165, + Longitude: -3.70256, + }, + nil, + }, + + { + "US;United States of America;California;Mountain View", + Location{ + CountryCode: "US", + Country: "United States of America", + Region: "California", + City: "Mountain View", + Latitude: -1000, + Longitude: -1000, + }, + nil, + }, + + // Invalid entries + { + "DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;XXX", + Location{}, + ErrInvalidCacheEntry, + }, + } + + for _, tt := range tests { + result, err := parseCacheEntry(tt.input) + if tt.err == nil { + require.NoError(t, err) + require.Equal(t, *result, tt.expected) + } else { + require.ErrorIs(t, err, tt.err) + } + } +}