From 17c85474fb53674650fd05854ba0b88d97f6b7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:32:15 +0000 Subject: [PATCH 1/2] Reuse HTTPS connections and negotiate HTTP/2 Every IPDataClient created without an HttpClient used to build its own HttpClient, so each client paid a fresh TCP and TLS handshake, and the netstandard2.0-only assembly never negotiated HTTP/2. * Target netstandard2.0 and net8.0. System.Text.Json is referenced only for netstandard2.0; it ships in the shared framework on net8.0. * HttpClientAdapter: default adapters share one process-wide HttpClient built lazily by CreateDefaultClient(). On .NET 5+ it wraps a SocketsHttpHandler with a 5 minute connection lifetime, a 60 s idle timeout, HTTP/2 keep-alive pings every 30 s (15 s timeout, policy Always) and cookies disabled; on .NET Standard 2.0 an HttpClientHandler with cookies disabled. The shared client is never disposed (process lifetime). User-supplied HttpClient/IHttpClient instances stay user-owned. The wrapped client is exposed internally for tests. * IPDataClient: all eleven requests are built by CreateRequest(), which on .NET 5+ asks for HTTP/2 with HttpVersionPolicy.RequestVersionOrLower so the handler falls back to HTTP/1.1 when h2 is not negotiated. The netstandard2.0 asset never sets Version 2.0 (.NET Framework throws). SendRequestAsync disposes the request and the response. New additive constructor IPDataClient(string, HttpClient, Uri) combines a caller-owned HttpClient with a custom base URL. * JsonSerializer: IgnoreNullValues is obsolete on net8.0, so that build uses DefaultIgnoreCondition = WhenWritingNull instead. * Tests: request version and policy are asserted for GET and bulk POST through the existing Mock; a success-path test proves disposal does not break deserialisation; HttpClientAdapterTests cover the shared default client, pass-through of a custom client and the default handler configuration; the real-network test is removed. A RecordingHttpMessageHandler drives a real HttpClient offline. * README: new "Connection reuse and HTTP/2" section. CI: pack step so multi-TFM packaging failures surface on pull requests. * VersionPrefix 3.0.1 -> 3.1.0 (publish derives the version from the tag). Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- .github/README.md | 19 ++ .github/workflows/ci.yml | 3 + src/IPData/Http/HttpClientAdapter.cs | 51 +++++- src/IPData/Http/Serializer/JsonSerializer.cs | 5 + src/IPData/IPData.csproj | 7 +- src/IPData/IPDataClient.cs | 93 +++++++--- .../Http/HttpClientAdapterTests.cs | 103 ++++++++++- .../Http/RecordingHttpMessageHandler.cs | 36 ++++ test/Unit/IPData.Tests/IPDataClientTests.cs | 167 ++++++++++++++++++ 9 files changed, 444 insertions(+), 40 deletions(-) create mode 100644 test/Unit/IPData.Tests/Http/RecordingHttpMessageHandler.cs diff --git a/.github/README.md b/.github/README.md index 219cc5d..83c8cb2 100644 --- a/.github/README.md +++ b/.github/README.md @@ -18,6 +18,7 @@ - [Timezone](#timezone) - [Currency](#currency) - [Threat](#threat) +- [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 +135,24 @@ var threatInfo = await client.Threat("69.78.70.144"); Console.WriteLine($"Threat is Tor: {threatInfo.IsTor}"); ``` +## 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.0 latest ip ipdata C# library to gather information for an IP using ipdata.co @@ -20,10 +20,11 @@ false false $(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/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs b/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs index 29e8840..32b5af0 100644 --- a/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs +++ b/test/Unit/IPData.Tests/Http/HttpClientAdapterTests.cs @@ -1,24 +1,51 @@ -using FluentAssertions; -using System; +using System; +using System.Net; using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; using Xunit; namespace IPData.Tests.Http { public class HttpClientAdapterTests { - [Theory, AutoMoqData] - public void SendAsync_WhenCalled_ShouldReturnHttpResponseMessage( - HttpRequestMessage requestMessage) + [Fact] + public void HttpClientAdapter_WhenCreatedWithoutHttpClient_ShouldShareOneHttpClientBetweenInstances() { - // Arrange - var sut = new HttpClientAdapter(); + // Act + var first = new HttpClientAdapter(); + var second = new HttpClientAdapter(); + // Assert + first.Client.Should().NotBeNull(); + second.Client.Should().BeSameAs(first.Client); + } + + [Fact] + public void HttpClientAdapter_WhenCreatedWithoutHttpClient_ShouldUseThePooledDefaultHandler() + { // Act - var actual = sut.SendAsync(requestMessage); + var sut = new HttpClientAdapter(); // Assert - actual.Should().NotBeNull(); + GetHandler(sut.Client).Should().BeOfType() + .Which.PooledConnectionIdleTimeout.Should().Be(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void HttpClientAdapter_WhenCreatedWithHttpClient_ShouldUseThatHttpClient() + { + // Arrange + using (var httpClient = new HttpClient()) + { + // Act + var sut = new HttpClientAdapter(httpClient); + + // Assert + sut.Client.Should().BeSameAs(httpClient); + sut.Client.Should().NotBeSameAs(new HttpClientAdapter().Client); + } } [Fact] @@ -33,5 +60,63 @@ public void HttpClientAdapter_WhenCreateWithInvalidHttpClient_ShouldThrowArgumen .Where(e => e.ParamName == "httpClient") .Where(e => e.Message.Contains("The httpClient can't be null")); } + + [Fact] + public async Task SendAsync_WhenCalledRepeatedly_ShouldSendEveryRequestThroughTheSameHttpClient() + { + // Arrange + var handler = new RecordingHttpMessageHandler(); + using (var httpClient = new HttpClient(handler)) + { + var sut = new HttpClientAdapter(httpClient); + + // Act + using (var first = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/8.8.8.8")).ConfigureAwait(false)) + using (var second = await sut.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://api.ipdata.co/1.1.1.1")).ConfigureAwait(false)) + { + // Assert + first.StatusCode.Should().Be(HttpStatusCode.OK); + second.StatusCode.Should().Be(HttpStatusCode.OK); + handler.Requests.Should().HaveCount(2); + } + } + } + + [Fact] + public void CreateDefaultHandler_WhenCalled_ShouldConfigurePooledSocketsHttpHandler() + { + // Act + using (var handler = HttpClientAdapter.CreateDefaultHandler()) + { + // Assert + var socketsHandler = handler.Should().BeOfType().Which; + socketsHandler.PooledConnectionLifetime.Should().Be(TimeSpan.FromMinutes(5)); + socketsHandler.PooledConnectionIdleTimeout.Should().Be(TimeSpan.FromSeconds(60)); + socketsHandler.KeepAlivePingDelay.Should().Be(TimeSpan.FromSeconds(30)); + socketsHandler.KeepAlivePingTimeout.Should().Be(TimeSpan.FromSeconds(15)); + socketsHandler.KeepAlivePingPolicy.Should().Be(HttpKeepAlivePingPolicy.Always); + socketsHandler.UseCookies.Should().BeFalse(); + } + } + + [Fact] + public void CreateDefaultClient_WhenCalled_ShouldWrapTheDefaultHandler() + { + // Act + using (var client = HttpClientAdapter.CreateDefaultClient()) + { + // Assert + GetHandler(client).Should().BeOfType() + .Which.KeepAlivePingPolicy.Should().Be(HttpKeepAlivePingPolicy.Always); + } + } + + /// HttpClient has no public accessor for its handler; HttpMessageInvoker keeps it in a private field. + private static HttpMessageHandler GetHandler(HttpClient client) + { + var field = typeof(HttpMessageInvoker).GetField("_handler", BindingFlags.Instance | BindingFlags.NonPublic); + field.Should().NotBeNull("HttpMessageInvoker is expected to store its handler in a private field named _handler"); + return (HttpMessageHandler)field.GetValue(client); + } } } diff --git a/test/Unit/IPData.Tests/Http/RecordingHttpMessageHandler.cs b/test/Unit/IPData.Tests/Http/RecordingHttpMessageHandler.cs new file mode 100644 index 0000000..6fa753f --- /dev/null +++ b/test/Unit/IPData.Tests/Http/RecordingHttpMessageHandler.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace IPData.Tests.Http +{ + /// + /// Records every request it receives and answers each one with a fresh 200 OK JSON response, + /// so tests can drive a real without touching the network. + /// + public sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + private readonly string _responseBody; + + public RecordingHttpMessageHandler(string responseBody = "{}") + { + _responseBody = responseBody; + } + + public List Requests { get; } = new List(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(_responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/test/Unit/IPData.Tests/IPDataClientTests.cs b/test/Unit/IPData.Tests/IPDataClientTests.cs index 1662abe..46e3fc9 100644 --- a/test/Unit/IPData.Tests/IPDataClientTests.cs +++ b/test/Unit/IPData.Tests/IPDataClientTests.cs @@ -1,12 +1,15 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; +using System.Text; using System.Threading.Tasks; using AutoFixture.Xunit2; using FluentAssertions; using IPData.Exceptions; using IPData.Tests.DataSources; +using IPData.Tests.Http; using Moq; using Xunit; @@ -66,6 +69,170 @@ public void IPDataClient_WhenCreatedWithValidApiKeyAndHttpClient_ShouldCreateCli act.Should().NotThrow(); } + private const string LookupJson = + "{\"ip\":\"8.8.8.8\",\"is_eu\":false,\"country_name\":\"United States\",\"count\":\"0\"}"; + + private const string BulkJson = + "[{\"ip\":\"1.1.1.1\",\"country_name\":\"Australia\",\"count\":\"0\"}," + + "{\"ip\":\"2.2.2.2\",\"country_name\":\"France\",\"count\":\"0\"}]"; + + private static HttpResponseMessage OkJson(string json) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + [Theory, AutoMoqData] + public void IPDataClient_WhenCreatedWithHttpClientAndBaseUrl_ShouldCreateClient( + string apiKey) + { + // Arrange + using (var httpClient = new HttpClient()) + { + // Act + var sut = new IPDataClient(apiKey, httpClient, new Uri("https://eu-api.ipdata.co")); + + // Assert + sut.ApiKey.Should().Be(apiKey); + } + } + + [Theory, AutoMoqData] + public void IPDataClient_WhenCreatedWithNullHttpClientAndBaseUrl_ThrowArgumentNullException( + string apiKey) + { + // Arrange + Action act = () => new IPDataClient(apiKey, (HttpClient)null, new Uri("https://eu-api.ipdata.co")); + + // Act/Assert + act.Should() + .Throw() + .Where(e => e.ParamName == "httpClient") + .Where(e => e.Message.Contains("The httpClient can't be null")); + } + + [Fact] + public async Task Lookup_WhenCreatedWithHttpClientAndBaseUrl_ShouldSendThroughThatClientToThatBaseUrl() + { + // Arrange + var handler = new RecordingHttpMessageHandler(LookupJson); + using (var httpClient = new HttpClient(handler)) + { + var sut = new IPDataClient("test-api-key", httpClient, new Uri("https://eu-api.ipdata.co")); + + // Act + var actual = await sut.Lookup("8.8.8.8").ConfigureAwait(false); + + // Assert + actual.Ip.Should().Be("8.8.8.8"); + handler.Requests.Should().ContainSingle() + .Which.RequestUri.AbsoluteUri.Should().Be("https://eu-api.ipdata.co/8.8.8.8?api-key=test-api-key"); + } + } + + [Fact] + public async Task Lookup_WhenCalled_ShouldRequestHttp2WithFallbackToLowerVersions() + { + // Arrange + var requests = new List(); + var httpClient = new Mock(); + httpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Callback(requests.Add) + .Returns(() => Task.FromResult(OkJson(LookupJson))); + + var sut = new IPDataClient("test-api-key", httpClient.Object); + + // Act + var actual = await sut.Lookup("8.8.8.8").ConfigureAwait(false); + + // Assert + actual.Ip.Should().Be("8.8.8.8"); + var request = requests.Should().ContainSingle().Which; + request.Method.Should().Be(HttpMethod.Get); + request.Version.Should().Be(HttpVersion.Version20); + request.VersionPolicy.Should().Be(HttpVersionPolicy.RequestVersionOrLower); + } + + [Fact] + public async Task Lookup_WhenCalledWithIpList_ShouldPostJsonAndRequestHttp2WithFallbackToLowerVersions() + { + // Arrange + var requests = new List(); + var bodies = new List(); + var httpClient = new Mock(); + httpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Callback(r => + { + requests.Add(r); + // Read the body here: the client disposes the request once the call completes. + bodies.Add(r.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + }) + .Returns(() => Task.FromResult(OkJson(BulkJson))); + + var sut = new IPDataClient("test-api-key", httpClient.Object); + + // Act + var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }).ConfigureAwait(false); + + // Assert + actual.Select(x => x.Ip).Should().Equal("1.1.1.1", "2.2.2.2"); + var request = requests.Should().ContainSingle().Which; + request.Method.Should().Be(HttpMethod.Post); + request.Version.Should().Be(HttpVersion.Version20); + request.VersionPolicy.Should().Be(HttpVersionPolicy.RequestVersionOrLower); + bodies.Should().ContainSingle().Which.Should().Be("[\"1.1.1.1\",\"2.2.2.2\"]"); + } + + [Fact] + public async Task Lookup_WhenSuccessful_ShouldDisposeRequestAndResponseAfterReadingTheBody() + { + // Arrange + var response = OkJson(BulkJson); + HttpRequestMessage request = null; + var httpClient = new Mock(); + httpClient + .Setup(x => x.SendAsync(It.IsAny())) + .Callback(r => request = r) + .Returns(Task.FromResult(response)); + + var sut = new IPDataClient("test-api-key", httpClient.Object); + + // Act + var actual = await sut.Lookup(new[] { "1.1.1.1", "2.2.2.2" }).ConfigureAwait(false); + + // Assert: the body was deserialized before anything was disposed... + actual.Select(x => x.Ip).Should().Equal("1.1.1.1", "2.2.2.2"); + + // ...and both messages are disposed afterwards, so their content can no longer be read. + Func readRequest = () => request.Content.ReadAsStringAsync(); + Func readResponse = () => response.Content.ReadAsStringAsync(); + await readRequest.Should().ThrowAsync().ConfigureAwait(false); + await readResponse.Should().ThrowAsync().ConfigureAwait(false); + } + + [Fact] + public async Task Lookup_WhenCalledRepeatedlyOnOneInstance_ShouldSendEveryRequestThroughTheSameHttpClient() + { + // Arrange + var handler = new RecordingHttpMessageHandler(LookupJson); + using (var httpClient = new HttpClient(handler)) + { + var sut = new IPDataClient("test-api-key", httpClient); + + // Act + await sut.Lookup().ConfigureAwait(false); + await sut.Lookup("8.8.8.8").ConfigureAwait(false); + await sut.Threat("8.8.8.8").ConfigureAwait(false); + + // Assert + handler.Requests.Should().HaveCount(3); + handler.Requests.Should().OnlyContain(r => r.Version == HttpVersion.Version20); + handler.Requests.Should().OnlyContain(r => r.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower); + } + } + public static IEnumerable StatusCodeExceptionData() { yield return new object[] { HttpStatusCode.BadRequest, typeof(BadRequestException) }; From 9d45708f741a9addfe15a98af47b45bca41dc9c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:38:57 +0000 Subject: [PATCH 2/2] Add is_satellite, is_anycast and dated blocklist entries The API now returns two more top-level booleans and three optional fields on every threat.blocklists entry. * IPLookupResult gains bool IsSatellite ("is_satellite") and bool IsAnycast ("is_anycast") next to IsEu. The API includes each key only when it is true, so both read false when the key is absent. Because they carry [JsonPropertyName], x => x.IsSatellite / x => x.IsAnycast work as field selectors for single-field and fields= lookups. * BlocklistInfo gains string FirstSeen ("first_seen"), string LastSeen ("last_seen", both YYYY-MM-DD UTC, kept as strings) and int? DaysSeen ("days_seen"). Some feeds omit all three, so they are nullable. * Threat.Blocklists now has a public setter: System.Text.Json does not populate collection properties with a non-public setter, so since the move from Newtonsoft.Json the list always deserialised empty and the new fields would have been unreachable. * System.Text.Json already ignores unknown members (is_vpn, scores and future keys); a test pins that. * Tests: fixtures built from the three spec samples (satellite ISP, anycast address with dated blocklist entries, VPN exit with an undated entry) assert the typed values, the false-when-absent behaviour, the per-item shape of bulk responses and that the two new names are accepted as selectable fields. * README: "Response fields" section documenting the five names. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- .github/README.md | 20 +++ src/IPData/Models/BlocklistInfo.cs | 21 +++ src/IPData/Models/IPLookupResult.cs | 14 ++ src/IPData/Models/Threat.cs | 6 +- .../TestData/IPLookupResultAnycast.json | 91 +++++++++++++ .../TestData/IPLookupResultSatellite.json | 34 +++++ .../TestData/IPLookupResultVpn.json | 70 ++++++++++ .../DataSources/TestDataSource.cs | 27 ++++ .../Unit/IPData.Tests/Helpers/ApiUrlsTests.cs | 39 ++++++ .../Http/Serializer/JsonSerializerTests.cs | 122 +++++++++++++++++- 10 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultAnycast.json create mode 100644 test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultSatellite.json create mode 100644 test/Unit/IPData.Tests/DataSources/TestData/IPLookupResultVpn.json diff --git a/.github/README.md b/.github/README.md index 83c8cb2..d636259 100644 --- a/.github/README.md +++ b/.github/README.md @@ -18,6 +18,7 @@ - [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) @@ -135,6 +136,25 @@ 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. 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 CompanyData() yield return new object[] { ReadJsonFile("Company.json") }; } + public static IEnumerable SatelliteData() + { + yield return new object[] { ReadJsonFile("IPLookupResultSatellite.json") }; + } + + public static IEnumerable AnycastData() + { + yield return new object[] { ReadJsonFile("IPLookupResultAnycast.json") }; + } + + public static IEnumerable VpnData() + { + yield return new object[] { ReadJsonFile("IPLookupResultVpn.json") }; + } + + public static IEnumerable BulkData() + { + var items = new[] + { + ReadJsonFile("IPLookupResultSatellite.json"), + ReadJsonFile("IPLookupResultAnycast.json"), + ReadJsonFile("IPLookupResultVpn.json") + }; + + yield return new object[] { "[" + string.Join(",", items) + "]" }; + } + private static string ReadJsonFile(string fileName) { var assembly = Assembly.GetExecutingAssembly(); diff --git a/test/Unit/IPData.Tests/Helpers/ApiUrlsTests.cs b/test/Unit/IPData.Tests/Helpers/ApiUrlsTests.cs index 21c33f4..0bebdb2 100644 --- a/test/Unit/IPData.Tests/Helpers/ApiUrlsTests.cs +++ b/test/Unit/IPData.Tests/Helpers/ApiUrlsTests.cs @@ -101,6 +101,45 @@ public void Get_WhenCalledWithMultipleFields_ReturnedUrl(string apiKey, string i url.Should().BeEquivalentTo(expectedUrl); } + [Theory, AutoMoqData] + public void Get_WhenCalledWithIsSatellite_ReturnedUrl(string apiKey, string ip) + { + // Arrange + var expectedUrl = $"https://api.ipdata.co/{ip}/is_satellite?api-key={apiKey}"; + + // Act + var url = new ApiUrls().Get(apiKey, ip, x => x.IsSatellite); + + // Assert + url.AbsoluteUri.Should().Be(expectedUrl); + } + + [Theory, AutoMoqData] + public void Get_WhenCalledWithIsAnycast_ReturnedUrl(string apiKey, string ip) + { + // Arrange + var expectedUrl = $"https://api.ipdata.co/{ip}/is_anycast?api-key={apiKey}"; + + // Act + var url = new ApiUrls().Get(apiKey, ip, x => x.IsAnycast); + + // Assert + url.AbsoluteUri.Should().Be(expectedUrl); + } + + [Theory, AutoMoqData] + public void Get_WhenCalledWithIsSatelliteAndIsAnycast_ReturnedUrl(string apiKey, string ip) + { + // Arrange + var expectedUrl = new Uri($"https://api.ipdata.co/{ip}?fields=is_satellite%2cis_anycast&api-key={apiKey}"); + + // Act + var url = new ApiUrls().Get(apiKey, ip, x => x.IsSatellite, x => x.IsAnycast); + + // Assert + url.Should().BeEquivalentTo(expectedUrl); + } + [Theory, AutoMoqData] public void Get_WhenCalledWithInvalidProp_ReturnedUrl(string apiKey, string ip) { diff --git a/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs b/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs index 7e9a6cc..19c90a7 100644 --- a/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs +++ b/test/Unit/IPData.Tests/Http/Serializer/JsonSerializerTests.cs @@ -1,4 +1,6 @@ -using FluentAssertions; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; using IPData.Http.Serializer; using IPData.Models; using IPData.Tests.DataSources; @@ -47,6 +49,124 @@ public void Deserialize_CountAsNumber_ParsedCorrectly() actual.Count.Should().Be(213586); } + [Theory] + [MemberData(nameof(TestDataSource.IPLookupResultData), MemberType = typeof(TestDataSource))] + public void Deserialize_WhenSatelliteAndAnycastKeysAbsent_ReadFalse(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.IsSatellite.Should().BeFalse(); + actual.IsAnycast.Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(TestDataSource.SatelliteData), MemberType = typeof(TestDataSource))] + public void Deserialize_SatelliteResponse_ReadsIsSatellite(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Ip.Should().Be("98.97.34.0"); + actual.IsSatellite.Should().BeTrue(); + actual.IsAnycast.Should().BeFalse(); + actual.IsEu.Should().BeFalse(); + actual.Asn.Name.Should().Be("Space Exploration Technologies Corporation"); + actual.Threat.Blocklists.Should().BeEmpty(); + actual.Count.Should().Be(0); + } + + [Theory] + [MemberData(nameof(TestDataSource.AnycastData), MemberType = typeof(TestDataSource))] + public void Deserialize_AnycastResponse_ReadsIsAnycastAndDatedBlocklistEntries(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Ip.Should().Be("8.8.8.8"); + actual.IsAnycast.Should().BeTrue(); + actual.IsSatellite.Should().BeFalse(); + actual.City.Should().BeNull(); + actual.Threat.IsDatacenter.Should().BeTrue(); + actual.Threat.Blocklists.Should().HaveCount(2); + + var cloud = actual.Threat.Blocklists[0]; + cloud.Name.Should().Be("ipdata"); + cloud.Site.Should().Be("https://ipdata.co"); + cloud.Type.Should().Be("cloud"); + cloud.FirstSeen.Should().Be("2026-11-02"); + cloud.LastSeen.Should().Be("2026-11-02"); + cloud.DaysSeen.Should().Be(1); + + actual.Threat.Blocklists[1].Type.Should().Be("open_resolvers"); + actual.Threat.Blocklists[1].DaysSeen.Should().Be(1); + } + + [Theory] + [MemberData(nameof(TestDataSource.VpnData), MemberType = typeof(TestDataSource))] + public void Deserialize_VpnResponse_LeavesFlagsFalseAndBlocklistDatesNullWhenAbsent(string json) + { + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Ip.Should().Be("187.13.250.1"); + actual.IsSatellite.Should().BeFalse(); + actual.IsAnycast.Should().BeFalse(); + actual.Threat.IsThreat.Should().BeTrue(); + actual.Threat.Blocklists.Should().HaveCount(6); + + var gluetun = actual.Threat.Blocklists[0]; + gluetun.Name.Should().Be("Gluetun"); + gluetun.FirstSeen.Should().Be("2026-10-31"); + gluetun.LastSeen.Should().Be("2026-11-02"); + gluetun.DaysSeen.Should().Be(2); + + var undated = actual.Threat.Blocklists[2]; + undated.Name.Should().Be("ipdata"); + undated.Type.Should().Be("vpn"); + undated.FirstSeen.Should().BeNull(); + undated.LastSeen.Should().BeNull(); + undated.DaysSeen.Should().BeNull(); + } + + [Theory] + [MemberData(nameof(TestDataSource.BulkData), MemberType = typeof(TestDataSource))] + public void Deserialize_BulkResponse_CarriesNewFieldsPerItem(string json) + { + // Act + var actual = _sut.Deserialize>(json).ToList(); + + // Assert + actual.Should().HaveCount(3); + actual[0].IsSatellite.Should().BeTrue(); + actual[0].IsAnycast.Should().BeFalse(); + actual[1].IsAnycast.Should().BeTrue(); + actual[1].IsSatellite.Should().BeFalse(); + actual[1].Threat.Blocklists[0].DaysSeen.Should().Be(1); + actual[2].IsSatellite.Should().BeFalse(); + actual[2].IsAnycast.Should().BeFalse(); + actual[2].Threat.Blocklists[2].DaysSeen.Should().BeNull(); + } + + [Fact] + public void Deserialize_UnknownKeys_AreIgnored() + { + // Arrange + var json = "{\"ip\":\"1.1.1.1\",\"some_future_field\":{\"nested\":true}," + + "\"threat\":{\"blocklists\":[{\"name\":\"ipdata\",\"some_future_field\":1}]}}"; + + // Act + var actual = _sut.Deserialize(json); + + // Assert + actual.Ip.Should().Be("1.1.1.1"); + actual.Threat.Blocklists.Should().ContainSingle().Which.Name.Should().Be("ipdata"); + } + [Theory, AutoMoqData] public void SerializeIPLookupResult_WhenCalled_ReturnedString(IPLookupResult ipInfo) {