Getting Location and Weather from an IP Address

Introduction

The concept of getting the location for a given IP address is not exactly new, and some posts have been written about it already. Still, I wanted to write about it because I will need it for a later article, and, to add something, also explain how to get the weather forecast for the given location.

Let's start by getting the location for a given IP address.

Geo Location

There are plenty of services available for free or a fee on the Internet that will give you some information about the location of an IP address (geo location by IP). To name just a few, in no particular order:

Some of these offer free services (with possibly some limitations, such as rate limiting per hour/day) where others offer full working sets. Also, the amount of information varies, all of them return latitude and longitude and the country name/code, but some may return more complex info, such as local currencies, timezones, languages, the name of the capital city, etc.

Let's start by defining a base contract for the service, first, an interface that specifies what we want:

public interface IGeoIPService
{
Task<GeoInfo> GetInfo(string ipAddress, CancellationToken cancellationToken = default);
}

The GeoInfo class contains the bare minimum information that should be returned by any provider, so we have:

public class GeoInfo
{
public string? CountryName { get; set; }
public string? CountryCode { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }

public override string ToString() => CountryName!;
}

Essentially, we have the country name and code, and the latitude/longitude pair.

Now, we need to create an implementation for a particular provider. I will pick Free IP API, just because it offered a free service with some useful information, such as time zones, top level domains, etc. I will base my example on this one, but, feel free to pick your own.

My IGeoIPService implementation for Free IP API is like this:

public class FreeIPApiGeoIPService : IGeoIPService
{
private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private readonly HttpClient _httpClient;

public FreeIPApiGeoIPService(HttpClient httpClient)
{
ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient));

_httpClient = httpClient;
_httpClient.BaseAddress = new Uri("https://freeipapi.com/api/json/");
_httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
_httpClient.DefaultRequestHeaders.Add(HeaderNames.CacheControl, "public, max-age=3600");
}

public async Task<GeoInfo> GetInfo(string ipAddress, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(ipAddress, nameof(ipAddress));

var geoInfo = await _httpClient.GetFromJsonAsync<FreeIPApiGeoInfo>(ipAddress, _options, cancellationToken);
return geoInfo!;
}
}

Nothing special here, the HttpClient is injected on the constructor, which means that we need to set it up (see my previous post), something like:

builder.Services.AddHttpClient();

I had to specify the JsonNamingPolicy.CamelCase option to control the JSON serialisation because of the way the JSON is returned by the service.

As for the FreeIPApiGeoInfo class, which extends GeoInfo, here it is:

class CurrencyInfo
{
public required string Code { get; set; }
public required string Name { get; set; }

public override string ToString() => Name;
}

class FreeIPApiGeoInfo : GeoInfo
{
public int IpVersion { get; set; }
public string? IpAddress { get; set; }
public string? ZipCode { get; set; }
public string? CityName { get; set; }
public string? RegionName { get; set; }
public bool IsProxy { get; set; }
public string? Continent { get; set; }
public string? ContinentCode { get; set; }
public string? Timezone { get; set; }
public CurrencyInfo? Currency { get; set; }
public string? Language { get; set; }
public string[]? Timezones { get; set; }
public string[]? Tlds { get; set; }

public override string ToString()
{
var str = new StringBuilder();

if (!string.IsNullOrWhiteSpace(CityName))
{
str.Append(CityName);
str.Append(", ");
}

if (!string.IsNullOrWhiteSpace(RegionName))
{
str.Append(RegionName);
str.Append(", ");
}

if (!string.IsNullOrWhiteSpace(CountryName))
{
str.Append(CountryName);
str.Append(", ");
}

if (!string.IsNullOrWhiteSpace(Continent))
{
str.Append(Continent);
}

return str.ToString().TrimEnd(',', ' ');
}
}

And this is all you need to try it! Just inject a copy of IGeoIPService and off you go!

