Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
```

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.
84 changes: 78 additions & 6 deletions Sources/IPData/IPDataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -134,19 +188,21 @@ public final class IPDataClient: @unchecked Sendable {
private func fetch<T: Decodable>(_ 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<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await dataTask(request)
private func perform<T: Decodable>(_ 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)
Expand All @@ -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)
}
Expand Down
32 changes: 32 additions & 0 deletions Sources/IPData/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading