diff --git a/.github/README.md b/.github/README.md
index 219cc5d..d636259 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -18,6 +18,8 @@
- [Timezone](#timezone)
- [Currency](#currency)
- [Threat](#threat)
+ - [Response fields](#response-fields)
+- [Connection reuse and HTTP/2](#connection-reuse-and-http2)
- [EU Endpoint](#eu-endpoint)
- [Dependency Injection](#dependency-injection)
- [Migrating from v2 to v3](#migrating-from-v2-to-v3)
@@ -134,6 +136,43 @@ var threatInfo = await client.Threat("69.78.70.144");
Console.WriteLine($"Threat is Tor: {threatInfo.IsTor}");
```
+### Response fields
+
+`IPLookupResult` maps the JSON response one property per field (`is_eu` is `IsEu`, `country_name` is `CountryName`, and so on); nested objects such as `asn`, `threat` and `currency` have model classes of their own, and keys the library does not know are ignored. The most recent additions:
+
+| JSON field | Property | Description |
+|---|---|---|
+| `is_satellite` | `IPLookupResult.IsSatellite` | `true` for satellite ISPs. The API sends the key only when it is true; the property reads `false` otherwise. |
+| `is_anycast` | `IPLookupResult.IsAnycast` | `true` for anycast addresses. The API sends the key only when it is true; the property reads `false` otherwise. |
+| `threat.blocklists[].first_seen` | `BlocklistInfo.FirstSeen` | First day the address was seen on that blocklist, a `YYYY-MM-DD` (UTC) string; `null` when the feed does not report it. |
+| `threat.blocklists[].last_seen` | `BlocklistInfo.LastSeen` | Last day the address was seen on that blocklist, a `YYYY-MM-DD` (UTC) string; `null` when the feed does not report it. |
+| `threat.blocklists[].days_seen` | `BlocklistInfo.DaysSeen` | Number of days the address was seen on that blocklist (`int?`); `null` when the feed does not report it. |
+
+Both flags can be requested on their own or as part of a field selection:
+
+```csharp
+var isAnycast = await client.Lookup("8.8.8.8", x => x.IsAnycast);
+var flags = await client.Lookup("8.8.8.8", x => x.IsSatellite, x => x.IsAnycast);
+```
+
+## Connection reuse and HTTP/2
+
+An `IPDataClient` keeps a persistent HTTPS connection pool for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: a reused connection skips the TCP and TLS handshake that every new connection pays. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise.
+
+- **Thread safety.** `IPDataClient` holds no per-request state; one instance can serve any number of concurrent lookups from any thread.
+- **Shared default pool.** Clients constructed without an `HttpClient` (`new IPDataClient("API_KEY")`, with or without a base URL) share one process-wide `HttpClient`. It lives for the lifetime of the process and is never disposed, so there is nothing to close. On .NET 8+ it keeps idle connections for 60 seconds, recycles every connection after 5 minutes and sends HTTP/2 keep-alive pings every 30 seconds.
+- **HTTP/2 on .NET 8+.** The `net8.0` build asks for HTTP/2 on every request (`HttpVersionPolicy.RequestVersionOrLower`) and falls back to HTTP/1.1 automatically when the server or a proxy does not negotiate it. The `netstandard2.0` build, used by .NET Framework and other .NET Standard consumers, never asks for HTTP/2 and relies on HTTP/1.1 keep-alive.
+- **Bring your own `HttpClient`.** Pass an `HttpClient` to send every request through it, for example one managed by `IHttpClientFactory` (see [Dependency Injection](#dependency-injection)). It can be combined with a custom base URL through the `IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl)` constructor. You own that client's lifetime; the library never disposes it. The same applies to a custom `IHttpClient` implementation.
+
+```csharp
+// One client for the whole application; reuse it for every lookup.
+var client = new IPDataClient("API_KEY");
+
+// Your own HttpClient, here combined with the EU endpoint; you dispose it at shutdown.
+var httpClient = new HttpClient();
+var euClient = new IPDataClient("API_KEY", httpClient, new Uri("https://eu-api.ipdata.co"));
+```
+
## EU Endpoint
To ensure your data stays in the EU, use the EU endpoint by passing a custom base URL:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b829fa5..df80885 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,3 +26,6 @@ jobs:
- name: Test
run: dotnet test --configuration Release --no-build --verbosity normal
+
+ - name: Pack
+ run: dotnet pack src/IPData/IPData.csproj --configuration Release --no-build
diff --git a/src/IPData/Http/HttpClientAdapter.cs b/src/IPData/Http/HttpClientAdapter.cs
index e2e9242..11a1cc0 100644
--- a/src/IPData/Http/HttpClientAdapter.cs
+++ b/src/IPData/Http/HttpClientAdapter.cs
@@ -1,18 +1,34 @@
using System;
using System.Net.Http;
+using System.Threading;
using System.Threading.Tasks;
namespace IPData
{
+ ///
+ /// Adapts an to . Adapters created without an
+ /// share one process-wide client, so every built that
+ /// way reuses the same pooled HTTPS connections for the lifetime of the process.
+ ///
internal class HttpClientAdapter : IHttpClient
{
+ ///
+ /// The process-wide default client. It is created on first use and intentionally never disposed:
+ /// disposing it would tear down the shared connection pool.
+ ///
+ private static readonly Lazy _defaultClient =
+ new Lazy(CreateDefaultClient, LazyThreadSafetyMode.ExecutionAndPublication);
+
private readonly HttpClient _httpClient;
+ /// Creates an adapter over the shared default .
public HttpClientAdapter()
- : this(new HttpClient())
+ : this(_defaultClient.Value)
{
}
+ /// Creates an adapter over a caller-owned ; the caller controls its lifetime.
+ /// The HTTP client.
public HttpClientAdapter(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(
@@ -20,7 +36,40 @@ public HttpClientAdapter(HttpClient httpClient)
$"The {nameof(httpClient)} can't be null");
}
+ /// The that requests are sent through.
+ internal HttpClient Client => _httpClient;
+
public Task SendAsync(HttpRequestMessage request) =>
_httpClient.SendAsync(request);
+
+ /// Creates the default over the handler from .
+ internal static HttpClient CreateDefaultClient() =>
+ new HttpClient(CreateDefaultHandler());
+
+ ///
+ /// Creates the default message handler. On .NET 5 and later it is a
+ /// that recycles connections after 5 minutes, closes idle ones after 60 seconds and keeps HTTP/2
+ /// connections alive with a ping every 30 seconds; on .NET Standard 2.0 it is a plain
+ /// . Cookies are disabled in both cases because the API is stateless.
+ ///
+ internal static HttpMessageHandler CreateDefaultHandler()
+ {
+#if NET5_0_OR_GREATER
+ return new SocketsHttpHandler
+ {
+ PooledConnectionLifetime = TimeSpan.FromMinutes(5),
+ PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
+ KeepAlivePingDelay = TimeSpan.FromSeconds(30),
+ KeepAlivePingTimeout = TimeSpan.FromSeconds(15),
+ KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
+ UseCookies = false
+ };
+#else
+ return new HttpClientHandler
+ {
+ UseCookies = false
+ };
+#endif
+ }
}
}
diff --git a/src/IPData/Http/Serializer/JsonSerializer.cs b/src/IPData/Http/Serializer/JsonSerializer.cs
index 8890006..6a7f10d 100644
--- a/src/IPData/Http/Serializer/JsonSerializer.cs
+++ b/src/IPData/Http/Serializer/JsonSerializer.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace IPData.Http.Serializer
{
@@ -6,7 +7,11 @@ internal class JsonSerializer : ISerializer
{
private static readonly JsonSerializerOptions _options = new JsonSerializerOptions
{
+#if NET5_0_OR_GREATER
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+#else
IgnoreNullValues = true
+#endif
};
public T Deserialize(string json)
diff --git a/src/IPData/IPData.csproj b/src/IPData/IPData.csproj
index 6f1917c..9a11a02 100644
--- a/src/IPData/IPData.csproj
+++ b/src/IPData/IPData.csproj
@@ -1,7 +1,7 @@
- netstandard2.0
+ netstandard2.0;net8.0latestip ipdataC# library to gather information for an IP using ipdata.co
@@ -20,10 +20,11 @@
falsefalse$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb
- 3.0.1
+ 3.1.0
-
+
+
diff --git a/src/IPData/IPDataClient.cs b/src/IPData/IPDataClient.cs
index b2f05b9..5d96cab 100644
--- a/src/IPData/IPDataClient.cs
+++ b/src/IPData/IPDataClient.cs
@@ -16,6 +16,15 @@
namespace IPData
{
/// Implementation of .
+ ///
+ /// Create one instance per process (or one per API key or endpoint) and reuse it for every lookup: the
+ /// instance keeps its HTTPS connections pooled and reuses them across requests instead of paying a TLS
+ /// handshake per call. Instances created without an share one process-wide
+ /// client that lives as long as the process does and is never disposed; an or
+ /// passed in stays owned by the caller. The class holds no per-request state
+ /// and is safe to use from several threads at once. On .NET 5 and later every request asks for HTTP/2
+ /// and falls back to HTTP/1.1 when the server does not negotiate it.
+ ///
public class IPDataClient : IIPDataClient
{
/// The serializer
@@ -63,6 +72,16 @@ public IPDataClient(string apiKey, Uri baseUrl)
{
}
+ /// Initializes a new instance of the class with a caller-owned HTTP client and a custom base URL.
+ /// The API key.
+ /// The HTTP client. The caller owns it and controls its lifetime.
+ /// The base URL (e.g. https://eu-api.ipdata.co for the EU endpoint). Defaults to https://api.ipdata.co when null.
+ /// httpClient - The {nameof(httpClient)}
+ public IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl)
+ : this(apiKey, new HttpClientAdapter(httpClient), baseUrl)
+ {
+ }
+
/// Initializes a new instance of the class.
/// The API key.
/// The HTTP client.
@@ -94,7 +113,7 @@ public Task Lookup() =>
public async Task Lookup(CultureInfo culture)
{
var url = _apiUrls.Get(ApiKey, culture);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -107,7 +126,7 @@ public Task Lookup(string ip) =>
public async Task Lookup(string ip, CultureInfo culture)
{
var url = _apiUrls.Get(ApiKey, ip, culture);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -116,7 +135,7 @@ public async Task Lookup(string ip, CultureInfo culture)
public Task Lookup(string ip, Expression> fieldSelector)
{
var url = _apiUrls.Get(ApiKey, ip, fieldSelector);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
return SendRequestAsync(_httpClient, request);
}
@@ -124,7 +143,7 @@ public Task Lookup(string ip, Expression> f
public async Task Lookup(string ip, params Expression>[] fieldSelectors)
{
var url = _apiUrls.Get(ApiKey, ip, fieldSelectors);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -133,10 +152,8 @@ public async Task Lookup(string ip, params Expression> Lookup(IReadOnlyCollection ips)
{
var url = _apiUrls.Bulk(ApiKey);
- var request = new HttpRequestMessage(HttpMethod.Post, url)
- {
- Content = new StringContent(_serializer.Serialize(ips), Encoding.UTF8, "application/json")
- };
+ var request = CreateRequest(HttpMethod.Post, url);
+ request.Content = new StringContent(_serializer.Serialize(ips), Encoding.UTF8, "application/json");
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize>(json);
@@ -146,7 +163,7 @@ public async Task> Lookup(IReadOnlyCollection Company(string ip)
{
var url = _apiUrls.Company(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -155,7 +172,7 @@ public async Task Company(string ip)
public async Task Carrier(string ip)
{
var url = _apiUrls.Carrier(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -164,7 +181,7 @@ public async Task Carrier(string ip)
public async Task Asn(string ip)
{
var url = _apiUrls.Asn(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -173,7 +190,7 @@ public async Task Asn(string ip)
public async Task TimeZone(string ip)
{
var url = _apiUrls.TimeZone(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -182,7 +199,7 @@ public async Task Asn(string ip)
public async Task Currency(string ip)
{
var url = _apiUrls.Currency(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
@@ -191,31 +208,53 @@ public async Task Currency(string ip)
public async Task Threat(string ip)
{
var url = _apiUrls.Threat(ApiKey, ip);
- var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var request = CreateRequest(HttpMethod.Get, url);
var json = await SendRequestAsync(_httpClient, request).ConfigureAwait(false);
return _serializer.Deserialize(json);
}
- /// Sends the request asynchronous.
+ /// Creates the request message for an API call.
+ ///
+ /// On .NET 5 and later the request asks for HTTP/2 and lets the handler fall back to HTTP/1.1
+ /// (HttpVersionPolicy.RequestVersionOrLower). The .NET Standard 2.0 build leaves the default version
+ /// alone, because .NET Framework rejects requests that ask for HTTP/2.
+ ///
+ /// The HTTP method.
+ /// The request URL.
+ /// The request message.
+ private static HttpRequestMessage CreateRequest(HttpMethod method, Uri url)
+ {
+ var request = new HttpRequestMessage(method, url);
+#if NET5_0_OR_GREATER
+ request.Version = System.Net.HttpVersion.Version20;
+ request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
+#endif
+ return request;
+ }
+
+ /// Sends the request asynchronous and disposes the request and the response once the body has been read.
/// The HTTP client.
/// The request.
- /// The HTTP request body.
+ /// The HTTP response body.
private static async Task SendRequestAsync(IHttpClient httpClient, HttpRequestMessage request)
{
- var response = await httpClient.SendAsync(request).ConfigureAwait(false);
- var content = string.Empty;
-
- if (response.Content != null)
+ using (request)
+ using (var response = await httpClient.SendAsync(request).ConfigureAwait(false))
{
- content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
- }
+ var content = string.Empty;
- if (response.IsSuccessStatusCode)
- {
- return content;
- }
+ if (response.Content != null)
+ {
+ content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ }
+
+ if (response.IsSuccessStatusCode)
+ {
+ return content;
+ }
- throw _exceptionFactory.Create(response.StatusCode, content);
+ throw _exceptionFactory.Create(response.StatusCode, content);
+ }
}
}
}
diff --git a/src/IPData/Models/BlocklistInfo.cs b/src/IPData/Models/BlocklistInfo.cs
index 3f12f3f..79204a3 100644
--- a/src/IPData/Models/BlocklistInfo.cs
+++ b/src/IPData/Models/BlocklistInfo.cs
@@ -12,5 +12,26 @@ public class BlocklistInfo
[JsonPropertyName("type")]
public string Type { get; set; }
+
+ ///
+ /// First day the address was seen on this blocklist, as a YYYY-MM-DD (UTC) string,
+ /// or null when the feed does not report it.
+ ///
+ [JsonPropertyName("first_seen")]
+ public string FirstSeen { get; set; }
+
+ ///
+ /// Last day the address was seen on this blocklist, as a YYYY-MM-DD (UTC) string,
+ /// or null when the feed does not report it.
+ ///
+ [JsonPropertyName("last_seen")]
+ public string LastSeen { get; set; }
+
+ ///
+ /// Number of days the address was seen on this blocklist, or null when the feed does
+ /// not report it.
+ ///
+ [JsonPropertyName("days_seen")]
+ public int? DaysSeen { get; set; }
}
}
diff --git a/src/IPData/Models/IPLookupResult.cs b/src/IPData/Models/IPLookupResult.cs
index e69f754..ed31be0 100644
--- a/src/IPData/Models/IPLookupResult.cs
+++ b/src/IPData/Models/IPLookupResult.cs
@@ -16,6 +16,20 @@ public class IPLookupResult
[JsonPropertyName("is_eu")]
public bool? IsEu { get; set; }
+ ///
+ /// Whether the address belongs to a satellite ISP. The API sends the key only when it is true,
+ /// so this reads false when it is absent.
+ ///
+ [JsonPropertyName("is_satellite")]
+ public bool IsSatellite { get; set; }
+
+ ///
+ /// Whether the address is an anycast address. The API sends the key only when it is true,
+ /// so this reads false when it is absent.
+ ///
+ [JsonPropertyName("is_anycast")]
+ public bool IsAnycast { get; set; }
+
[JsonPropertyName("city")]
public string City { get; set; }
diff --git a/src/IPData/Models/Threat.cs b/src/IPData/Models/Threat.cs
index 58f0e8b..75f031e 100644
--- a/src/IPData/Models/Threat.cs
+++ b/src/IPData/Models/Threat.cs
@@ -32,7 +32,11 @@ public class Threat
[JsonPropertyName("is_datacenter")]
public bool? IsDatacenter { get; set; }
+ ///
+ /// The blocklists the address appears on. The setter is public because System.Text.Json
+ /// does not populate collection properties that have a non-public setter.
+ ///
[JsonPropertyName("blocklists")]
- public List Blocklists { get; private set; } = new List();
+ public List Blocklists { get; set; } = new List();
}
}
diff --git a/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultAnycast.json b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultAnycast.json
new file mode 100644
index 0000000..72d009d
--- /dev/null
+++ b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultAnycast.json
@@ -0,0 +1,91 @@
+{
+ "ip": "8.8.8.8",
+ "is_anycast": true,
+ "is_eu": false,
+ "city": null,
+ "region": null,
+ "region_code": null,
+ "region_type": null,
+ "country_name": "United States",
+ "country_code": "US",
+ "continent_name": "North America",
+ "continent_code": "NA",
+ "latitude": 39.7599983215332,
+ "longitude": -98.5,
+ "postal": null,
+ "calling_code": "1",
+ "flag": "https://ipdata.co/flags/us.png",
+ "emoji_flag": "🇺🇸",
+ "emoji_unicode": "U+1F1FA U+1F1F8",
+ "asn": {
+ "asn": "AS15169",
+ "name": "Google LLC",
+ "domain": "about.google",
+ "route": "8.8.8.0/24",
+ "type": "hosting"
+ },
+ "company": {
+ "name": "Google LLC",
+ "domain": "about.google",
+ "network": "8.8.8.0/24",
+ "type": "hosting"
+ },
+ "languages": [
+ {
+ "name": "English",
+ "native": "English",
+ "code": "en"
+ }
+ ],
+ "currency": {
+ "name": "US Dollar",
+ "code": "USD",
+ "symbol": "$",
+ "native": "$",
+ "plural": "US dollars"
+ },
+ "time_zone": {
+ "name": null,
+ "abbr": null,
+ "offset": null,
+ "is_dst": null,
+ "current_time": null
+ },
+ "threat": {
+ "is_tor": false,
+ "is_vpn": false,
+ "is_icloud_relay": false,
+ "is_proxy": false,
+ "is_datacenter": true,
+ "is_anonymous": false,
+ "is_known_attacker": false,
+ "is_known_abuser": false,
+ "is_threat": false,
+ "is_bogon": false,
+ "blocklists": [
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "cloud"
+ },
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "open_resolvers"
+ }
+ ],
+ "scores": {
+ "vpn_score": 100,
+ "proxy_score": 93,
+ "threat_score": 100,
+ "trust_score": 2
+ }
+ },
+ "count": "0"
+}
diff --git a/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultSatellite.json b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultSatellite.json
new file mode 100644
index 0000000..172aab7
--- /dev/null
+++ b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultSatellite.json
@@ -0,0 +1,34 @@
+{
+ "ip": "98.97.34.0",
+ "is_satellite": true,
+ "is_eu": false,
+ "city": "Seattle",
+ "country_code": "US",
+ "asn": {
+ "asn": "AS14593",
+ "name": "Space Exploration Technologies Corporation",
+ "domain": "spacex.com",
+ "route": "98.97.32.0/21",
+ "type": "business"
+ },
+ "threat": {
+ "is_tor": false,
+ "is_vpn": false,
+ "is_icloud_relay": false,
+ "is_proxy": false,
+ "is_datacenter": false,
+ "is_anonymous": false,
+ "is_known_attacker": false,
+ "is_known_abuser": false,
+ "is_threat": false,
+ "is_bogon": false,
+ "blocklists": [],
+ "scores": {
+ "vpn_score": 0,
+ "proxy_score": 1,
+ "threat_score": 8,
+ "trust_score": 97
+ }
+ },
+ "count": "0"
+}
diff --git a/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultVpn.json b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultVpn.json
new file mode 100644
index 0000000..65c6d36
--- /dev/null
+++ b/test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultVpn.json
@@ -0,0 +1,70 @@
+{
+ "ip": "187.13.250.1",
+ "is_eu": false,
+ "threat": {
+ "is_tor": false,
+ "is_vpn": true,
+ "is_icloud_relay": false,
+ "is_proxy": false,
+ "is_datacenter": true,
+ "is_anonymous": true,
+ "is_known_attacker": true,
+ "is_known_abuser": true,
+ "is_threat": true,
+ "is_bogon": false,
+ "blocklists": [
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "Gluetun",
+ "site": "https://github.com/qdm12/gluetun",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 1,
+ "first_seen": "2026-11-02",
+ "last_seen": "2026-11-02",
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "cloud"
+ },
+ {
+ "name": "ipdata",
+ "site": "https://ipdata.co",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "NordVPN",
+ "site": "https://nordvpn.com",
+ "type": "vpn"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "Wikimedia Global Blocklist",
+ "site": "https://wikimedia.org",
+ "type": "general"
+ },
+ {
+ "days_seen": 2,
+ "first_seen": "2026-10-31",
+ "last_seen": "2026-11-02",
+ "name": "X4BNet",
+ "site": "https://github.com/X4BNet/lists_vpn",
+ "type": "vpn"
+ }
+ ],
+ "scores": {
+ "vpn_score": 100,
+ "proxy_score": 99,
+ "threat_score": 100,
+ "trust_score": 0
+ }
+ },
+ "count": "0"
+}
diff --git a/test/Unit/IPData.Tests/DataSources/TestDataSource.cs b/test/Unit/IPData.Tests/DataSources/TestDataSource.cs
index def8814..5a9878e 100644
--- a/test/Unit/IPData.Tests/DataSources/TestDataSource.cs
+++ b/test/Unit/IPData.Tests/DataSources/TestDataSource.cs
@@ -28,6 +28,33 @@ public static IEnumerable