Oh, almost forgot: a helper method for registering the service:

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddGeoService<TService>(this IServiceCollection services) where TService : class, IGeoIPService
{
ArgumentNullException.ThrowIfNull(services, nameof(services));

services.AddTransient<IGeoIPService, TService>();
services.AddHttpClient<TService>();
return services;
}

public static IServiceCollection AddGeoService<TService>(this IServiceCollection services, Action<GeoIPServiceOptions> options) where TService : class, IGeoIPService
{
ArgumentNullException.ThrowIfNull(services, nameof(services));
ArgumentNullException.ThrowIfNull(options, nameof(options));

var opt = new GeoIPServiceOptions();
options(opt);

services.Configure(options);
services.AddTransient<IGeoIPService, TService>();
services.AddHttpClient<TService>();
return services;
}
}

Finding the Client's IP Address

One common usage scenarion is to get the location for the currently connecting client. A simple thing, I hear you say, all we have to do is look into the HttpContext.Connection.RemoteIpAddress; well, if you had said that, you would be partially right, or right most of the time, but not when you're behind a load proxy server or load balancer! Usually when this happens, the client IP address is instead placed (by the proxy) in the X-FORWARDED-FOR header. Now, lucky for us, we have two options:

  1. We either look up this header first and then resort to RemoteIpAddress if it's not present in the request
  2. Or we leverage the Forwarded Headers Middleware and let it move the header's contents into RemoteIpAddress as appropriate

For #1, all we have to do is something like this:

public static class HttpContextExtensions
{
public static string? GetRemoteIpAddress(this HttpContext context)
{
var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString();
return ipAddress;
}
}

And for #2, we just need to add the middleware to the pipeline:

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});

And RemoteIpAddress will always contain the right IP address. You can read more about it here.

Weather Information

As for weather, again, there are plenty services that can provide the current weather conditions and even the forecast for the next days, either for free or for a fee. In most cases, though, you need to get an account with them, even for the free version. Some I've tried, in no particular order, are:

The contract for this service is as follows:

public interface IWeatherService
{
Task<WeatherInfo> GetWeather(double lat, double lon, CancellationToken cancellationToken = default);
}

As you can see, all it takes is a latitude and longitude pair, and it returns weather information for that location. This weather information is a class (or subclass) of WeatherInfo, which must contain this information:

public class WeatherInfo
{
public virtual double Latitude { get; set; }
public virtual double Longitude { get; set; }
public virtual float Temperature { get; set; }
}

I will pick the OpenWeatherMap service, because I've used it in the past, and it works well, from my experience. I created an account and got an API key to use.

Here is the service implementation for IWeatherService:

public class OpenWeatherMapWeatherService : IWeatherService
{
private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };

private readonly HttpClient _httpClient;
private readonly string? _apiKey;

public OpenWeatherMapWeatherService(HttpClient httpClient, IOptions<WeatherServiceOptions> options)
{
ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient));
ArgumentNullException.ThrowIfNull(options, nameof(options));
ArgumentException.ThrowIfNullOrWhiteSpace(options.Value.ApiKey, nameof(WeatherServiceOptions.ApiKey));

_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(@"https://api.openweathermap.org/data/2.5/weather");
_httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
_httpClient.DefaultRequestHeaders.Add(HeaderNames.CacheControl, "public, max-age=3600");

_apiKey = options.Value.ApiKey;
}

public async Task<WeatherInfo> GetWeather(double lat, double lon, CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync<OpenWeatherMapWeatherInfo>($"?lat={lat}&lon={lon}&appid={_apiKey}", _options, cancellationToken);
}
}

As you can see, this implementation takes, besides the HttpClient parameter, also an IOptions<WeatherServiceOptions> parameter, this is .NET's implementation of the Options Pattern. The WeatherServiceOptions class itself is very simple:

public class WeatherServiceOptions
{
public string? ApiKey { get; set; }
}

All it takes is an optional ApiKey value.

We also need a subclass of WeatherInfo for OpenWeatherMap:

