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