Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 50 additions & 1 deletion src/IPData/Http/HttpClientAdapter.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,75 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace IPData
{
/// <summary>
/// Adapts an <see cref="HttpClient"/> to <see cref="IHttpClient"/>. Adapters created without an
/// <see cref="HttpClient"/> share one process-wide client, so every <see cref="IPDataClient"/> built that
/// way reuses the same pooled HTTPS connections for the lifetime of the process.
/// </summary>
internal class HttpClientAdapter : IHttpClient
{
/// <summary>
/// The process-wide default client. It is created on first use and intentionally never disposed:
/// disposing it would tear down the shared connection pool.
/// </summary>
private static readonly Lazy<HttpClient> _defaultClient =
new Lazy<HttpClient>(CreateDefaultClient, LazyThreadSafetyMode.ExecutionAndPublication);

private readonly HttpClient _httpClient;

/// <summary>Creates an adapter over the shared default <see cref="HttpClient"/>.</summary>
public HttpClientAdapter()
: this(new HttpClient())
: this(_defaultClient.Value)
{
}

/// <summary>Creates an adapter over a caller-owned <see cref="HttpClient"/>; the caller controls its lifetime.</summary>
/// <param name="httpClient">The HTTP client.</param>
public HttpClientAdapter(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(
nameof(httpClient),
$"The {nameof(httpClient)} can't be null");
}

/// <summary>The <see cref="HttpClient"/> that requests are sent through.</summary>
internal HttpClient Client => _httpClient;

public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request) =>
_httpClient.SendAsync(request);

/// <summary>Creates the default <see cref="HttpClient"/> over the handler from <see cref="CreateDefaultHandler"/>.</summary>
internal static HttpClient CreateDefaultClient() =>
new HttpClient(CreateDefaultHandler());

/// <summary>
/// Creates the default message handler. On .NET 5 and later it is a <see cref="SocketsHttpHandler"/>
/// 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
/// <see cref="HttpClientHandler"/>. Cookies are disabled in both cases because the API is stateless.
/// </summary>
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
}
}
}
5 changes: 5 additions & 0 deletions src/IPData/Http/Serializer/JsonSerializer.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace IPData.Http.Serializer
{
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<T>(string json)
Expand Down
7 changes: 4 additions & 3 deletions src/IPData/IPData.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<PackageTags>ip ipdata</PackageTags>
<Description>C# library to gather information for an IP using ipdata.co</Description>
Expand All @@ -20,10 +20,11 @@
<SignAssembly>false</SignAssembly>
<DelaySign>false</DelaySign>
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
<VersionPrefix>3.0.1</VersionPrefix>
<VersionPrefix>3.1.0</VersionPrefix>
</PropertyGroup>

<ItemGroup>
<!-- System.Text.Json ships in the shared framework on net8.0; only the netstandard2.0 asset needs the package. -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" Version="4.7.2" />
</ItemGroup>

Expand Down
93 changes: 66 additions & 27 deletions src/IPData/IPDataClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
namespace IPData
{
/// <summary>Implementation of <see cref="IIPDataClient"/>.</summary>
/// <remarks>
/// 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 <see cref="HttpClient"/> share one process-wide
/// client that lives as long as the process does and is never disposed; an <see cref="HttpClient"/> or
/// <see cref="IHttpClient"/> 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.
/// </remarks>
public class IPDataClient : IIPDataClient
{
/// <summary>The serializer</summary>
Expand Down Expand Up @@ -63,6 +72,16 @@ public IPDataClient(string apiKey, Uri baseUrl)
{
}

/// <summary>Initializes a new instance of the <see cref="IPDataClient"/> class with a caller-owned HTTP client and a custom base URL.</summary>
/// <param name="apiKey">The API key.</param>
/// <param name="httpClient">The HTTP client. The caller owns it and controls its lifetime.</param>
/// <param name="baseUrl">The base URL (e.g. https://eu-api.ipdata.co for the EU endpoint). Defaults to https://api.ipdata.co when null.</param>
/// <exception cref="ArgumentNullException">httpClient - The {nameof(httpClient)}</exception>
public IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl)
: this(apiKey, new HttpClientAdapter(httpClient), baseUrl)
{
}

/// <summary>Initializes a new instance of the <see cref="IPDataClient"/> class.</summary>
/// <param name="apiKey">The API key.</param>
/// <param name="httpClient">The HTTP client.</param>
Expand Down Expand Up @@ -94,7 +113,7 @@ public Task<IPLookupResult> Lookup() =>
public async Task<IPLookupResult> 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<IPLookupResult>(json);
}
Expand All @@ -107,7 +126,7 @@ public Task<IPLookupResult> Lookup(string ip) =>
public async Task<IPLookupResult> 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<IPLookupResult>(json);
}
Expand All @@ -116,15 +135,15 @@ public async Task<IPLookupResult> Lookup(string ip, CultureInfo culture)
public Task<string> Lookup(string ip, Expression<Func<IPLookupResult, object>> 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);
}

/// <inheritdoc />
public async Task<IPLookupResult> Lookup(string ip, params Expression<Func<IPLookupResult, object>>[] 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<IPLookupResult>(json);
}
Expand All @@ -133,10 +152,8 @@ public async Task<IPLookupResult> Lookup(string ip, params Expression<Func<IPLoo
public async Task<IEnumerable<IPLookupResult>> Lookup(IReadOnlyCollection<string> 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<IEnumerable<IPLookupResult>>(json);
Expand All @@ -146,7 +163,7 @@ public async Task<IEnumerable<IPLookupResult>> Lookup(IReadOnlyCollection<string
public async Task<CompanyInfo> 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<CompanyInfo>(json);
}
Expand All @@ -155,7 +172,7 @@ public async Task<CompanyInfo> Company(string ip)
public async Task<CarrierInfo> 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<CarrierInfo>(json);
}
Expand All @@ -164,7 +181,7 @@ public async Task<CarrierInfo> Carrier(string ip)
public async Task<AsnInfo> 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<AsnInfo>(json);
}
Expand All @@ -173,7 +190,7 @@ public async Task<AsnInfo> Asn(string ip)
public async Task<Models.TimeZone> 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<Models.TimeZone>(json);
}
Expand All @@ -182,7 +199,7 @@ public async Task<AsnInfo> Asn(string ip)
public async Task<Currency> 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<Currency>(json);
}
Expand All @@ -191,31 +208,53 @@ public async Task<Currency> Currency(string ip)
public async Task<Threat> 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<Threat>(json);
}

/// <summary>Sends the request asynchronous.</summary>
/// <summary>Creates the request message for an API call.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="method">The HTTP method.</param>
/// <param name="url">The request URL.</param>
/// <returns>The request message.</returns>
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;
}

/// <summary>Sends the request asynchronous and disposes the request and the response once the body has been read.</summary>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="request">The request.</param>
/// <returns>The HTTP request body.</returns>
/// <returns>The HTTP response body.</returns>
private static async Task<string> 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);
}
}
}
}
Loading
Loading