class OpenWeatherMapWeatherInfo : WeatherInfo
{
public Coord? Coord { get; set; }
public Weather[]? Weather { get; set; }
public string? Base { get; set; }
public Main? Main { get; set; }
public int Visibility { get; set; }
public Wind? Wind { get; set; }
public Rain? Rain { get; set; }
public Clouds? Icon { get; set; }
public int Dt { get; set; }
public Sys? Sys { get; set; }
public int Timezone { get; set; }
public int Id { get; set; }
public string? Name { get; set; }
public int Cod { get; set; }

public override double Longitude => ((double?)Coord?.Lon).GetValueOrDefault();
public override double Latitude => ((double?)Coord?.Lat).GetValueOrDefault();
public override float Temperature => ((float?)Main?.Temp).GetValueOrDefault();
}

public record Coord(double Lon, double Lat);

public record Main(float Temp, float FeelsLike, float TempMin, float TempMax, int Pressure, int Humidity, int SeaLevel, int GrndLevel);

public record Wind(float Speed, int Deg, float Gust);

public record Rain(float Lh);

public record Clouds(int All);

public record Sys(int Type, int Id, string? Country, int Sunrise, int Sunset);

public record Weather(int Id, string? Main, string? Description, string? Icon);

Don't mind the mix of classes and records, just some personal choices, you can have it in different ways.

Finally, as usual, some extension methods to help:

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddWeatherService<TService>(this IServiceCollection services) where TService : class, IWeatherService
{
ArgumentNullException.ThrowIfNull(services, nameof(services));

services.AddHttpClient("WeatherService").AddTypedClient<IWeatherService, TService>();
return services;
}

public static IServiceCollection AddWeatherService<TService>(this IServiceCollection services, Action<WeatherServiceOptions> options) where TService : class, IWeatherService
{
ArgumentNullException.ThrowIfNull(services, nameof(services));
ArgumentNullException.ThrowIfNull(options, nameof(options));

services.Configure(options);
services.AddHttpClient("WeatherService").AddTypedClient<IWeatherService, TService>();
return services;
}
}

And we're done!

Conclusion

To wire everything together, we must register all services upfront:

builder.Services.AddGeoService<IPGeolocationGeoIPService>();
builder.Services.AddWeatherService<OpenMeteoWeatherService>(options => options.ApiKey = "<your-api-key>");

So, from, for example, a controller, a common usage would be:

public async Task<IActionResult> Address(string ipAddress, [FromServices] IGeoService geo, [FromServices] IWeatherService, CancellationToken cancellationToken)
{
var ip = string.IsNullOrWhiteSpace(ipAddress) ? HttpContext.GetRemoteIpAddress() : ipAddress;
var location = await geo.GetInfo(ip!, cancellationToken);
var wea = await weather.GetWeather(location.Latitude, location.Longitude, cancellationToken);
return Json(wea);
}

I hope you find this useful, and let me know if you find any issues!

                             

2 Comments

  • Hi Ricardo,

    Do you have similar article written for othe API such as IP2Location.io Free API?

  • @Michael: no, I have implementations for a few other services, it should be easy to implement your own. I didn't know this one, but, from the documentation, here is what I can tell you: for the API URL, on the IGeoService implementation, you will use as the base https://api.ip2location.io/, and you need to send, on the GetInfo, this: "?key=<your key>&ip=<some ip>". Just generate a class that inherits from GeoInfo for the sample response you get, which is like:
    {
    "ip": "46.175.50.250",
    "country_code": "GB",
    "country_name": "United Kingdom of Great Britain and Northern Ireland",
    "region_name": "England",
    "city_name": "Hessle",
    "latitude": 53.72454,
    "longitude": -0.43842,
    "zip_code": "HU13",
    "time_zone": "+01:00",
    "asn": "31727",
    "as": "Node4 Limited",
    "is_proxy": false
    }
    ContryName, CountryCode, Latitude, and Longitude are already there, so you just need to set the naming options to SnakeCaseLower. Just let me know if you run into any issue!

Add a Comment

As it will appear on the website

Not displayed

Your website