mirror of
https://dev.azure.com/tstanciu94/PhantomMind/_git/Bitip
synced 2025-10-13 01:52:19 +03:00
feat: refactor Bitip.Client to enhance IP location services and improve error handling
This commit is contained in:
parent
27b3073907
commit
f227523fce
@ -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,
|
||||
|
@ -1,4 +1,6 @@
|
||||
namespace Bitip.Client.Tests
|
||||
// Copyright (c) 2025 Tudor Stanciu
|
||||
|
||||
namespace Bitip.Client.Tests
|
||||
{
|
||||
public class Class1
|
||||
{
|
||||
|
@ -5,4 +5,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Bitip.Client.Extensions
|
||||
{
|
||||
internal static class HttpClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs GET request, ensures success, and deserializes response
|
||||
/// </summary>
|
||||
public static async Task<T> GetAndReadJsonAsync<T>(this HttpClient httpClient, string requestUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await httpClient.GetAsync(requestUri, cancellationToken);
|
||||
var data = await response.EnsureSuccessAndReadJsonAsync<T>(cancellationToken);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<HttpResponseMessage> EnsureSuccessOperation(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return response;
|
||||
var errorResponse = await response.Content.ReadFromJsonAsync<ErrorResponse>(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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures the HTTP response indicates success; otherwise, throws an exception with error details.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseMessage> EnsureSuccessOperation(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return response;
|
||||
var errorResponse = await response.Content.ReadFromJsonAsync<ErrorResponse>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads JSON content and ensures it's not null
|
||||
/// </summary>
|
||||
public static async Task<T> ReadFromJsonOrThrowAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<T>(cancellationToken);
|
||||
if (data is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize response content to type {typeof(T).Name}. Content was null.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures success status code and reads JSON content
|
||||
/// </summary>
|
||||
public static async Task<T> EnsureSuccessAndReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await response.EnsureSuccessOperation(cancellationToken);
|
||||
return await response.ReadFromJsonOrThrowAsync<T>(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
19
src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs
Normal file
19
src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs
Normal file
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
17
src/clients/dotnet/Bitip.Client/Helpers/UriNormalizer.cs
Normal file
17
src/clients/dotnet/Bitip.Client/Helpers/UriNormalizer.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
124
src/clients/dotnet/Bitip.Client/Models/DetailedIpLocation.cs
Normal file
124
src/clients/dotnet/Bitip.Client/Models/DetailedIpLocation.cs
Normal file
@ -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<Subdivision>? 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<string, string>? Names { get; init; }
|
||||
|
||||
[JsonPropertyName("is_in_european_union")]
|
||||
public bool? IsInEuropeanUnion { get; init; }
|
||||
}
|
||||
|
||||
public record CityInfo
|
||||
{
|
||||
[JsonPropertyName("names")]
|
||||
public Dictionary<string, string>? Names { get; init; }
|
||||
}
|
||||
|
||||
public record Subdivision
|
||||
{
|
||||
[JsonPropertyName("iso_code")]
|
||||
public string? IsoCode { get; init; }
|
||||
|
||||
[JsonPropertyName("names")]
|
||||
public Dictionary<string, string>? 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<string, string>? 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; }
|
||||
}
|
||||
}
|
54
src/clients/dotnet/Bitip.Client/Models/IpLocation.cs
Normal file
54
src/clients/dotnet/Bitip.Client/Models/IpLocation.cs
Normal file
@ -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; }
|
||||
}
|
||||
}
|
@ -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; }
|
@ -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<BitipOptions> 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<HealthResponse> GetHealth(CancellationToken cancellationToken = default)
|
||||
public Task<HealthInfo> GetHealth(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(ApiRoutes.Health, cancellationToken);
|
||||
await response.EnsureSuccessOperation(cancellationToken);
|
||||
var data = await response.Content.ReadFromJsonAsync<HealthResponse>(cancellationToken);
|
||||
if (data is null)
|
||||
throw new InvalidOperationException("The response content is null.");
|
||||
return data;
|
||||
var task = _httpClient.GetAndReadJsonAsync<HealthInfo>(ApiRoutes.Health, cancellationToken);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<VersionResponse> GetVersion(CancellationToken cancellationToken = default)
|
||||
public Task<VersionInfo> GetVersion(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(ApiRoutes.Version, cancellationToken);
|
||||
await response.EnsureSuccessOperation(cancellationToken);
|
||||
var data = await response.Content.ReadFromJsonAsync<VersionResponse>(cancellationToken);
|
||||
if (data is null)
|
||||
throw new InvalidOperationException("The response content is null.");
|
||||
return data;
|
||||
var task = _httpClient.GetAndReadJsonAsync<VersionInfo>(ApiRoutes.Version, cancellationToken);
|
||||
return task;
|
||||
}
|
||||
|
||||
private Uri EnsureTrailingSlash(string url)
|
||||
public Task<IpLocation> 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<IpLocation>(route, cancellationToken);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<DetailedIpLocation> GetDetailedIpLocation(string ip, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IpValidator.Validate(ip);
|
||||
var route = ApiRoutes.DetailedLookup.Replace("{ip}", ip);
|
||||
var task = await _httpClient.GetAndReadJsonAsync<DetailedIpLocation>(route, cancellationToken);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user