diff --git a/content/Overview.json b/content/Overview.json index daa754b..35bd285 100644 --- a/content/Overview.json +++ b/content/Overview.json @@ -1,7 +1,7 @@ { "title": "Bitip - Professional GeoIP Lookup Service", "subtitle": "Modern GeoIP lookup service with REST API and interactive web interface", - "lastUpdated": "2025-10-09T12:00:00Z", + "lastUpdated": "2025-10-09T16:00:00Z", "sections": [ { "title": "Overview", @@ -10,7 +10,7 @@ { "title": "Client Libraries", "items": [ - "**.NET / C# - Bitip.Client** - Official NuGet package with strongly-typed models, async/await support, and dependency injection integration. Includes comprehensive IntelliSense documentation and 29 unit/integration tests for reliability.", + "**.NET / C# - Bitip.Client** - Official NuGet package with strongly-typed models, async/await support, and dependency injection integration. Includes comprehensive IntelliSense documentation and 40 unit/integration tests for reliability.", "**Node.js / JavaScript** - Client library in development, coming soon", "**REST API** - Direct HTTP access available for any programming language with full OpenAPI/Swagger documentation" ] @@ -85,14 +85,25 @@ "Returns detailed location hierarchy with ISO codes and network information" ] }, + { + "subtitle": "Detailed IP Lookup", + "items": [ + "Endpoint: `GET /api/lookup/detailed?ip=:ip`", + "Returns structured geolocation data with separate location and ASN sections", + "Response format: `{ip, location: {...}, asn: {...}}`", + "Location section includes all geographic data (country, region, city, coordinates, timezone, postal code)", + "ASN section includes organization and autonomous system number", + "Useful for applications requiring clear separation of geographic and network information" + ] + }, { "subtitle": "Batch IP Lookup", "items": [ "Endpoint: `POST /api/lookup/batch`", "Process up to 100 IP addresses in a single request", - "Returns array of results matching input order", + "Returns `{succeeded: IpLocation[], failed: Array<{ip, error}>}` with separated success and failure arrays", "Efficient bulk processing for large-scale operations", - "Individual error handling for each IP address" + "Individual error handling for each IP address with clear error messages" ] }, { diff --git a/content/ReleaseNotes.json b/content/ReleaseNotes.json index 4f8b2d9..aef82b2 100644 --- a/content/ReleaseNotes.json +++ b/content/ReleaseNotes.json @@ -1,5 +1,83 @@ { "releases": [ + { + "version": "1.1.2", + "date": "2025-10-09T16:00:00Z", + "title": "Batch API Refactoring & .NET Client Completion", + "summary": "Refactored batch lookup API to use a cleaner succeeded/failed response structure instead of union types. Completed .NET client library (Bitip.Client v1.0.0) with full batch lookup support, comprehensive testing, and updated documentation.", + "sections": [ + { + "title": "Overview", + "content": "Version 1.1.2 introduces a breaking change to the batch lookup API for improved clarity and type safety. The response structure now separates successful and failed lookups into distinct arrays, making it easier to process results and handle errors. The .NET client library has been completed with full batch lookup support and is ready for v1.0.0 release." + }, + { + "title": "⚠️ Breaking Changes - Batch API", + "items": [ + "**New Response Structure** - `/api/lookup/batch` now returns `{succeeded: IpLocation[], failed: Array<{ip, error}>}` instead of `{results: Array}`", + "**Separated Collections** - Successful lookups are in the `succeeded` array, failures in the `failed` array", + "**Type Safety** - Eliminates union types for cleaner, more predictable response handling", + "**Consistent Error Format** - All errors (validation, API failures) use the same `{ip, error}` structure", + "**Better Iteration** - Process successful and failed lookups independently without type checks" + ] + }, + { + "title": "Backend Improvements", + "items": [ + "**TypeScript Type Refactoring** - Updated `BatchGeoIPResponse` interface with `succeeded` and `failed` properties", + "**Service Layer Updates** - Modified `lookupBatch()` in `geoip.ts` to return separated results", + "**Route Handler Changes** - Updated batch endpoint to combine API failures and validation errors in `failed` array", + "**Private IP Handling** - Private/reserved IPs rejected with clear error messages in `failed` array", + "**Promise.allSettled** - Used for parallel lookups with proper error handling" + ] + }, + { + "title": ".NET Client Completion (v1.0.0)", + "items": [ + "**Batch Lookup Implementation** - Added `GetBatchIpLocation()` method to `IBitipClient` interface", + "**Clean Model Design** - `BatchIpLookupResponse` with `IEnumerable Succeeded` and `IEnumerable Failed`", + "**No Model Pollution** - Kept `IpLocation` strongly-typed without nullable properties for batch use", + "**BatchIpLookupError Record** - Dedicated record type with `string Ip` and `string Error` properties", + "**Client-Side Validation** - Added `IpValidator.IsValid()` helper to validate IPs before API call", + "**Error Aggregation** - Combines client-side validation errors with API errors in unified response" + ] + }, + { + "title": "Testing & Quality", + "items": [ + "**40 Tests Passing** - Complete test suite with unit and integration tests", + "**GetBatchIpLocationTests** - 10 comprehensive tests covering all batch scenarios", + "**Mixed Success/Failure Handling** - Tests verify both succeeded and failed collections populate correctly", + "**Invalid IP Handling** - Tests confirm client-side validation catches malformed IPs", + "**Cancellation Token Support** - Tests verify proper async cancellation behavior", + "**Integration Tests** - Real API tests confirm end-to-end batch functionality" + ] + }, + { + "title": "Documentation Updates", + "items": [ + "**Bitip.Client README** - Updated with new batch API structure and code examples", + "**Separate Loop Examples** - Shows iteration over `Succeeded` and `Failed` collections independently", + "**Clear Error Messages** - Documents that invalid IPs are validated client-side with descriptive errors", + "**Migration Guide** - Inline comments explain the API structure change for existing users", + "**Type Safety Benefits** - Documentation highlights improved type safety with separated arrays" + ] + }, + { + "title": "Developer Experience", + "items": [ + "**Clearer API Contract** - No more union types or type guards needed when processing results", + "**Predictable Responses** - Always get both `succeeded` and `failed` arrays (may be empty)", + "**Better IntelliSense** - IDEs provide better autocomplete with distinct types", + "**Easier Error Handling** - Loop through `failed` array without type checking each item", + "**Maintainability** - Code is more readable and easier to test with explicit separation" + ] + }, + { + "title": "Migration Notes", + "content": "If you are using the batch lookup API, update your code to access `response.succeeded` instead of filtering `response.results`. Failed lookups are now in `response.failed` array. For .NET developers using Bitip.Client, update to v1.0.0 and access `Succeeded` and `Failed` properties on the `BatchIpLookupResponse` object." + } + ] + }, { "version": "1.1.1", "date": "2025-10-09T12:00:00Z", diff --git a/package.json b/package.json index e3ed046..e5bdcd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bitip", - "version": "1.1.1", + "version": "1.1.2", "description": "Bitip - GeoIP Lookup Service with REST API and Web Interface", "type": "module", "main": "dist/backend/index.js", diff --git a/src/backend/routes/api.ts b/src/backend/routes/api.ts index 973cf59..ae30279 100644 --- a/src/backend/routes/api.ts +++ b/src/backend/routes/api.ts @@ -220,22 +220,23 @@ router.post('/lookup/batch', async (req: Request, res: Response) => { const validIPs = ips.filter(ip => !geoIPService.isPrivateIP(ip)); const privateIPs = ips.filter(ip => geoIPService.isPrivateIP(ip)); - const results = await geoIPService.lookupBatch(validIPs); + const batchResults = await geoIPService.lookupBatch(validIPs); - // Add errors for private IPs + // Add private IP errors to failed results const privateIPErrors = privateIPs.map(ip => ({ ip, error: 'Private IP addresses are not supported', })); const response: BatchGeoIPResponse = { - results: [...results, ...privateIPErrors], + succeeded: batchResults.succeeded, + failed: [...batchResults.failed, ...privateIPErrors], }; logger.info('Batch IP lookup completed', { totalIPs: ips.length, - validIPs: validIPs.length, - privateIPs: privateIPs.length, + succeeded: batchResults.succeeded.length, + failed: batchResults.failed.length + privateIPErrors.length, }); res.json(response); @@ -256,7 +257,7 @@ router.get('/version', (_req: Request, res: Response): void => { try { res.json({ version: process.env.APP_VERSION || '1.0.0', - buildDate: process.env.CREATED_AT || 'unknown', + buildDate: process.env.CREATED_AT || new Date(0).toISOString(), commitHash: process.env.GIT_REVISION || 'unknown', service: 'Bitip GeoIP Service', }); diff --git a/src/backend/services/geoip.ts b/src/backend/services/geoip.ts index 73cbf43..9ee413d 100644 --- a/src/backend/services/geoip.ts +++ b/src/backend/services/geoip.ts @@ -6,6 +6,8 @@ import { GeoIPLocation, SimplifiedGeoIPResponse, DetailedGeoIPResponse, + BatchFailedLookup, + BatchGeoIPResponse, } from '../types/index'; import config from './config'; import logger from './logger'; @@ -128,23 +130,26 @@ class GeoIPService { } } - async lookupBatch( - ips: string[] - ): Promise> { + async lookupBatch(ips: string[]): Promise { const results = await Promise.allSettled( ips.map(async ip => this.lookupSimple(ip)) ); - return results.map((result, index) => { + const succeeded: SimplifiedGeoIPResponse[] = []; + const failed: Array = []; + + results.forEach((result, index) => { if (result.status === 'fulfilled') { - return result.value; + succeeded.push(result.value); } else { - return { + failed.push({ ip: ips[index] || 'unknown', error: result.reason?.message || 'Lookup failed', - }; + }); } }); + + return { succeeded, failed }; } isValidIP(ip: string): boolean { diff --git a/src/backend/types/index.ts b/src/backend/types/index.ts index 42012df..b625ab6 100644 --- a/src/backend/types/index.ts +++ b/src/backend/types/index.ts @@ -72,8 +72,14 @@ export interface BatchGeoIPRequest { ips: string[]; } +export interface BatchFailedLookup { + ip: string; + error: string; +} + export interface BatchGeoIPResponse { - results: Array; + succeeded: SimplifiedGeoIPResponse[]; + failed: Array; } export interface ErrorResponse { diff --git a/src/clients/dotnet/Bitip.Client.Tests/GetBatchIpLocationTests.cs b/src/clients/dotnet/Bitip.Client.Tests/GetBatchIpLocationTests.cs new file mode 100644 index 0000000..5072cba --- /dev/null +++ b/src/clients/dotnet/Bitip.Client.Tests/GetBatchIpLocationTests.cs @@ -0,0 +1,316 @@ +// Copyright (c) 2025 Tudor Stanciu + +using Bitip.Client.Models; +using Bitip.Client.Tests.Helpers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Bitip.Client.Tests +{ + /// + /// Unit tests for IBitipClient.GetBatchIpLocation method (batch lookup). + /// + public class GetBatchIpLocationTests + { + [Fact] + public async Task GetBatchIpLocation_WithValidIps_ReturnsSucceeded() + { + // Arrange + var ips = new List { "8.8.8.8", "1.1.1.1" }; + var expectedResponse = new BatchIpLookupResponse + { + Succeeded = new List + { + new IpLocation + { + Ip = "8.8.8.8", + Country = "United States", + CountryCode = "US", + IsInEuropeanUnion = false, + Region = "California", + City = "Mountain View", + Latitude = 37.4056, + Longitude = -122.0775, + Timezone = "America/Los_Angeles" + }, + new IpLocation + { + Ip = "1.1.1.1", + Country = "Australia", + CountryCode = "AU", + IsInEuropeanUnion = false, + Region = "Queensland", + City = "Brisbane", + Latitude = -27.4679, + Longitude = 153.0281, + Timezone = "Australia/Brisbane" + } + }, + Failed = new List() + }; + + var client = BitipClientTestFixture.CreateMockedClientWithResponse(expectedResponse, "lookup/batch", HttpMethod.Post); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + var succeededList = result.Succeeded.ToList(); + var failedList = result.Failed.ToList(); + + Assert.Equal(2, succeededList.Count); + Assert.Empty(failedList); + + Assert.Equal("8.8.8.8", succeededList[0].Ip); + Assert.Equal("United States", succeededList[0].Country); + Assert.Equal("1.1.1.1", succeededList[1].Ip); + Assert.Equal("Australia", succeededList[1].Country); + } + + [Fact] + public async Task GetBatchIpLocation_WithMixedSuccessAndErrors_ReturnsBoth() + { + // Arrange + var ips = new List { "8.8.8.8", "192.168.1.1" }; + var expectedResponse = new BatchIpLookupResponse + { + Succeeded = new List + { + new IpLocation + { + Ip = "8.8.8.8", + Country = "United States", + CountryCode = "US", + IsInEuropeanUnion = false, + Region = "California", + City = "Mountain View" + } + }, + Failed = new List + { + new BatchIpLookupError + { + Ip = "192.168.1.1", + Error = "Private IP addresses are not supported" + } + } + }; + + var client = BitipClientTestFixture.CreateMockedClientWithResponse(expectedResponse, "lookup/batch", HttpMethod.Post); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + var succeededList = result.Succeeded.ToList(); + var failedList = result.Failed.ToList(); + + Assert.Single(succeededList); + Assert.Single(failedList); + + Assert.Equal("United States", succeededList[0].Country); + Assert.Contains("Private IP", failedList[0].Error); + } + + [Fact] + public async Task GetBatchIpLocation_WithNullList_ThrowsArgumentNullException() + { + // Arrange + var client = BitipClientTestFixture.CreateMockedClient(mock => { }); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await client.GetBatchIpLocation(null!)); + } + + [Fact] + public async Task GetBatchIpLocation_WithEmptyList_ThrowsArgumentException() + { + // Arrange + var client = BitipClientTestFixture.CreateMockedClient(mock => { }); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await client.GetBatchIpLocation(new List())); + } + + [Fact] + public async Task GetBatchIpLocation_WithInvalidIp_ReturnsInFailed() + { + // Arrange - Mix of valid and invalid IPs + var ips = new List { "8.8.8.8", "invalid-ip", "1.1.1.1" }; + + // Mock response for the valid IPs only (invalid ones handled client-side) + var expectedResponse = new BatchIpLookupResponse + { + Succeeded = new List + { + new IpLocation + { + Ip = "8.8.8.8", + Country = "United States", + CountryCode = "US", + IsInEuropeanUnion = false, + Region = "California", + City = "Mountain View" + }, + new IpLocation + { + Ip = "1.1.1.1", + Country = "Australia", + CountryCode = "AU", + IsInEuropeanUnion = false, + Region = "Queensland", + City = "Brisbane" + } + }, + Failed = new List() + }; + + var client = BitipClientTestFixture.CreateMockedClientWithResponse(expectedResponse, "lookup/batch", HttpMethod.Post); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + var succeededList = result.Succeeded.ToList(); + var failedList = result.Failed.ToList(); + + Assert.Equal(2, succeededList.Count); // Valid IPs + Assert.Single(failedList); // Invalid IP + + // Check valid IPs succeeded + Assert.Equal("8.8.8.8", succeededList[0].Ip); + Assert.Equal("1.1.1.1", succeededList[1].Ip); + + // Check invalid IP has error + Assert.Equal("invalid-ip", failedList[0].Ip); + Assert.Contains("Invalid IP", failedList[0].Error); + } + + [Fact] + public async Task GetBatchIpLocation_WithCancellation_ThrowsTaskCanceledException() + { + // Arrange + var ips = new List { "8.8.8.8", "1.1.1.1" }; + var client = BitipClientTestFixture.CreateCancelledClient(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await client.GetBatchIpLocation(ips, cts.Token)); + } + + [Fact] + public async Task GetBatchIpLocation_WithServerError_ThrowsHttpRequestException() + { + // Arrange + var ips = new List { "8.8.8.8" }; + var client = BitipClientTestFixture.CreateClientWithError( + HttpStatusCode.InternalServerError, + "Internal Server Error", + "Failed to process batch lookup"); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await client.GetBatchIpLocation(ips)); + } + + [Fact] + public async Task GetBatchIpLocation_WithSingleIp_ReturnsResult() + { + // Arrange + var ips = new List { "8.8.8.8" }; + var expectedResponse = new BatchIpLookupResponse + { + Succeeded = new List + { + new IpLocation + { + Ip = "8.8.8.8", + Country = "United States", + CountryCode = "US", + IsInEuropeanUnion = false, + Region = "California", + City = "Mountain View" + } + }, + Failed = new List() + }; + + var client = BitipClientTestFixture.CreateMockedClientWithResponse(expectedResponse, "lookup/batch", HttpMethod.Post); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + var succeededList = result.Succeeded.ToList(); + Assert.Single(succeededList); + Assert.Empty(result.Failed); + Assert.Equal("8.8.8.8", succeededList[0].Ip); + } + + [Fact] + public async Task GetBatchIpLocation_WithMultipleIps_ReturnsAllResults() + { + // Arrange + var ips = new List { "8.8.8.8", "1.1.1.1", "9.9.9.9" }; + var expectedResponse = new BatchIpLookupResponse + { + Succeeded = new List + { + new IpLocation { Ip = "8.8.8.8", Country = "United States", CountryCode = "US", IsInEuropeanUnion = false, Region = "California", City = "Mountain View" }, + new IpLocation { Ip = "1.1.1.1", Country = "Australia", CountryCode = "AU", IsInEuropeanUnion = false, Region = "Queensland", City = "Brisbane" }, + new IpLocation { Ip = "9.9.9.9", Country = "United States", CountryCode = "US", IsInEuropeanUnion = false, Region = "California", City = "Oakland" } + }, + Failed = new List() + }; + + var client = BitipClientTestFixture.CreateMockedClientWithResponse(expectedResponse, "lookup/batch", HttpMethod.Post); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + var succeededList = result.Succeeded.ToList(); + Assert.Equal(3, succeededList.Count); + Assert.Empty(result.Failed); + Assert.All(succeededList, r => Assert.NotNull(r.Ip)); + } + + [Fact] + public async Task GetBatchIpLocation_WithOnlyInvalidIps_ReturnsAllInFailed() + { + // Arrange - Only invalid IPs (no API call should be made) + var ips = new List { "invalid-ip", "not-an-ip", "xyz" }; + var client = BitipClientTestFixture.CreateMockedClient(mock => { }); + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + Assert.Empty(result.Succeeded); + + var failedList = result.Failed.ToList(); + Assert.Equal(3, failedList.Count); + Assert.All(failedList, r => + { + Assert.NotNull(r.Error); + Assert.Contains("Invalid IP", r.Error); + }); + } + } +} diff --git a/src/clients/dotnet/Bitip.Client.Tests/Helpers/BitipClientTestFixture.cs b/src/clients/dotnet/Bitip.Client.Tests/Helpers/BitipClientTestFixture.cs index 071a31e..d885405 100644 --- a/src/clients/dotnet/Bitip.Client.Tests/Helpers/BitipClientTestFixture.cs +++ b/src/clients/dotnet/Bitip.Client.Tests/Helpers/BitipClientTestFixture.cs @@ -44,7 +44,7 @@ namespace Bitip.Client.Tests.Helpers /// /// Creates a mocked client that returns a successful response with the provided data. /// - public static IBitipClient CreateMockedClientWithResponse(T responseData, string urlContains = "") + public static IBitipClient CreateMockedClientWithResponse(T responseData, string urlContains = "", HttpMethod? method = null) { return CreateMockedClient(mockHandler => { @@ -52,11 +52,11 @@ namespace Bitip.Client.Tests.Helpers .Protected() .Setup>( "SendAsync", - string.IsNullOrEmpty(urlContains) + string.IsNullOrEmpty(urlContains) && method == null ? ItExpr.IsAny() : ItExpr.Is(req => - req.Method == HttpMethod.Get && - req.RequestUri!.ToString().Contains(urlContains)), + (method == null || req.Method == method) && + (string.IsNullOrEmpty(urlContains) || req.RequestUri!.ToString().Contains(urlContains))), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage { diff --git a/src/clients/dotnet/Bitip.Client.Tests/Integration/RealApiIntegrationTests.cs b/src/clients/dotnet/Bitip.Client.Tests/Integration/RealApiIntegrationTests.cs index 4f6cefd..b1e6c3a 100644 --- a/src/clients/dotnet/Bitip.Client.Tests/Integration/RealApiIntegrationTests.cs +++ b/src/clients/dotnet/Bitip.Client.Tests/Integration/RealApiIntegrationTests.cs @@ -4,6 +4,8 @@ using Bitip.Client.Services; using Bitip.Client.Tests.Helpers; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Xunit; @@ -102,5 +104,29 @@ namespace Bitip.Client.Tests.Integration Assert.NotNull(result.Location); Assert.NotNull(result.Asn); } + + [Fact] + public async Task GetBatchIpLocation_ReturnsBatchResults() + { + // Skip if not configured for real API + if (!TestConfiguration.UseRealApi || _realServiceProvider == null) + return; + + // Arrange + var client = _realServiceProvider.GetRequiredService(); + var ips = new List { TestConfiguration.ValidIpV4, TestConfiguration.ValidIpV6, TestConfiguration.InvalidIp }; + + // Act + var result = await client.GetBatchIpLocation(ips); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Succeeded.Count()); + Assert.Single(result.Failed); + Assert.All(result.Succeeded, r => Assert.NotNull(r.Ip)); + Assert.All(result.Succeeded, r => Assert.NotNull(r.Country)); + Assert.All(result.Failed, r => Assert.NotNull(r.Ip)); + Assert.All(result.Failed, r => Assert.NotNull(r.Error)); + } } } diff --git a/src/clients/dotnet/Bitip.Client.Tests/TestConfiguration.cs b/src/clients/dotnet/Bitip.Client.Tests/TestConfiguration.cs index fff9f63..8d5af56 100644 --- a/src/clients/dotnet/Bitip.Client.Tests/TestConfiguration.cs +++ b/src/clients/dotnet/Bitip.Client.Tests/TestConfiguration.cs @@ -14,17 +14,15 @@ namespace Bitip.Client.Tests /// /// Real Bitip API base URL for integration tests. - /// Update this with your actual Bitip instance URL. - /// Example: "https://lab.code-rove.com/bitip/api/" /// - public const string RealApiBaseUrl = "https://lab.code-rove.com/bitip/api/"; + public const string RealApiBaseUrl = "http://localhost:5172/api/"; /// /// Real API key for integration tests. /// Update this with your actual API key when running integration tests locally. /// WARNING: Never commit your real API key to source control! /// - public const string RealApiKey = "your-api-key-here"; + public const string RealApiKey = "external-dev-key-1"; /// /// Mock base URL used for unit tests diff --git a/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs b/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs index cc6766e..c63dd3b 100644 --- a/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs +++ b/src/clients/dotnet/Bitip.Client/Constants/ApiConstants.cs @@ -13,7 +13,8 @@ namespace Bitip.Client.Constants Health = "health", Version = "version", Lookup = "lookup?ip={ip}", - DetailedLookup = "lookup/detailed?ip={ip}"; + DetailedLookup = "lookup/detailed?ip={ip}", + BatchLookup = "lookup/batch"; } public struct ValueKeys diff --git a/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs b/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs index 33f75d0..1a54b82 100644 --- a/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs +++ b/src/clients/dotnet/Bitip.Client/Helpers/IpValidator.cs @@ -15,5 +15,12 @@ namespace Bitip.Client.Helpers throw new ArgumentException("The provided IP address is not valid.", nameof(ip)); } + public static bool IsValid(string ip) + { + if (string.IsNullOrWhiteSpace(ip)) + return false; + + return System.Net.IPAddress.TryParse(ip, out _); + } } } diff --git a/src/clients/dotnet/Bitip.Client/Models/BatchIpLocation.cs b/src/clients/dotnet/Bitip.Client/Models/BatchIpLocation.cs new file mode 100644 index 0000000..537e3d8 --- /dev/null +++ b/src/clients/dotnet/Bitip.Client/Models/BatchIpLocation.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Tudor Stanciu + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Bitip.Client.Models +{ + public record BatchIpLookupRequest + { + [JsonPropertyName("ips")] + public required IEnumerable Ips { get; init; } + } + + public record BatchIpLookupResponse + { + [JsonPropertyName("succeeded")] + public required IEnumerable Succeeded { get; init; } + + [JsonPropertyName("failed")] + public required IEnumerable Failed { get; init; } + } + + public record BatchIpLookupError + { + [JsonPropertyName("ip")] + public required string Ip { get; init; } + + [JsonPropertyName("error")] + public required string Error { get; init; } + } +} + diff --git a/src/clients/dotnet/Bitip.Client/README.md b/src/clients/dotnet/Bitip.Client/README.md index 0604145..fee47e5 100644 --- a/src/clients/dotnet/Bitip.Client/README.md +++ b/src/clients/dotnet/Bitip.Client/README.md @@ -8,6 +8,7 @@ A .NET client library for integrating with the [Bitip GeoIP Service](https://lab - ✅ Simple IP geolocation lookup - ✅ Detailed IP geolocation with ASN information +- ✅ Batch IP geolocation for multiple addresses - ✅ Health check endpoint - ✅ Version information retrieval - ✅ Built-in IP address validation @@ -139,6 +140,39 @@ Console.WriteLine($"ISP: {detailedLocation.Asn.AutonomousSystemOrganization}"); - `IpAddress` - IP address - `Network` - Network range +### Batch IP Lookup + +Query multiple IP addresses in a single request. Results are separated into succeeded and failed: + +```csharp +var ips = new List { "8.8.8.8", "invalid-ip", "1.1.1.1" }; +var batchResult = await _bitipClient.GetBatchIpLocation(ips); + +// Process successful lookups +foreach (var location in batchResult.Succeeded) +{ + Console.WriteLine($"{location.Ip}: {location.Country} ({location.CountryCode}), {location.City}"); +} + +// Handle failures +foreach (var error in batchResult.Failed) +{ + Console.WriteLine($"Error for {error.Ip}: {error.Error}"); +} +``` + +**Response Model (`BatchIpLookupResponse`):** + +- `Succeeded` - Collection of `IpLocation` objects for successful lookups + - Same structure as single IP lookup response + - All location data available + +- `Failed` - Collection of `BatchIpLookupError` objects for failed lookups + - `Ip` - The IP address that failed + - `Error` - Error message (e.g., "Invalid IP address format", "Private IP addresses are not supported") + +**Note:** Invalid IPs are validated client-side before sending to the API. The API may return additional errors for private IPs or IPs not found in the database. + ### Health Check Check the health status of the Bitip API: @@ -179,7 +213,7 @@ try } catch (ArgumentException ex) { - // Invalid IP format + // Invalid IP format (single lookup methods only) Console.WriteLine($"Invalid IP: {ex.Message}"); } catch (HttpRequestException ex) @@ -194,9 +228,11 @@ catch (TaskCanceledException ex) } ``` +**Note:** `GetBatchIpLocation` does not throw exceptions for invalid IPs. Instead, it returns error results for each invalid IP in the response. + ### Common API Error Responses -- **400 Bad Request** - Invalid IP address format or private IP +- **400 Bad Request** - Invalid IP address format or private IP (single lookups) - **404 Not Found** - IP address not found in database - **429 Too Many Requests** - Rate limit exceeded - **503 Service Unavailable** - Service under maintenance @@ -250,6 +286,7 @@ This client interacts with the following Bitip API endpoints: | `GetVersion()` | `GET /version` | Version info | | `GetIpLocation(ip)` | `GET /lookup?ip={ip}` | Simple lookup | | `GetDetailedIpLocation(ip)` | `GET /lookup/detailed?ip={ip}` | Detailed lookup | +| `GetBatchIpLocation(ips)` | `POST /lookup/batch` | Batch lookup | ## Best Practices diff --git a/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs b/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs index cf35795..f08e0e6 100644 --- a/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs +++ b/src/clients/dotnet/Bitip.Client/Services/BitipClient.cs @@ -5,8 +5,12 @@ using Bitip.Client.Extensions; using Bitip.Client.Helpers; using Bitip.Client.Models; using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; @@ -52,5 +56,60 @@ namespace Bitip.Client.Services var task = await _httpClient.GetAndReadJsonAsync(route, cancellationToken); return task; } + + public async Task GetBatchIpLocation(IEnumerable ips, CancellationToken cancellationToken = default) + { + if (ips == null) + throw new ArgumentNullException(nameof(ips)); + + if (!ips.Any()) + { + throw new ArgumentException("IP list cannot be empty", nameof(ips)); + } + + var validIps = new List(); + var clientErrors = new List(); + + foreach (var ip in ips) + { + if (IpValidator.IsValid(ip)) + validIps.Add(ip); + else + { + clientErrors.Add(new BatchIpLookupError + { + Ip = ip ?? string.Empty, + Error = "Invalid IP address format" + }); + } + } + + if (validIps.Count <= 0) + { + // All IPs were invalid - return only errors + return new BatchIpLookupResponse + { + Succeeded = Enumerable.Empty(), + Failed = clientErrors + }; + } + + // If we have valid IPs, send them to the API + var request = new BatchIpLookupRequest { Ips = validIps }; + var response = await _httpClient.PostAsJsonAsync(ApiRoutes.BatchLookup, request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + + if (apiResponse == null) + throw new HttpRequestException("Failed to deserialize batch lookup response"); + + // Combine API results with client-side validation errors + return new BatchIpLookupResponse + { + Succeeded = apiResponse.Succeeded, + Failed = apiResponse.Failed.Concat(clientErrors) + }; + } } } diff --git a/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs b/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs index cf9b966..2f9a706 100644 --- a/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs +++ b/src/clients/dotnet/Bitip.Client/Services/IBitipClient.cs @@ -1,6 +1,7 @@ // Copyright (c) 2025 Tudor Stanciu using Bitip.Client.Models; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -12,5 +13,6 @@ namespace Bitip.Client.Services Task GetVersion(CancellationToken cancellationToken = default); Task GetIpLocation(string ip, CancellationToken cancellationToken = default); Task GetDetailedIpLocation(string ip, CancellationToken cancellationToken = default); + Task GetBatchIpLocation(IEnumerable ips, CancellationToken cancellationToken = default); } }