From 018b4df04f92fd7ce13cbe49a67ec6bc5eecd45f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:32:18 +0000 Subject: [PATCH 1/2] Reuse HTTPS connections and retry lost-connection bulk lookups IPDataClient already sends every request through one URLSession (the process-wide URLSession.shared by default), so connections are pooled and HTTP/2 is negotiated natively on Apple platforms and on Linux (libcurl). This commit makes that behaviour explicit and complete: - Add `init(apiKey:baseURL:configuration:)`, which creates a dedicated URLSession once from the given configuration, owns it for the lifetime of the client and calls `finishTasksAndInvalidate()` in `deinit`. The existing `init(apiKey:baseURL:session:)` keeps `.shared` as its default and never invalidates an injected session; both public initialisers now delegate to one private designated initialiser. - Send `Accept: application/json` next to the existing `api-key` header on every request. - Retry `POST /bulk` exactly once when the transport fails with `URLError.networkConnectionLost`, the error a pooled connection produces when the server closed it while idle. Nothing else is retried, and HTTP statuses are never retried. - Keep the Linux `URLSession.data(for:)` shim unchanged. Tests: consecutive lookups of every kind arrive through the single injected mock session (handler invocations counted); the owned-session initialiser routes requests through its configuration, honours `baseURL` and lets the client deallocate; the Accept header is asserted on GET, raw-field and bulk requests; the bulk retry happens once and only for `networkConnectionLost`, never for other errors or HTTP statuses, and plain lookups are not retried by the client. The bulk test now reads the request body from `httpBodyStream` when `httpBody` is nil, which is how Apple platforms hand POST bodies to a URLProtocol; this fixes the macOS test crash on main that PR #3 also addresses, in a form that compiles on Linux too. README: new "Connection reuse and HTTP/2" section after Quick Start, and the install snippet points at 1.0.2, the release that ships these changes. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- README.md | 20 +- Sources/IPData/IPDataClient.swift | 84 ++++++++- Tests/IPDataTests/IPDataClientTests.swift | 213 +++++++++++++++++++++- 3 files changed, 309 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 013dfb6..66e60d2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Add the dependency to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/ipdata/swift.git", from: "1.0.0") + .package(url: "https://github.com/ipdata/swift.git", from: "1.0.2") ] ``` @@ -41,6 +41,24 @@ print(info.countryName ?? "") // United States print(info.threat?.isVPN ?? false) ``` +## Connection reuse and HTTP/2 + +An `IPDataClient` keeps a persistent HTTPS connection pool for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup. HTTP/2 and keep-alive are automatic: `URLSession` negotiates HTTP/2 with api.ipdata.co when the platform supports it (Apple platforms, and Linux when libcurl was built with HTTP/2), multiplexes requests over one connection, and falls back to HTTP/1.1 keep-alive otherwise. Only the first request pays the TCP and TLS handshake; nothing needs to be configured. + +By default the client sends its requests through `URLSession.shared`, the process-wide session, so its connections are shared with the rest of your app and survive the client itself. To give a client a dedicated session, pass a `URLSessionConfiguration`; the client creates that session once, owns it, and invalidates it (`finishTasksAndInvalidate()`) when the client is deallocated: + +```swift +let configuration = URLSessionConfiguration.default +configuration.timeoutIntervalForRequest = 10 +let client = IPDataClient(apiKey: "YOUR_API_KEY", configuration: configuration) +``` + +You can still inject a ready-made `URLSession` with `IPDataClient(apiKey:baseURL:session:)`, for example one with a custom delegate or a mock `URLProtocol` in tests; the client never invalidates a session it did not create. + +`IPDataClient` is immutable and `Sendable`: share one instance across threads and Swift concurrency tasks. There is nothing to close explicitly; let the client be released and its owned session, if it has one, is invalidated for you (`URLSession.shared` is never invalidated). + +A bulk lookup (`POST /bulk`) that fails with `URLError.networkConnectionLost`, typically because the server closed an idle pooled connection, is sent once more over a fresh connection. No other request is retried by the client, and nothing is retried once an HTTP status has been received. + ## Usage ### Look Up the Current Device's IP diff --git a/Sources/IPData/IPDataClient.swift b/Sources/IPData/IPDataClient.swift index 300f5fc..5963b00 100644 --- a/Sources/IPData/IPDataClient.swift +++ b/Sources/IPData/IPDataClient.swift @@ -12,6 +12,12 @@ import FoundationNetworking /// let info = try await client.lookup("8.8.8.8") /// print(info.city ?? "unknown") /// ``` +/// +/// Create one client per process (or per API key or endpoint) and reuse it for every +/// lookup. Its `URLSession` keeps HTTPS connections alive between requests and +/// multiplexes them over HTTP/2 where the platform negotiates it, so only the first +/// request pays the TCP and TLS handshake. The client is immutable and `Sendable`: +/// one instance can be shared across threads and tasks. public final class IPDataClient: @unchecked Sendable { /// The base URL for the standard API. @@ -22,6 +28,9 @@ public final class IPDataClient: @unchecked Sendable { private let apiKey: String private let baseURL: URL private let session: URLSession + /// Whether `session` was created by this client, which then invalidates it in + /// `deinit`, or was supplied by the caller, who keeps ownership of it. + private let ownsSession: Bool private let decoder: JSONDecoder /// Creates a new client. @@ -30,18 +39,59 @@ public final class IPDataClient: @unchecked Sendable { /// - apiKey: Your ipdata API key. /// - baseURL: Override the base URL (defaults to `https://api.ipdata.co`). /// Use ``euBaseURL`` to route through EU servers only. - /// - session: A custom `URLSession` if needed (e.g. for testing). - public init( + /// - session: A custom `URLSession` if needed (e.g. for testing). Defaults to + /// `URLSession.shared`, whose connection pool is shared by the whole + /// process. The client never invalidates a session passed in here. + public convenience init( apiKey: String, baseURL: URL = IPDataClient.defaultBaseURL, session: URLSession = .shared ) { + self.init(apiKey: apiKey, baseURL: baseURL, session: session, ownsSession: false) + } + + /// Creates a client that owns a dedicated `URLSession` built from `configuration`. + /// + /// The session is created once and keeps its connections alive for as long as the + /// client lives; it is invalidated with `finishTasksAndInvalidate()` when the client + /// is deallocated. Use this to give the client its own connection pool instead of + /// `URLSession.shared`, or to set custom timeouts, proxies or additional headers. + /// + /// - Parameters: + /// - apiKey: Your ipdata API key. + /// - baseURL: Override the base URL (defaults to `https://api.ipdata.co`). + /// Use ``euBaseURL`` to route through EU servers only. + /// - configuration: The configuration of the client's own session, for example + /// `URLSessionConfiguration.default` with a custom timeout. + public convenience init( + apiKey: String, + baseURL: URL = IPDataClient.defaultBaseURL, + configuration: URLSessionConfiguration + ) { + self.init( + apiKey: apiKey, + baseURL: baseURL, + session: URLSession(configuration: configuration), + ownsSession: true + ) + } + + private init(apiKey: String, baseURL: URL, session: URLSession, ownsSession: Bool) { self.apiKey = apiKey self.baseURL = baseURL self.session = session + self.ownsSession = ownsSession self.decoder = JSONDecoder() } + deinit { + // Only a session created by this client is torn down here. `URLSession.shared` + // and injected sessions belong to the caller and must never be invalidated. + if ownsSession { + session.finishTasksAndInvalidate() + } + } + // MARK: - IP Lookup /// Look up the calling device's IP address. @@ -93,10 +143,14 @@ public final class IPDataClient: @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(apiKey, forHTTPHeaderField: "api-key") request.httpBody = try JSONEncoder().encode(ips) - return try await perform(request) + // POST is not idempotent, so URLSession does not resend it by itself when a + // pooled connection turns out to have been closed by the server while idle; + // `dataTask` retries that single case once. + return try await perform(request, retryOnConnectionLoss: true) } // MARK: - ASN Lookup @@ -134,19 +188,21 @@ public final class IPDataClient: @unchecked Sendable { private func fetch(_ url: URL) async throws -> T { var request = URLRequest(url: url) request.setValue(apiKey, forHTTPHeaderField: "api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") return try await perform(request) } private func fetchRaw(_ url: URL) async throws -> Data { var request = URLRequest(url: url) request.setValue(apiKey, forHTTPHeaderField: "api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") let (data, response) = try await dataTask(request) try checkResponse(response, data: data) return data } - private func perform(_ request: URLRequest) async throws -> T { - let (data, response) = try await dataTask(request) + private func perform(_ request: URLRequest, retryOnConnectionLoss: Bool = false) async throws -> T { + let (data, response) = try await dataTask(request, retryOnConnectionLoss: retryOnConnectionLoss) try checkResponse(response, data: data) do { return try decoder.decode(T.self, from: data) @@ -155,9 +211,25 @@ public final class IPDataClient: @unchecked Sendable { } } - private func dataTask(_ request: URLRequest) async throws -> (Data, URLResponse) { + /// Sends `request` on the client's session and wraps transport failures in + /// ``IPDataError/networkError(_:)``. + /// + /// With `retryOnConnectionLoss`, a request that fails with + /// `URLError.networkConnectionLost` is sent exactly once more. That is the error a + /// pooled connection produces when the server closed it while it was idle: no + /// response was received, the request never reached the application, and + /// `URLSession` opens a fresh connection for the second attempt. Nothing else is + /// retried, and HTTP statuses are never retried: they are turned into errors by + /// `checkResponse` after this returns. + private func dataTask(_ request: URLRequest, retryOnConnectionLoss: Bool = false) async throws -> (Data, URLResponse) { do { return try await session.data(for: request) + } catch let error as URLError where retryOnConnectionLoss && error.code == .networkConnectionLost { + do { + return try await session.data(for: request) + } catch { + throw IPDataError.networkError(error) + } } catch { throw IPDataError.networkError(error) } diff --git a/Tests/IPDataTests/IPDataClientTests.swift b/Tests/IPDataTests/IPDataClientTests.swift index 303c722..63d696c 100644 --- a/Tests/IPDataTests/IPDataClientTests.swift +++ b/Tests/IPDataTests/IPDataClientTests.swift @@ -9,11 +9,16 @@ import FoundationNetworking final class MockURLProtocol: URLProtocol { static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + /// Number of requests that reached this protocol since the counter was last reset. + /// The protocol is registered only on the session injected into the client under + /// test, so every request counted here went through that one session. + static var requestCount = 0 override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { + MockURLProtocol.requestCount += 1 guard let handler = MockURLProtocol.requestHandler else { XCTFail("No request handler set") return @@ -31,6 +36,44 @@ final class MockURLProtocol: URLProtocol { override func stopLoading() {} } +// MARK: - Test Helpers + +/// The body of a request captured by `MockURLProtocol`. +/// +/// On Apple platforms `URLSession` hands the body to a `URLProtocol` as +/// `httpBodyStream` and leaves `httpBody` nil; on Linux `httpBody` is set. +private func requestBody(of request: URLRequest) -> Data? { + if let body = request.httpBody { + return body + } + guard let stream = request.httpBodyStream else { + return nil + } + stream.open() + defer { stream.close() } + var collected = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + collected.append(buffer, count: read) + } + return collected +} + +/// A 200 response carrying `json`, for use inside a request handler. +private func okResponse(for request: URLRequest, json: String) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, json.data(using: .utf8)!) +} + +/// A session configuration that routes every request to `MockURLProtocol`. +private func mockConfiguration() -> URLSessionConfiguration { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return config +} + // MARK: - Client Tests final class IPDataClientTests: XCTestCase { @@ -40,6 +83,7 @@ final class IPDataClientTests: XCTestCase { override func setUp() { super.setUp() + MockURLProtocol.requestCount = 0 let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] session = URLSession(configuration: config) @@ -48,6 +92,7 @@ final class IPDataClientTests: XCTestCase { override func tearDown() { MockURLProtocol.requestHandler = nil + MockURLProtocol.requestCount = 0 session = nil client = nil super.tearDown() @@ -61,6 +106,7 @@ final class IPDataClientTests: XCTestCase { XCTAssertTrue(request.url!.path.contains("/8.8.8.8")) XCTAssertTrue(request.url!.query!.contains("api-key=test-api-key")) XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "test-api-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! let data = #"{"ip": "8.8.8.8"}"#.data(using: .utf8)! @@ -105,6 +151,8 @@ final class IPDataClientTests: XCTestCase { let asnJSON = #"{"asn": "AS15169", "name": "Google LLC"}"# MockURLProtocol.requestHandler = { request in XCTAssertTrue(request.url!.path.contains("/8.8.8.8/asn")) + XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "test-api-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return (response, asnJSON.data(using: .utf8)!) @@ -139,9 +187,14 @@ final class IPDataClientTests: XCTestCase { XCTAssertEqual(request.httpMethod, "POST") XCTAssertTrue(request.url!.path.contains("/bulk")) XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "test-api-key") - let body = try! JSONDecoder().decode([String].self, from: request.httpBody!) + guard let bodyData = requestBody(of: request) else { + XCTFail("Expected an HTTP body on the bulk request") + throw URLError(.unknown) + } + let body = try JSONDecoder().decode([String].self, from: bodyData) XCTAssertEqual(body, ["1.1.1.1", "8.8.8.8"]) let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! @@ -155,6 +208,164 @@ final class IPDataClientTests: XCTestCase { XCTAssertEqual(results[1].ip, "8.8.8.8") } + // MARK: - Connection Reuse + + func testConsecutiveRequestsGoThroughTheSameInjectedSession() async throws { + MockURLProtocol.requestHandler = { request in + if request.httpMethod == "POST" { + return okResponse(for: request, json: #"[{"ip": "1.1.1.1"}, {"ip": "8.8.8.8"}]"#) + } + return okResponse(for: request, json: #"{"ip": "8.8.8.8"}"#) + } + + // MockURLProtocol is registered only on the session injected into `client`, so a + // request that reaches it necessarily went through that session rather than + // through URLSession.shared or a transport created per request. + _ = try await client.lookup("8.8.8.8") + _ = try await client.lookup("8.8.8.8", fields: ["ip"]) + _ = try await client.lookup() + _ = try await client.lookupField("8.8.8.8", field: "asn") + _ = try await client.bulkLookup(["1.1.1.1", "8.8.8.8"]) + _ = try await client.lookupASN("AS15169") + + XCTAssertEqual(MockURLProtocol.requestCount, 6) + } + + func testOwnedSessionInitialiserSendsRequestsThroughItsConfiguration() async throws { + let owned = IPDataClient(apiKey: "test-api-key", configuration: mockConfiguration()) + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "test-api-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + return okResponse(for: request, json: #"{"ip": "8.8.8.8"}"#) + } + + let first = try await owned.lookup("8.8.8.8") + let second = try await owned.lookup("8.8.8.8") + + XCTAssertEqual(first.ip, "8.8.8.8") + XCTAssertEqual(second.ip, "8.8.8.8") + XCTAssertEqual(MockURLProtocol.requestCount, 2) + } + + func testOwnedSessionInitialiserAcceptsBaseURL() async throws { + let owned = IPDataClient( + apiKey: "test-api-key", + baseURL: IPDataClient.euBaseURL, + configuration: mockConfiguration() + ) + MockURLProtocol.requestHandler = { request in + XCTAssertTrue(request.url!.absoluteString.contains("eu-api.ipdata.co")) + return okResponse(for: request, json: #"{"ip": "8.8.8.8"}"#) + } + + let info = try await owned.lookup("8.8.8.8") + XCTAssertEqual(info.ip, "8.8.8.8") + XCTAssertEqual(MockURLProtocol.requestCount, 1) + } + + func testClientWithOwnedSessionIsReleasedAfterUse() async throws { + MockURLProtocol.requestHandler = { request in + okResponse(for: request, json: #"{"ip": "8.8.8.8"}"#) + } + var owned: IPDataClient? = IPDataClient(apiKey: "test-api-key", configuration: mockConfiguration()) + weak var weakClient = owned + + _ = try await owned?.lookup("8.8.8.8") + owned = nil + + // The owned session does not retain the client, so `deinit` (which invalidates + // that session) runs as soon as the last reference to the client goes away. + XCTAssertNil(weakClient) + } + + // MARK: - Bulk Retry + + func testBulkLookupRetriesOnceWhenTheConnectionWasLost() async throws { + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "POST") + if MockURLProtocol.requestCount == 1 { + // A pooled connection the server closed while idle: no response bytes arrived. + throw URLError(.networkConnectionLost) + } + return okResponse(for: request, json: #"[{"ip": "1.1.1.1"}]"#) + } + + let results = try await client.bulkLookup(["1.1.1.1"]) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].ip, "1.1.1.1") + XCTAssertEqual(MockURLProtocol.requestCount, 2, "the bulk request should be sent exactly twice") + } + + func testBulkLookupIsRetriedOnlyOnce() async throws { + MockURLProtocol.requestHandler = { _ in + throw URLError(.networkConnectionLost) + } + + do { + _ = try await client.bulkLookup(["1.1.1.1"]) + XCTFail("Expected error") + } catch let error as IPDataError { + guard case .networkError(let underlying) = error else { + return XCTFail("Expected .networkError, got \(error)") + } + XCTAssertEqual((underlying as? URLError)?.code, .networkConnectionLost) + } + XCTAssertEqual(MockURLProtocol.requestCount, 2, "one retry, then the error surfaces") + } + + func testBulkLookupDoesNotRetryOtherNetworkErrors() async throws { + MockURLProtocol.requestHandler = { _ in + throw URLError(.timedOut) + } + + do { + _ = try await client.bulkLookup(["1.1.1.1"]) + XCTFail("Expected error") + } catch let error as IPDataError { + guard case .networkError(let underlying) = error else { + return XCTFail("Expected .networkError, got \(error)") + } + XCTAssertEqual((underlying as? URLError)?.code, .timedOut) + } + XCTAssertEqual(MockURLProtocol.requestCount, 1) + } + + func testBulkLookupDoesNotRetryOnHTTPStatus() async throws { + MockURLProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + let data = #"{"message": "Internal server error"}"#.data(using: .utf8)! + return (response, data) + } + + do { + _ = try await client.bulkLookup(["1.1.1.1"]) + XCTFail("Expected error") + } catch let error as IPDataError { + guard case .unexpectedStatus(let code, _) = error else { + return XCTFail("Expected .unexpectedStatus, got \(error)") + } + XCTAssertEqual(code, 500) + } + XCTAssertEqual(MockURLProtocol.requestCount, 1) + } + + func testLookupIsNotRetriedByTheClientWhenTheConnectionWasLost() async throws { + MockURLProtocol.requestHandler = { _ in + throw URLError(.networkConnectionLost) + } + + do { + _ = try await client.lookup("8.8.8.8") + XCTFail("Expected error") + } catch let error as IPDataError { + guard case .networkError = error else { + return XCTFail("Expected .networkError, got \(error)") + } + } + XCTAssertEqual(MockURLProtocol.requestCount, 1) + } + // MARK: - ASN Lookup func testASNLookupPrependsASPrefix() async throws { From eba3de3cc3f56f824c18611173ca94dced2a9c29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:36:41 +0000 Subject: [PATCH 2/2] Add is_satellite, is_anycast and blocklist date fields The API now returns two more top-level booleans and three more keys per blocklist entry: - `IPResponse.isSatellite` and `IPResponse.isAnycast` (`is_satellite`, `is_anycast`), siblings of `isEU`. The API includes each key only when it is true, so they are plain `Bool`s decoded with `decodeIfPresent(Bool.self, ...) ?? false` in the existing custom `init(from:)`: absent or null reads false. - `Blocklist.firstSeen`, `Blocklist.lastSeen` (`first_seen`, `last_seen`, `YYYY-MM-DD` UTC strings) and `Blocklist.daysSeen` (`days_seen`, Int). Some feeds, for example ipdata's own vpn feed, omit all three, so they are optional and decode to nil when absent. The dates stay strings: no date parsing, no new dependencies. `JSONDecoder` ignores unknown keys by default and the client configures nothing else, so responses with further new keys keep decoding; a test now pins that. The client does not validate `fields=` names, so the new names pass through unchanged (covered by a client test). Tests: fixtures built from the three sample responses (satellite ISP; anycast address with dated blocklist entries; VPN exit whose third blocklist entry has no dates), the same shape in a bulk response, false-when-absent on the existing fixtures, explicit false/true/null values, and unknown keys. README: the two flags join the IPResponse table, a Blocklist table documents the entry fields and which feeds omit the dates, and the usage examples show both. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- README.md | 36 ++- Sources/IPData/Models.swift | 32 +++ Tests/IPDataTests/IPDataClientTests.swift | 14 ++ Tests/IPDataTests/IPDataTests.swift | 279 ++++++++++++++++++++++ 4 files changed, 360 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 66e60d2..9d6bbce 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,28 @@ if let threat = info.threat { print("Trust score: \(threat.scores?.trustScore ?? 0)") for entry in threat.blocklists ?? [] { - print("Listed on: \(entry.name ?? "")") + print("Listed on: \(entry.name ?? "") (\(entry.type ?? ""))") + if let firstSeen = entry.firstSeen, let lastSeen = entry.lastSeen { + print(" seen \(firstSeen) to \(lastSeen), \(entry.daysSeen ?? 0) day(s)") + } } } ``` +### Satellite and Anycast Flags + +`isSatellite` and `isAnycast` are plain `Bool`s. The API includes `is_satellite` and `is_anycast` in a response only when they are true, so both read `false` whenever the key is absent: + +```swift +let info = try await client.lookup("8.8.8.8") +if info.isAnycast { + print("anycast address") +} +if info.isSatellite { + print("satellite internet provider") +} +``` + ### EU Endpoint Route all requests through EU-based servers: @@ -173,6 +190,8 @@ The primary response type for IP lookups. |---|---|---| | `ip` | `String?` | IP address | | `isEU` | `Bool?` | Located in the EU | +| `isSatellite` | `Bool` | Satellite internet provider. The API sends `is_satellite` only when it is true; reads `false` when the key is absent | +| `isAnycast` | `Bool` | Anycast address. The API sends `is_anycast` only when it is true; reads `false` when the key is absent | | `city` | `String?` | City name | | `region` | `String?` | Region / state | | `regionCode` | `String?` | Region code | @@ -203,4 +222,19 @@ Detailed ASN data from the dedicated `/asn` endpoint. Includes `ipv4Prefixes`, ` Threat intelligence flags: `isTor`, `isVPN`, `isICloudRelay`, `isProxy`, `isDatacenter`, `isAnonymous`, `isKnownAttacker`, `isKnownAbuser`, `isThreat`, `isBogon`. Includes `blocklists` and reputation `scores` (0-100 for VPN, proxy, threat, and trust). +### Blocklist + +One entry per list in `threat.blocklists`: + +| Property | Type | Description | +|---|---|---| +| `name` | `String?` | Name of the blocklist | +| `site` | `String?` | Website of the blocklist | +| `type` | `String?` | Kind of list (`vpn`, `cloud`, `general`, ...) | +| `firstSeen` | `String?` | First day the IP was seen on the list, `YYYY-MM-DD` (UTC) | +| `lastSeen` | `String?` | Last day the IP was seen on the list, `YYYY-MM-DD` (UTC) | +| `daysSeen` | `Int?` | Number of days the IP has been seen on the list | + +Some feeds, such as ipdata's own VPN feed, omit `firstSeen`, `lastSeen` and `daysSeen`; those entries decode with the three properties set to `nil`. The dates are kept as strings exactly as the API sends them. + All model types conform to `Codable`, `Sendable`, and `Equatable`. diff --git a/Sources/IPData/Models.swift b/Sources/IPData/Models.swift index e531a26..1e8994d 100644 --- a/Sources/IPData/Models.swift +++ b/Sources/IPData/Models.swift @@ -6,6 +6,16 @@ import Foundation public struct IPResponse: Codable, Sendable, Equatable { public let ip: String? public let isEU: Bool? + /// Whether the address belongs to a satellite internet provider. + /// + /// The API sends `is_satellite` only when it is true, so this reads `false` + /// whenever the key is absent from the response. + public let isSatellite: Bool + /// Whether the address is anycast, i.e. announced from several locations at once. + /// + /// The API sends `is_anycast` only when it is true, so this reads `false` + /// whenever the key is absent from the response. + public let isAnycast: Bool public let city: String? public let region: String? public let regionCode: String? @@ -35,6 +45,8 @@ public struct IPResponse: Codable, Sendable, Equatable { enum CodingKeys: String, CodingKey { case ip case isEU = "is_eu" + case isSatellite = "is_satellite" + case isAnycast = "is_anycast" case city, region case regionCode = "region_code" case regionType = "region_type" @@ -56,6 +68,9 @@ public struct IPResponse: Codable, Sendable, Equatable { let c = try decoder.container(keyedBy: CodingKeys.self) ip = try c.decodeIfPresent(String.self, forKey: .ip) isEU = try c.decodeIfPresent(Bool.self, forKey: .isEU) + // The API includes these two keys only when they are true. + isSatellite = try c.decodeIfPresent(Bool.self, forKey: .isSatellite) ?? false + isAnycast = try c.decodeIfPresent(Bool.self, forKey: .isAnycast) ?? false city = try c.decodeIfPresent(String.self, forKey: .city) region = try c.decodeIfPresent(String.self, forKey: .region) regionCode = try c.decodeIfPresent(String.self, forKey: .regionCode) @@ -220,10 +235,27 @@ public struct Threat: Codable, Sendable, Equatable { // MARK: - Blocklist /// A blocklist entry that an IP address appears on. +/// +/// `firstSeen`, `lastSeen` and `daysSeen` are omitted by some feeds (for example +/// ipdata's own VPN feed), so they are optional. The dates are kept as the +/// `YYYY-MM-DD` (UTC) strings the API sends. public struct Blocklist: Codable, Sendable, Equatable { public let name: String? public let site: String? public let type: String? + /// First day the address was seen on this list, as `YYYY-MM-DD` (UTC). + public let firstSeen: String? + /// Last day the address was seen on this list, as `YYYY-MM-DD` (UTC). + public let lastSeen: String? + /// Number of days the address has been seen on this list. + public let daysSeen: Int? + + enum CodingKeys: String, CodingKey { + case name, site, type + case firstSeen = "first_seen" + case lastSeen = "last_seen" + case daysSeen = "days_seen" + } } // MARK: - ThreatScores diff --git a/Tests/IPDataTests/IPDataClientTests.swift b/Tests/IPDataTests/IPDataClientTests.swift index 63d696c..2cdf3a5 100644 --- a/Tests/IPDataTests/IPDataClientTests.swift +++ b/Tests/IPDataTests/IPDataClientTests.swift @@ -147,6 +147,20 @@ final class IPDataClientTests: XCTestCase { XCTAssertEqual(info.city, "Mountain View") } + func testFieldsParameterPassesNewFieldNamesThrough() async throws { + // The client does not validate field names; the new ones are sent as given. + MockURLProtocol.requestHandler = { request in + let query = request.url!.query! + XCTAssertTrue(query.contains("fields=is_satellite,is_anycast,is_eu"), "Query: \(query)") + return okResponse(for: request, json: #"{"is_anycast": true, "is_eu": false}"#) + } + + let info = try await client.lookup("8.8.8.8", fields: ["is_satellite", "is_anycast", "is_eu"]) + XCTAssertTrue(info.isAnycast) + XCTAssertFalse(info.isSatellite) + XCTAssertEqual(info.isEU, false) + } + func testLookupFieldReturnsRawData() async throws { let asnJSON = #"{"asn": "AS15169", "name": "Google LLC"}"# MockURLProtocol.requestHandler = { request in diff --git a/Tests/IPDataTests/IPDataTests.swift b/Tests/IPDataTests/IPDataTests.swift index 44ab39a..968eee1 100644 --- a/Tests/IPDataTests/IPDataTests.swift +++ b/Tests/IPDataTests/IPDataTests.swift @@ -87,6 +87,8 @@ final class IPDataTests: XCTestCase { XCTAssertEqual(info.ip, "8.8.8.8") XCTAssertEqual(info.isEU, false) + XCTAssertFalse(info.isSatellite) + XCTAssertFalse(info.isAnycast) XCTAssertEqual(info.city, "Mountain View") XCTAssertEqual(info.region, "California") XCTAssertEqual(info.regionCode, "CA") @@ -170,6 +172,8 @@ final class IPDataTests: XCTestCase { let info = try JSONDecoder().decode(IPResponse.self, from: json) XCTAssertEqual(info.ip, "1.2.3.4") XCTAssertEqual(info.countryName, "Australia") + XCTAssertFalse(info.isSatellite) + XCTAssertFalse(info.isAnycast) XCTAssertNil(info.city) XCTAssertNil(info.asn) XCTAssertNil(info.threat) @@ -256,10 +260,285 @@ final class IPDataTests: XCTestCase { XCTAssertEqual(threat.isThreat, true) XCTAssertEqual(threat.blocklists?.count, 2) XCTAssertEqual(threat.blocklists?.first?.name, "Firehol Level 1") + XCTAssertNil(threat.blocklists?.first?.firstSeen) + XCTAssertNil(threat.blocklists?.first?.lastSeen) + XCTAssertNil(threat.blocklists?.first?.daysSeen) XCTAssertEqual(threat.scores?.proxyScore, 85) XCTAssertEqual(threat.scores?.threatScore, 92) } + // MARK: - is_satellite, is_anycast and Blocklist Dates + + func testDecodeSatelliteResponse() throws { + // Sample A: a satellite ISP. The API sends `is_satellite` only when it is true. + let json = """ + { + "ip": "98.97.34.0", + "is_satellite": true, + "is_eu": false, + "city": "Seattle", + "country_code": "US", + "asn": { + "asn": "AS14593", + "name": "Space Exploration Technologies Corporation", + "domain": "spacex.com", + "route": "98.97.32.0/21", + "type": "business" + }, + "threat": { + "is_tor": false, + "is_vpn": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": false, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [], + "scores": {"vpn_score": 0, "proxy_score": 1, "threat_score": 8, "trust_score": 97} + }, + "count": "0" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(IPResponse.self, from: json) + XCTAssertEqual(info.ip, "98.97.34.0") + XCTAssertTrue(info.isSatellite) + XCTAssertFalse(info.isAnycast, "is_anycast is absent, so it reads false") + XCTAssertEqual(info.isEU, false) + XCTAssertEqual(info.city, "Seattle") + XCTAssertEqual(info.countryCode, "US") + XCTAssertEqual(info.asn?.asn, "AS14593") + XCTAssertEqual(info.asn?.name, "Space Exploration Technologies Corporation") + XCTAssertEqual(info.threat?.isDatacenter, false) + XCTAssertEqual(info.threat?.blocklists?.isEmpty, true) + XCTAssertEqual(info.threat?.scores?.trustScore, 97) + XCTAssertEqual(info.count, 0) + } + + func testDecodeAnycastResponseWithDatedBlocklists() throws { + // Sample B: an anycast address with dated blocklist entries (complete response). + let json = """ + { + "ip": "8.8.8.8", + "is_anycast": true, + "is_eu": false, + "city": null, + "region": null, + "region_code": null, + "region_type": null, + "country_name": "United States", + "country_code": "US", + "continent_name": "North America", + "continent_code": "NA", + "latitude": 39.7599983215332, + "longitude": -98.5, + "postal": null, + "calling_code": "1", + "flag": "https://ipdata.co/flags/us.png", + "emoji_flag": "🇺🇸", + "emoji_unicode": "U+1F1FA U+1F1F8", + "asn": {"asn": "AS15169", "name": "Google LLC", "domain": "about.google", "route": "8.8.8.0/24", "type": "hosting"}, + "company": {"name": "Google LLC", "domain": "about.google", "network": "8.8.8.0/24", "type": "hosting"}, + "languages": [{"name": "English", "native": "English", "code": "en"}], + "currency": {"name": "US Dollar", "code": "USD", "symbol": "$", "native": "$", "plural": "US dollars"}, + "time_zone": {"name": null, "abbr": null, "offset": null, "is_dst": null, "current_time": null}, + "threat": { + "is_tor": false, + "is_vpn": false, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": true, + "is_anonymous": false, + "is_known_attacker": false, + "is_known_abuser": false, + "is_threat": false, + "is_bogon": false, + "blocklists": [ + {"days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud"}, + {"days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "open_resolvers"} + ], + "scores": {"vpn_score": 100, "proxy_score": 93, "threat_score": 100, "trust_score": 2} + }, + "count": "0" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(IPResponse.self, from: json) + XCTAssertEqual(info.ip, "8.8.8.8") + XCTAssertTrue(info.isAnycast) + XCTAssertFalse(info.isSatellite, "is_satellite is absent, so it reads false") + XCTAssertEqual(info.isEU, false) + XCTAssertNil(info.city) + XCTAssertEqual(info.countryName, "United States") + XCTAssertEqual(info.latitude, 39.7599983215332) + XCTAssertNil(info.timeZone?.name) + XCTAssertEqual(info.threat?.isDatacenter, true) + + let blocklists = try XCTUnwrap(info.threat?.blocklists) + XCTAssertEqual(blocklists.count, 2) + XCTAssertEqual(blocklists[0].name, "ipdata") + XCTAssertEqual(blocklists[0].site, "https://ipdata.co") + XCTAssertEqual(blocklists[0].type, "cloud") + XCTAssertEqual(blocklists[0].firstSeen, "2026-11-02") + XCTAssertEqual(blocklists[0].lastSeen, "2026-11-02") + XCTAssertEqual(blocklists[0].daysSeen, 1) + XCTAssertEqual(blocklists[1].type, "open_resolvers") + XCTAssertEqual(blocklists[1].daysSeen, 1) + XCTAssertEqual(info.threat?.scores?.trustScore, 2) + XCTAssertEqual(info.count, 0) + } + + func testDecodeBlocklistEntryWithoutDates() throws { + // Sample C: a VPN exit. Neither top-level flag is present, and the third + // blocklist entry (ipdata's own vpn feed) carries no dates. + let json = """ + { + "ip": "187.13.250.1", + "is_eu": false, + "threat": { + "is_tor": false, + "is_vpn": true, + "is_icloud_relay": false, + "is_proxy": false, + "is_datacenter": true, + "is_anonymous": true, + "is_known_attacker": true, + "is_known_abuser": true, + "is_threat": true, + "is_bogon": false, + "blocklists": [ + {"days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Gluetun", "site": "https://github.com/qdm12/gluetun", "type": "vpn"}, + {"days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud"}, + {"name": "ipdata", "site": "https://ipdata.co", "type": "vpn"}, + {"days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "NordVPN", "site": "https://nordvpn.com", "type": "vpn"}, + {"days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Wikimedia Global Blocklist", "site": "https://wikimedia.org", "type": "general"}, + {"days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "X4BNet", "site": "https://github.com/X4BNet/lists_vpn", "type": "vpn"} + ], + "scores": {"vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0} + }, + "count": "0" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(IPResponse.self, from: json) + XCTAssertEqual(info.ip, "187.13.250.1") + XCTAssertFalse(info.isSatellite) + XCTAssertFalse(info.isAnycast) + XCTAssertEqual(info.threat?.isVPN, true) + XCTAssertEqual(info.threat?.isThreat, true) + + let blocklists = try XCTUnwrap(info.threat?.blocklists) + XCTAssertEqual(blocklists.count, 6) + XCTAssertEqual(blocklists[0].name, "Gluetun") + XCTAssertEqual(blocklists[0].firstSeen, "2026-10-31") + XCTAssertEqual(blocklists[0].lastSeen, "2026-11-02") + XCTAssertEqual(blocklists[0].daysSeen, 2) + + // The vpn feed entry has no first_seen, last_seen or days_seen; decoding still succeeds. + XCTAssertEqual(blocklists[2].name, "ipdata") + XCTAssertEqual(blocklists[2].site, "https://ipdata.co") + XCTAssertEqual(blocklists[2].type, "vpn") + XCTAssertNil(blocklists[2].firstSeen) + XCTAssertNil(blocklists[2].lastSeen) + XCTAssertNil(blocklists[2].daysSeen) + + XCTAssertEqual(blocklists[5].name, "X4BNet") + XCTAssertEqual(blocklists[5].daysSeen, 2) + XCTAssertEqual(info.threat?.scores?.trustScore, 0) + } + + func testNewFlagsReadFalseUnlessSentAsTrue() throws { + let absent = try JSONDecoder().decode( + IPResponse.self, + from: #"{"ip": "1.1.1.1", "is_eu": true}"#.data(using: .utf8)! + ) + XCTAssertFalse(absent.isSatellite) + XCTAssertFalse(absent.isAnycast) + XCTAssertEqual(absent.isEU, true) + + let explicit = try JSONDecoder().decode( + IPResponse.self, + from: #"{"ip": "1.1.1.1", "is_satellite": false, "is_anycast": true}"#.data(using: .utf8)! + ) + XCTAssertFalse(explicit.isSatellite) + XCTAssertTrue(explicit.isAnycast) + + let nullFlag = try JSONDecoder().decode( + IPResponse.self, + from: #"{"ip": "1.1.1.1", "is_satellite": null}"#.data(using: .utf8)! + ) + XCTAssertFalse(nullFlag.isSatellite) + } + + func testDecodeBulkResponseWithNewFields() throws { + // Bulk responses carry the same shape per item. + let json = """ + [ + {"ip": "98.97.34.0", "is_satellite": true, "is_eu": false}, + { + "ip": "8.8.8.8", + "is_anycast": true, + "is_eu": false, + "threat": { + "is_vpn": false, + "blocklists": [ + {"days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud"} + ] + } + }, + { + "ip": "187.13.250.1", + "is_eu": false, + "threat": { + "is_vpn": true, + "blocklists": [{"name": "ipdata", "site": "https://ipdata.co", "type": "vpn"}] + } + } + ] + """.data(using: .utf8)! + + let results = try JSONDecoder().decode([IPResponse].self, from: json) + XCTAssertEqual(results.count, 3) + XCTAssertTrue(results[0].isSatellite) + XCTAssertFalse(results[0].isAnycast) + XCTAssertFalse(results[1].isSatellite) + XCTAssertTrue(results[1].isAnycast) + XCTAssertEqual(results[1].threat?.blocklists?.first?.firstSeen, "2026-11-02") + XCTAssertEqual(results[1].threat?.blocklists?.first?.lastSeen, "2026-11-02") + XCTAssertEqual(results[1].threat?.blocklists?.first?.daysSeen, 1) + XCTAssertFalse(results[2].isSatellite) + XCTAssertFalse(results[2].isAnycast) + XCTAssertEqual(results[2].threat?.isVPN, true) + XCTAssertNil(results[2].threat?.blocklists?.first?.firstSeen) + XCTAssertNil(results[2].threat?.blocklists?.first?.lastSeen) + XCTAssertNil(results[2].threat?.blocklists?.first?.daysSeen) + } + + func testDecoderIgnoresUnknownKeys() throws { + let json = """ + { + "ip": "8.8.8.8", + "is_anycast": true, + "some_future_field": {"nested": [1, 2, 3]}, + "threat": { + "is_vpn": false, + "another_future_field": true, + "blocklists": [{"name": "ipdata", "type": "cloud", "yet_another_field": "x"}] + } + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(IPResponse.self, from: json) + XCTAssertEqual(info.ip, "8.8.8.8") + XCTAssertTrue(info.isAnycast) + XCTAssertEqual(info.threat?.isVPN, false) + XCTAssertEqual(info.threat?.blocklists?.first?.name, "ipdata") + XCTAssertNil(info.threat?.blocklists?.first?.daysSeen) + } + // MARK: - Error Handling func testErrorDescriptions() {