diff --git a/README.md b/README.md index 013dfb6..9d6bbce 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 @@ -105,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: @@ -155,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 | @@ -185,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/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/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 303c722..2cdf3a5 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)! @@ -101,10 +147,26 @@ 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 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 +201,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 +222,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 { 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() {