Compare commits

...

2 Commits

15 changed files with 338 additions and 56 deletions

View File

@ -75,7 +75,7 @@ class GeoIPService {
const result: SimplifiedGeoIPResponse = { const result: SimplifiedGeoIPResponse = {
ip, ip,
country: cityResponse.country?.names?.en || 'Unknown', 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, is_in_european_union: cityResponse.country?.isInEuropeanUnion || false,
region: cityResponse.subdivisions?.[0]?.names?.en || 'Unknown', region: cityResponse.subdivisions?.[0]?.names?.en || 'Unknown',
region_code: cityResponse.subdivisions?.[0]?.isoCode || null, region_code: cityResponse.subdivisions?.[0]?.isoCode || null,
@ -111,9 +111,12 @@ class GeoIPService {
try { try {
const response: City = this.cityReader!.city(ip); const response: City = this.cityReader!.city(ip);
const asnResponse: Asn = this.asnReader!.asn(ip);
const result: DetailedGeoIPResponse = { const result: DetailedGeoIPResponse = {
ip, ip,
location: response as GeoIPLocation, location: response as GeoIPLocation,
asn: asnResponse,
}; };
this.cache.set(cacheKey, result); this.cache.set(cacheKey, result);

View File

@ -37,6 +37,13 @@ export interface GeoIPLocation {
}; };
} }
export interface AsnInfo {
autonomousSystemNumber?: number;
autonomousSystemOrganization?: string;
ipAddress?: string;
network?: string;
}
export interface SimplifiedGeoIPResponse { export interface SimplifiedGeoIPResponse {
ip: string; ip: string;
country: string; country: string;
@ -58,6 +65,7 @@ export interface SimplifiedGeoIPResponse {
export interface DetailedGeoIPResponse { export interface DetailedGeoIPResponse {
ip: string; ip: string;
location: GeoIPLocation; location: GeoIPLocation;
asn: AsnInfo;
} }
export interface BatchGeoIPRequest { export interface BatchGeoIPRequest {

View File

@ -1,4 +1,6 @@
namespace Bitip.Client.Tests // Copyright (c) 2025 Tudor Stanciu
namespace Bitip.Client.Tests
{ {
public class Class1 public class Class1
{ {

View File

@ -5,4 +5,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
</ItemGroup>
</Project> </Project>

View File

@ -11,6 +11,14 @@ namespace Bitip.Client.Constants
{ {
public const string public const string
Health = "health", Health = "health",
Version = "version"; Version = "version",
Lookup = "lookup?ip={ip}",
DetailedLookup = "lookup/detailed?ip={ip}";
}
public struct ValueKeys
{
public const string
MissingValue = "Unknown";
} }
} }

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View 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));
}
}
}

View 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);
}
}
}

View 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; }
}
}

View 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; }
}
}

View File

@ -4,7 +4,7 @@ using System;
namespace Bitip.Client.Models namespace Bitip.Client.Models
{ {
internal record HealthResponse public record HealthInfo
{ {
public required string Status { get; init; } public required string Status { get; init; }
public required string Service { get; init; } public required string Service { get; init; }
@ -12,14 +12,14 @@ namespace Bitip.Client.Models
public string? Error { get; init; } public string? Error { get; init; }
} }
internal record VersionResponse public record VersionInfo
{ {
public required string Version { get; init; } public required string Version { get; init; }
public required string CommitHash { get; init; } public required string CommitHash { get; init; }
public DateTime BuildDate { get; init; } public DateTime BuildDate { get; init; }
} }
internal record ErrorResponse public record ErrorResponse
{ {
public required string Error { get; init; } public required string Error { get; init; }
public required string Message { get; init; } public required string Message { get; init; }

View File

@ -2,12 +2,11 @@
using Bitip.Client.Constants; using Bitip.Client.Constants;
using Bitip.Client.Extensions; using Bitip.Client.Extensions;
using Bitip.Client.Helpers;
using Bitip.Client.Models; using Bitip.Client.Models;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -19,37 +18,39 @@ namespace Bitip.Client.Services
public BitipClient(HttpClient httpClient, IOptions<BitipOptions> options) 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.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpClient.DefaultRequestHeaders.Add(ApiKeys.HttpHeader, options.Value.ApiKey); httpClient.DefaultRequestHeaders.Add(ApiKeys.HttpHeader, options.Value.ApiKey);
_httpClient = httpClient; _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); var task = _httpClient.GetAndReadJsonAsync<HealthInfo>(ApiRoutes.Health, cancellationToken);
await response.EnsureSuccessOperation(cancellationToken); return task;
var data = await response.Content.ReadFromJsonAsync<HealthResponse>(cancellationToken);
if (data is null)
throw new InvalidOperationException("The response content is null.");
return data;
} }
public async Task<VersionResponse> GetVersion(CancellationToken cancellationToken = default) public Task<VersionInfo> GetVersion(CancellationToken cancellationToken = default)
{ {
var response = await _httpClient.GetAsync(ApiRoutes.Version, cancellationToken); var task = _httpClient.GetAndReadJsonAsync<VersionInfo>(ApiRoutes.Version, cancellationToken);
await response.EnsureSuccessOperation(cancellationToken); return task;
var data = await response.Content.ReadFromJsonAsync<VersionResponse>(cancellationToken);
if (data is null)
throw new InvalidOperationException("The response content is null.");
return data;
} }
private Uri EnsureTrailingSlash(string url) public Task<IpLocation> GetIpLocation(string ip, CancellationToken cancellationToken = default)
{ {
var address = url.EndsWith("/") ? url : $"{url}/"; IpValidator.Validate(ip);
return new Uri(address); 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;
} }
} }
} }

View File

@ -1,11 +1,5 @@
// Copyright (c) 2025 Tudor Stanciu // 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 namespace Bitip.Client.Services
{ {
public interface IBitipClient public interface IBitipClient