diff --git a/src/backend/services/geoip.ts b/src/backend/services/geoip.ts
index 3197987..73cbf43 100644
--- a/src/backend/services/geoip.ts
+++ b/src/backend/services/geoip.ts
@@ -75,7 +75,7 @@ class GeoIPService {
const result: SimplifiedGeoIPResponse = {
ip,
country: cityResponse.country?.names?.en || 'Unknown',
- country_code: cityResponse.country?.isoCode || 'XX',
+ country_code: cityResponse.country?.isoCode || 'Unknown',
is_in_european_union: cityResponse.country?.isInEuropeanUnion || false,
region: cityResponse.subdivisions?.[0]?.names?.en || 'Unknown',
region_code: cityResponse.subdivisions?.[0]?.isoCode || null,
diff --git a/src/clients/dotnet/Bitip.Client.Tests/Class1.cs b/src/clients/dotnet/Bitip.Client.Tests/Class1.cs
index 5cedbf4..2c0fa9a 100644
--- a/src/clients/dotnet/Bitip.Client.Tests/Class1.cs
+++ b/src/clients/dotnet/Bitip.Client.Tests/Class1.cs
@@ -1,4 +1,6 @@
-namespace Bitip.Client.Tests
+// Copyright (c) 2025 Tudor Stanciu
+
+namespace Bitip.Client.Tests
{
public class Class1
{
diff --git a/src/clients/dotnet/Bitip.Client/Bitip.Client.csproj b/src/clients/dotnet/Bitip.Client/Bitip.Client.csproj
index 0e0aa05..f39832f 100644
--- a/src/clients/dotnet/Bitip.Client/Bitip.Client.csproj
+++ b/src/clients/dotnet/Bitip.Client/Bitip.Client.csproj
@@ -5,4 +5,9 @@
enable
+
+
+
+
+
diff --git a/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs b/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs
index 0bff885..cc6766e 100644
--- a/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs
+++ b/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs
@@ -11,6 +11,14 @@ namespace Bitip.Client.Constants
{
public const string
Health = "health",
- Version = "version";
+ Version = "version",
+ Lookup = "lookup?ip={ip}",
+ DetailedLookup = "lookup/detailed?ip={ip}";
+ }
+
+ public struct ValueKeys
+ {
+ public const string
+ MissingValue = "Unknown";
}
}
diff --git a/src/clients/dotnet/Bitip.Client/Extensions/HttpClientExtensions.cs b/src/clients/dotnet/Bitip.Client/Extensions/HttpClientExtensions.cs
new file mode 100644
index 0000000..80f5f8a
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Extensions/HttpClientExtensions.cs
@@ -0,0 +1,19 @@
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Bitip.Client.Extensions
+{
+ internal static class HttpClientExtensions
+ {
+ ///
+ /// Performs GET request, ensures success, and deserializes response
+ ///
+ public static async Task GetAndReadJsonAsync(this HttpClient httpClient, string requestUri, CancellationToken cancellationToken = default)
+ {
+ var response = await httpClient.GetAsync(requestUri, cancellationToken);
+ var data = await response.EnsureSuccessAndReadJsonAsync(cancellationToken);
+ return data;
+ }
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Extensions/HttpMessageExtensions.cs b/src/clients/dotnet/Bitip.Client/Extensions/HttpMessageExtensions.cs
deleted file mode 100644
index 6da1bc0..0000000
--- a/src/clients/dotnet/Bitip.Client/Extensions/HttpMessageExtensions.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using Bitip.Client.Models;
-using System.Net.Http;
-using System.Net.Http.Json;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace Bitip.Client.Extensions
-{
- internal static class HttpMessageExtensions
- {
- public static async Task EnsureSuccessOperation(this HttpResponseMessage response, CancellationToken cancellationToken = default)
- {
- if (response.IsSuccessStatusCode)
- return response;
- var errorResponse = await response.Content.ReadFromJsonAsync(cancellationToken);
- if (errorResponse is null)
- throw new HttpRequestException("An error occurred while fetching Bitip information.");
- var message = $"{errorResponse.Error}: {errorResponse.Message}";
- if (!string.IsNullOrWhiteSpace(errorResponse.Ip))
- message += $" (IP: {errorResponse.Ip})";
- throw new HttpRequestException(message);
- }
- }
-}
diff --git a/src/clients/dotnet/Bitip.Client/Extensions/HttpResponseMessageExtensions.cs b/src/clients/dotnet/Bitip.Client/Extensions/HttpResponseMessageExtensions.cs
new file mode 100644
index 0000000..b7fb41c
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Extensions/HttpResponseMessageExtensions.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2025 Tudor Stanciu
+
+using Bitip.Client.Models;
+using System;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Bitip.Client.Extensions
+{
+ internal static class HttpResponseMessageExtensions
+ {
+ ///
+ /// Ensures the HTTP response indicates success; otherwise, throws an exception with error details.
+ ///
+ public static async Task EnsureSuccessOperation(this HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ if (response.IsSuccessStatusCode)
+ return response;
+ var errorResponse = await response.Content.ReadFromJsonAsync(cancellationToken);
+ if (errorResponse is null)
+ throw new HttpRequestException("An error occurred while fetching Bitip information.");
+ var message = $"{errorResponse.Error}: {errorResponse.Message}";
+ if (!string.IsNullOrWhiteSpace(errorResponse.Ip))
+ message += $" (IP: {errorResponse.Ip})";
+ throw new HttpRequestException(message);
+ }
+
+ ///
+ /// Reads JSON content and ensures it's not null
+ ///
+ public static async Task ReadFromJsonOrThrowAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ var data = await response.Content.ReadFromJsonAsync(cancellationToken);
+ if (data is null)
+ {
+ throw new InvalidOperationException($"Failed to deserialize response content to type {typeof(T).Name}. Content was null.");
+ }
+ return data;
+ }
+
+ ///
+ /// Ensures success status code and reads JSON content
+ ///
+ public static async Task EnsureSuccessAndReadJsonAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default)
+ {
+ await response.EnsureSuccessOperation(cancellationToken);
+ return await response.ReadFromJsonOrThrowAsync(cancellationToken);
+ }
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs b/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs
new file mode 100644
index 0000000..33f75d0
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2025 Tudor Stanciu
+
+using System;
+
+namespace Bitip.Client.Helpers
+{
+ internal static class IpValidator
+ {
+ public static void Validate(string ip)
+ {
+ if (string.IsNullOrWhiteSpace(ip))
+ throw new ArgumentException("Value cannot be null or whitespace.", nameof(ip));
+ // Basic validation for IPv4 and IPv6 formats
+ if (!System.Net.IPAddress.TryParse(ip, out _))
+ throw new ArgumentException("The provided IP address is not valid.", nameof(ip));
+ }
+
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Helpers/UriNormalizer.cs b/src/clients/dotnet/Bitip.Client/Helpers/UriNormalizer.cs
new file mode 100644
index 0000000..242b2d1
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Helpers/UriNormalizer.cs
@@ -0,0 +1,17 @@
+// Copyright (c) 2025 Tudor Stanciu
+
+using System;
+
+namespace Bitip.Client.Helpers
+{
+ internal static class UriNormalizer
+ {
+ public static Uri EnsureTrailingSlash(string url)
+ {
+ if (url is null) throw new ArgumentNullException(nameof(url));
+ var trimmed = url.Trim();
+ var address = trimmed.EndsWith("/") ? trimmed : $"{trimmed}/";
+ return new Uri(address);
+ }
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Models/DetailedIpLocation.cs b/src/clients/dotnet/Bitip.Client/Models/DetailedIpLocation.cs
new file mode 100644
index 0000000..e040859
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Models/DetailedIpLocation.cs
@@ -0,0 +1,124 @@
+// Copyright (c) 2025 Tudor Stanciu
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Bitip.Client.Models
+{
+ public record DetailedIpLocation
+ {
+ [JsonPropertyName("ip")]
+ public required string Ip { get; init; }
+
+ [JsonPropertyName("location")]
+ public required GeoIpLocation Location { get; init; }
+
+ [JsonPropertyName("asn")]
+ public required AsnInfo Asn { get; init; }
+ }
+
+ public record GeoIpLocation
+ {
+ [JsonPropertyName("country")]
+ public CountryInfo? Country { get; init; }
+
+ [JsonPropertyName("city")]
+ public CityInfo? City { get; init; }
+
+ [JsonPropertyName("subdivisions")]
+ public List? Subdivisions { get; init; }
+
+ [JsonPropertyName("location")]
+ public LocationInfo? Location { get; init; }
+
+ [JsonPropertyName("postal")]
+ public PostalInfo? Postal { get; init; }
+
+ [JsonPropertyName("continent")]
+ public ContinentInfo? Continent { get; init; }
+
+ [JsonPropertyName("registered_country")]
+ public CountryInfo? RegisteredCountry { get; init; }
+
+ [JsonPropertyName("traits")]
+ public TraitsInfo? Traits { get; init; }
+ }
+
+ public record CountryInfo
+ {
+ [JsonPropertyName("iso_code")]
+ public string? IsoCode { get; init; }
+
+ [JsonPropertyName("names")]
+ public Dictionary? Names { get; init; }
+
+ [JsonPropertyName("is_in_european_union")]
+ public bool? IsInEuropeanUnion { get; init; }
+ }
+
+ public record CityInfo
+ {
+ [JsonPropertyName("names")]
+ public Dictionary? Names { get; init; }
+ }
+
+ public record Subdivision
+ {
+ [JsonPropertyName("iso_code")]
+ public string? IsoCode { get; init; }
+
+ [JsonPropertyName("names")]
+ public Dictionary? Names { get; init; }
+ }
+
+ public record LocationInfo
+ {
+ [JsonPropertyName("latitude")]
+ public double? Latitude { get; init; }
+
+ [JsonPropertyName("longitude")]
+ public double? Longitude { get; init; }
+
+ [JsonPropertyName("time_zone")]
+ public string? TimeZone { get; init; }
+ }
+
+ public record PostalInfo
+ {
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+ }
+
+ public record ContinentInfo
+ {
+ [JsonPropertyName("code")]
+ public string? Code { get; init; }
+
+ [JsonPropertyName("names")]
+ public Dictionary? Names { get; init; }
+ }
+
+ public record TraitsInfo
+ {
+ [JsonPropertyName("is_anonymous_proxy")]
+ public bool? IsAnonymousProxy { get; init; }
+
+ [JsonPropertyName("is_satellite_provider")]
+ public bool? IsSatelliteProvider { get; init; }
+ }
+
+ public record AsnInfo
+ {
+ [JsonPropertyName("autonomousSystemNumber")]
+ public long? AutonomousSystemNumber { get; init; }
+
+ [JsonPropertyName("autonomousSystemOrganization")]
+ public string? AutonomousSystemOrganization { get; init; }
+
+ [JsonPropertyName("ipAddress")]
+ public string? IpAddress { get; init; }
+
+ [JsonPropertyName("network")]
+ public string? Network { get; init; }
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Models/IpLocation.cs b/src/clients/dotnet/Bitip.Client/Models/IpLocation.cs
new file mode 100644
index 0000000..1e84958
--- /dev/null
+++ b/src/clients/dotnet/Bitip.Client/Models/IpLocation.cs
@@ -0,0 +1,54 @@
+// Copyright (c) 2025 Tudor Stanciu
+
+using System.Text.Json.Serialization;
+
+namespace Bitip.Client.Models
+{
+ public record IpLocation
+ {
+ [JsonPropertyName("ip")]
+ public required string Ip { get; init; }
+
+ [JsonPropertyName("country")]
+ public required string Country { get; init; }
+
+ [JsonPropertyName("country_code")]
+ public required string CountryCode { get; init; }
+
+ [JsonPropertyName("is_in_european_union")]
+ public required bool IsInEuropeanUnion { get; init; }
+
+ [JsonPropertyName("region")]
+ public required string Region { get; init; }
+
+ [JsonPropertyName("region_code")]
+ public string? RegionCode { get; init; }
+
+ [JsonPropertyName("city")]
+ public required string City { get; init; }
+
+ [JsonPropertyName("latitude")]
+ public double? Latitude { get; init; }
+
+ [JsonPropertyName("longitude")]
+ public double? Longitude { get; init; }
+
+ [JsonPropertyName("timezone")]
+ public string? Timezone { get; init; }
+
+ [JsonPropertyName("postal_code")]
+ public string? PostalCode { get; init; }
+
+ [JsonPropertyName("continent_code")]
+ public string? ContinentCode { get; init; }
+
+ [JsonPropertyName("continent_name")]
+ public string? ContinentName { get; init; }
+
+ [JsonPropertyName("organization")]
+ public string? Organization { get; init; }
+
+ [JsonPropertyName("asn")]
+ public long? Asn { get; init; }
+ }
+}
diff --git a/src/clients/dotnet/Bitip.Client/Models/SystemDtos.cs b/src/clients/dotnet/Bitip.Client/Models/System.cs
similarity index 85%
rename from src/clients/dotnet/Bitip.Client/Models/SystemDtos.cs
rename to src/clients/dotnet/Bitip.Client/Models/System.cs
index a5d0600..22285d9 100644
--- a/src/clients/dotnet/Bitip.Client/Models/SystemDtos.cs
+++ b/src/clients/dotnet/Bitip.Client/Models/System.cs
@@ -4,7 +4,7 @@ using System;
namespace Bitip.Client.Models
{
- internal record HealthResponse
+ public record HealthInfo
{
public required string Status { get; init; }
public required string Service { get; init; }
@@ -12,14 +12,14 @@ namespace Bitip.Client.Models
public string? Error { get; init; }
}
- internal record VersionResponse
+ public record VersionInfo
{
public required string Version { get; init; }
public required string CommitHash { get; init; }
public DateTime BuildDate { get; init; }
}
- internal record ErrorResponse
+ public record ErrorResponse
{
public required string Error { get; init; }
public required string Message { get; init; }
diff --git a/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs b/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs
index edcddcd..cf35795 100644
--- a/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs
+++ b/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs
@@ -2,12 +2,11 @@
using Bitip.Client.Constants;
using Bitip.Client.Extensions;
+using Bitip.Client.Helpers;
using Bitip.Client.Models;
using Microsoft.Extensions.Options;
-using System;
using System.Net.Http;
using System.Net.Http.Headers;
-using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -19,37 +18,39 @@ namespace Bitip.Client.Services
public BitipClient(HttpClient httpClient, IOptions options)
{
- httpClient.BaseAddress = EnsureTrailingSlash(options.Value.BaseUrl);
+ httpClient.BaseAddress = UriNormalizer.EnsureTrailingSlash(options.Value.BaseUrl);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpClient.DefaultRequestHeaders.Add(ApiKeys.HttpHeader, options.Value.ApiKey);
_httpClient = httpClient;
}
- public async Task GetHealth(CancellationToken cancellationToken = default)
+ public Task GetHealth(CancellationToken cancellationToken = default)
{
- var response = await _httpClient.GetAsync(ApiRoutes.Health, cancellationToken);
- await response.EnsureSuccessOperation(cancellationToken);
- var data = await response.Content.ReadFromJsonAsync(cancellationToken);
- if (data is null)
- throw new InvalidOperationException("The response content is null.");
- return data;
+ var task = _httpClient.GetAndReadJsonAsync(ApiRoutes.Health, cancellationToken);
+ return task;
}
- public async Task GetVersion(CancellationToken cancellationToken = default)
+ public Task GetVersion(CancellationToken cancellationToken = default)
{
- var response = await _httpClient.GetAsync(ApiRoutes.Version, cancellationToken);
- await response.EnsureSuccessOperation(cancellationToken);
- var data = await response.Content.ReadFromJsonAsync(cancellationToken);
- if (data is null)
- throw new InvalidOperationException("The response content is null.");
- return data;
+ var task = _httpClient.GetAndReadJsonAsync(ApiRoutes.Version, cancellationToken);
+ return task;
}
- private Uri EnsureTrailingSlash(string url)
+ public Task GetIpLocation(string ip, CancellationToken cancellationToken = default)
{
- var address = url.EndsWith("/") ? url : $"{url}/";
- return new Uri(address);
+ IpValidator.Validate(ip);
+ var route = ApiRoutes.Lookup.Replace("{ip}", ip);
+ var task = _httpClient.GetAndReadJsonAsync(route, cancellationToken);
+ return task;
+ }
+
+ public async Task GetDetailedIpLocation(string ip, CancellationToken cancellationToken = default)
+ {
+ IpValidator.Validate(ip);
+ var route = ApiRoutes.DetailedLookup.Replace("{ip}", ip);
+ var task = await _httpClient.GetAndReadJsonAsync(route, cancellationToken);
+ return task;
}
}
}
diff --git a/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs b/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs
index 4c16d2e..76e73cb 100644
--- a/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs
+++ b/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs
@@ -1,11 +1,5 @@
// Copyright (c) 2025 Tudor Stanciu
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
namespace Bitip.Client.Services
{
public interface IBitipClient