diff --git a/IPinfo.Tests/IPApiPlusTest.cs b/IPinfo.Tests/IPApiPlusTest.cs new file mode 100644 index 0000000..8abf287 --- /dev/null +++ b/IPinfo.Tests/IPApiPlusTest.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using Xunit; + +using IPinfo.Models; + +namespace IPinfo.Tests +{ + public class IPApiPlusTest + { + [Fact] + public void TestGetDetailsIPV4() + { + string ip = "8.8.8.8"; + IPinfoClientPlus client = new IPinfoClientPlus.Builder() + .AccessToken(Environment.GetEnvironmentVariable("IPINFO_TOKEN")) + .Build(); + + IPResponsePlus actual = client.IPApi.GetDetails(ip); + + Assert.Equal("8.8.8.8", actual.IP); + Assert.Equal("dns.google", actual.Hostname); + Assert.False(actual.Bogon); + + // Geo assertions + Assert.NotNull(actual.Geo); + Assert.NotNull(actual.Geo.City); + Assert.NotNull(actual.Geo.Region); + Assert.NotNull(actual.Geo.RegionCode); + Assert.Equal("US", actual.Geo.CountryCode); + Assert.Equal("United States", actual.Geo.Country); + Assert.Equal("United States", actual.Geo.CountryName); + Assert.False(actual.Geo.IsEU); + Assert.NotNull(actual.Geo.Continent); + Assert.NotNull(actual.Geo.ContinentCode); + Assert.NotEqual(0, actual.Geo.Latitude); + Assert.NotEqual(0, actual.Geo.Longitude); + Assert.NotNull(actual.Geo.Timezone); + Assert.NotNull(actual.Geo.PostalCode); + Assert.Equal("🇺🇸", actual.Geo.CountryFlag.Emoji); + Assert.Equal("U+1F1FA U+1F1F8", actual.Geo.CountryFlag.Unicode); + Assert.Equal("https://cdn.ipinfo.io/static/images/countries-flags/US.svg", actual.Geo.CountryFlagURL); + Assert.Equal("USD", actual.Geo.CountryCurrency.Code); + Assert.Equal("$", actual.Geo.CountryCurrency.Symbol); + Assert.Equal("NA", actual.Geo.ContinentInfo.Code); + Assert.Equal("North America", actual.Geo.ContinentInfo.Name); + + // AS assertions + Assert.NotNull(actual.As); + Assert.Equal("AS15169", actual.As.Asn); + Assert.NotNull(actual.As.Name); + Assert.NotNull(actual.As.Domain); + Assert.NotNull(actual.As.Type); + + // Network flags + Assert.False(actual.IsAnonymous); + Assert.True(actual.IsAnycast); + Assert.True(actual.IsHosting); + Assert.False(actual.IsMobile); + Assert.False(actual.IsSatellite); + + // Plus-specific fields (may be present based on token tier) + // These fields exist in the response structure + } + + [Fact] + public void TestGetDetailsIPV6() + { + string ip = "2001:4860:4860::8888"; + IPinfoClientPlus client = new IPinfoClientPlus.Builder() + .AccessToken(Environment.GetEnvironmentVariable("IPINFO_TOKEN")) + .Build(); + + IPResponsePlus actual = client.IPApi.GetDetails(ip); + + Assert.Equal("2001:4860:4860::8888", actual.IP); + + // Geo assertions + Assert.NotNull(actual.Geo); + Assert.Equal("US", actual.Geo.CountryCode); + Assert.Equal("United States", actual.Geo.Country); + Assert.NotNull(actual.Geo.City); + Assert.NotNull(actual.Geo.Region); + + // AS assertions + Assert.NotNull(actual.As); + Assert.NotNull(actual.As.Asn); + Assert.NotNull(actual.As.Name); + Assert.NotNull(actual.As.Domain); + + // Network flags + Assert.False(actual.IsAnonymous); + Assert.False(actual.IsMobile); + Assert.False(actual.IsSatellite); + } + + [Fact] + public void TestBogonIPV4() + { + string ip = "127.0.0.1"; + IPinfoClientPlus client = new IPinfoClientPlus.Builder() + .AccessToken(Environment.GetEnvironmentVariable("IPINFO_TOKEN")) + .Build(); + + IPResponsePlus actual = client.IPApi.GetDetails(ip); + + Assert.Equal("127.0.0.1", actual.IP); + Assert.True(actual.Bogon); + } + + [Fact] + public void TestBogonIPV6() + { + string ip = "2001:0:c000:200::0:255:1"; + IPinfoClientPlus client = new IPinfoClientPlus.Builder() + .AccessToken(Environment.GetEnvironmentVariable("IPINFO_TOKEN")) + .Build(); + + IPResponsePlus actual = client.IPApi.GetDetails(ip); + + Assert.Equal("2001:0:c000:200::0:255:1", actual.IP); + Assert.True(actual.Bogon); + } + } +} diff --git a/src/IPinfo/Apis/IPApiPlus.cs b/src/IPinfo/Apis/IPApiPlus.cs new file mode 100644 index 0000000..4ef3334 --- /dev/null +++ b/src/IPinfo/Apis/IPApiPlus.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Threading.Tasks; +using System.Threading; + +using IPinfo.Utilities; +using IPinfo.Http.Client; +using IPinfo.Http.Request; +using IPinfo.Models; +using IPinfo.Http.Response; +using IPinfo.Cache; + +namespace IPinfo.Apis +{ + /// + /// IPApiPlus. + /// + public sealed class IPApiPlus : BaseApi + { + /// + /// Initializes a new instance of the class. + /// + /// httpClient. + /// token. + /// cacheHandler. + internal IPApiPlus(IHttpClient httpClient, string token, CacheHandler cacheHandler) + : base(httpClient, token, cacheHandler) + { + this.BaseUrl = "https://api.ipinfo.io/lookup/"; + } + + /// + /// Retrieves details of an IP address. + /// + /// The IP address of the user to retrieve details for. + /// Returns the Models.IPResponsePlus response from the API call. + public Models.IPResponsePlus GetDetails( + IPAddress ipAddress) + { + string ipString = ipAddress?.ToString(); + return this.GetDetails(ipString); + } + + /// + /// Retrieves details of an IP address. + /// + /// The IP address of the user to retrieve details for. + /// Returns the Models.IPResponsePlus response from the API call. + public Models.IPResponsePlus GetDetails( + string ipAddress = "") + { + Task t = this.GetDetailsAsync(ipAddress); + ApiHelper.RunTaskSynchronously(t); + return t.Result; + } + + /// + /// Retrieves details of an IP address. + /// + /// The IP address of the user to retrieve details for. + /// Cancellation token if the request is cancelled. + /// Returns the Models.IPResponsePlus response from the API call. + public Task GetDetailsAsync( + IPAddress ipAddress, + CancellationToken cancellationToken = default) + { + string ipString = ipAddress?.ToString(); + return this.GetDetailsAsync(ipString, cancellationToken); + } + + /// + /// Retrieves details of an IP address. + /// + /// The IP address of the user to retrieve details for. + /// Cancellation token if the request is cancelled. + /// Returns the Models.IPResponsePlus response from the API call. + public async Task GetDetailsAsync( + string ipAddress = "", + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(ipAddress)) + { + ipAddress = ""; + } + + // first check the data in the cache if cache is available + IPResponsePlus ipResponse = (IPResponsePlus)GetFromCache(ipAddress); + if (ipResponse != null) + { + return ipResponse; + } + + if (BogonHelper.IsBogon(ipAddress)) + { + ipResponse = new IPResponsePlus() + { + IP = ipAddress, + Bogon = true + }; + return ipResponse; + } + + // prepare the API call request to fetch the response. + HttpRequest httpRequest = this.CreateGetRequest(this.BaseUrl + ipAddress); + // invoke request and get response. + HttpStringResponse response = await this.GetClientInstance().ExecuteAsStringAsync(httpRequest, cancellationToken).ConfigureAwait(false); + HttpContext context = new HttpContext(httpRequest, response); + + // handle errors defined at the API level. + this.ValidateResponse(context); + + var responseModel = JsonHelper.ParseIPResponsePlus(response.Body); + + SetInCache(ipAddress, responseModel); + return responseModel; + } + } +} diff --git a/src/IPinfo/IPinfoClientPlus.cs b/src/IPinfo/IPinfoClientPlus.cs new file mode 100644 index 0000000..5279974 --- /dev/null +++ b/src/IPinfo/IPinfoClientPlus.cs @@ -0,0 +1,120 @@ +using System; + +using IPinfo.Http.Client; +using IPinfo.Apis; +using IPinfo.Cache; +using IPinfo.Utilities; + +namespace IPinfo +{ + /// + /// The gateway for IPinfo Plus SDK. This class holds the configuration of the SDK. + /// + public sealed class IPinfoClientPlus + { + private readonly IHttpClient _httpClient; + private readonly CacheHandler _cacheHandler; + private readonly Lazy _ipApi; + + private IPinfoClientPlus( + string accessToken, + IHttpClient httpClient, + CacheHandler cacheHandler, + IHttpClientConfiguration httpClientConfiguration) + { + this._httpClient = httpClient; + this._cacheHandler = cacheHandler; + this.HttpClientConfiguration = httpClientConfiguration; + + this._ipApi = new Lazy( + () => new IPApiPlus(this._httpClient, accessToken, cacheHandler)); + } + + /// + /// Gets IPApiPlus. + /// + public IPApiPlus IPApi => this._ipApi.Value; + + /// + /// Gets the configuration of the Http Client associated with this client. + /// + public IHttpClientConfiguration HttpClientConfiguration { get; } + + /// + /// Gets the configuration of the Http Client associated with this client. + /// + public ICache Cache { get => _cacheHandler?.Cache; } + + /// + /// Builder class. + /// + public class Builder + { + private string _accessToken = ""; + private HttpClientConfiguration.Builder _httpClientConfig = new HttpClientConfiguration.Builder(); + private IHttpClient _httpClient; + private CacheHandler _cacheHandler = new CacheHandler(); + + /// + /// Sets credentials for BearerAuth. + /// + /// AccessToken. + /// Builder. + public Builder AccessToken(string accessToken) + { + this._accessToken = accessToken; + return this; + } + + /// + /// Sets HttpClientConfig. + /// + /// Action. + /// Builder. + public Builder HttpClientConfig(Action action) + { + if (action is null) + { + throw new ArgumentNullException(nameof(action)); + } + + action(this._httpClientConfig); + return this; + } + + /// + /// Sets the ICache implementation for the Builder. + /// + /// ICache implementation. Pass null to disable the cache. + /// Builder. + public Builder Cache(ICache cache) + { + // Null is allowed here, which is being used to indicate that user do not want the cache. + if(cache == null) + { + this._cacheHandler = null; + } + else + { + this._cacheHandler = new CacheHandler(cache); + } + return this; + } + + /// + /// Creates an object of the IPinfoClientPlus using the values provided for the builder. + /// + /// IPinfoClientPlus. + public IPinfoClientPlus Build() + { + this._httpClient = new HttpClientWrapper(this._httpClientConfig.Build()); + + return new IPinfoClientPlus( + _accessToken, + _httpClient, + _cacheHandler, + _httpClientConfig.Build()); + } + } + } +} diff --git a/src/IPinfo/Models/IPResponsePlus.cs b/src/IPinfo/Models/IPResponsePlus.cs new file mode 100644 index 0000000..e90d28b --- /dev/null +++ b/src/IPinfo/Models/IPResponsePlus.cs @@ -0,0 +1,181 @@ +using System.Text.Json.Serialization; + +namespace IPinfo.Models +{ + public class IPResponsePlus + { + [JsonInclude] + public string IP { get; internal set; } + + [JsonInclude] + public string Hostname { get; private set; } + + [JsonInclude] + public bool Bogon { get; internal set; } + + [JsonInclude] + public GeoPlus Geo { get; private set; } + + [JsonPropertyName("as")] + [JsonInclude] + public ASPlus As { get; private set; } + + [JsonInclude] + public MobilePlus Mobile { get; private set; } + + [JsonInclude] + public AnonymousPlus Anonymous { get; private set; } + + [JsonPropertyName("is_anonymous")] + [JsonInclude] + public bool IsAnonymous { get; private set; } + + [JsonPropertyName("is_anycast")] + [JsonInclude] + public bool IsAnycast { get; private set; } + + [JsonPropertyName("is_hosting")] + [JsonInclude] + public bool IsHosting { get; private set; } + + [JsonPropertyName("is_mobile")] + [JsonInclude] + public bool IsMobile { get; private set; } + + [JsonPropertyName("is_satellite")] + [JsonInclude] + public bool IsSatellite { get; private set; } + + [JsonInclude] + public Abuse Abuse { get; private set; } + + [JsonInclude] + public Company Company { get; private set; } + + [JsonInclude] + public Privacy Privacy { get; private set; } + + [JsonInclude] + public DomainsList Domains { get; private set; } + } + + public class GeoPlus + { + [JsonInclude] + public string City { get; private set; } + + [JsonInclude] + public string Region { get; private set; } + + [JsonPropertyName("region_code")] + [JsonInclude] + public string RegionCode { get; private set; } + + [JsonInclude] + public string Country { get; private set; } + + [JsonPropertyName("country_code")] + [JsonInclude] + public string CountryCode { get; private set; } + + public string CountryName { get; internal set; } + + public bool IsEU { get; internal set; } + + public CountryFlag CountryFlag { get; internal set; } + + public string CountryFlagURL { get; internal set; } + + public CountryCurrency CountryCurrency { get; internal set; } + + [JsonInclude] + public string Continent { get; private set; } + + [JsonPropertyName("continent_code")] + [JsonInclude] + public string ContinentCode { get; private set; } + + public Continent ContinentInfo { get; internal set; } + + [JsonInclude] + public double Latitude { get; private set; } + + [JsonInclude] + public double Longitude { get; private set; } + + [JsonInclude] + public string Timezone { get; private set; } + + [JsonPropertyName("postal_code")] + [JsonInclude] + public string PostalCode { get; private set; } + + [JsonPropertyName("dma_code")] + [JsonInclude] + public string DmaCode { get; private set; } + + [JsonPropertyName("geoname_id")] + [JsonInclude] + public string GeonameId { get; private set; } + + [JsonInclude] + public int Radius { get; private set; } + + [JsonPropertyName("last_changed")] + [JsonInclude] + public string LastChanged { get; private set; } + } + + public class ASPlus + { + [JsonInclude] + public string Asn { get; private set; } + + [JsonInclude] + public string Name { get; private set; } + + [JsonInclude] + public string Domain { get; private set; } + + [JsonInclude] + public string Type { get; private set; } + + [JsonPropertyName("last_changed")] + [JsonInclude] + public string LastChanged { get; private set; } + } + + public class MobilePlus + { + [JsonInclude] + public string Name { get; private set; } + + [JsonInclude] + public string Mcc { get; private set; } + + [JsonInclude] + public string Mnc { get; private set; } + } + + public class AnonymousPlus + { + [JsonPropertyName("is_proxy")] + [JsonInclude] + public bool IsProxy { get; private set; } + + [JsonPropertyName("is_relay")] + [JsonInclude] + public bool IsRelay { get; private set; } + + [JsonPropertyName("is_tor")] + [JsonInclude] + public bool IsTor { get; private set; } + + [JsonPropertyName("is_vpn")] + [JsonInclude] + public bool IsVpn { get; private set; } + + [JsonInclude] + public string Name { get; private set; } + } +} diff --git a/src/IPinfo/Utilities/JsonHelper.cs b/src/IPinfo/Utilities/JsonHelper.cs index e624fa8..47f5149 100644 --- a/src/IPinfo/Utilities/JsonHelper.cs +++ b/src/IPinfo/Utilities/JsonHelper.cs @@ -133,5 +133,27 @@ internal static IPResponseCore ParseIPResponseCore(string response) { return responseModel; } + + /// + /// IPResponsePlus object with extra manual parsing. + /// + /// The json string to be parsed. + /// The deserialized IPResponsePlus object with extra parsing for geo object country enrichment being done. + internal static IPResponsePlus ParseIPResponsePlus(string response) { + IPResponsePlus responseModel = JsonHelper.Deserialize(response); + + if (responseModel.Geo != null && !String.IsNullOrEmpty(responseModel.Geo.CountryCode)) + { + responseModel.Geo.CountryName = CountryHelper.GetCountry(responseModel.Geo.CountryCode); + responseModel.Geo.IsEU = CountryHelper.IsEU(responseModel.Geo.CountryCode); + responseModel.Geo.CountryFlag = CountryHelper.GetCountryFlag(responseModel.Geo.CountryCode); + responseModel.Geo.CountryCurrency = CountryHelper.GetCountryCurrency(responseModel.Geo.CountryCode); + responseModel.Geo.ContinentInfo = CountryHelper.GetContinent(responseModel.Geo.CountryCode); + responseModel.Geo.CountryFlagURL = CountryFlagURL + responseModel.Geo.CountryCode + ".svg"; + } + + return responseModel; + } } + }