diff --git a/Sources/ipinfoKit/IPInfoPlus.swift b/Sources/ipinfoKit/IPInfoPlus.swift new file mode 100644 index 0000000..8a0f7c7 --- /dev/null +++ b/Sources/ipinfoKit/IPInfoPlus.swift @@ -0,0 +1,232 @@ +import Foundation + +@available(iOS 13.0.0, macOS 10.15.0, *) +@MainActor +open class IPInfoPlus { + private let urlSession: URLSession + private let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + private var token: String + + public init(token: String, urlSession: URLSession = .shared) { + self.token = token + self.urlSession = urlSession + } + + public func lookup(ip: String? = nil) async throws -> Response { + let endpoint = ip ?? "me" + var urlRequest = URLRequest(url: URL(string: "https://api.ipinfo.io/lookup/\(endpoint)")!) + urlRequest.allHTTPHeaderFields = [ + "accept": "application/json", + "authorization": "Bearer \(token)", + "content-type": "application/json", + "user-agent": "IPinfoClient/Swift/\(Constants.SDK_VERSION)", + ] + + let (data, response) = try await urlSession.data(for: urlRequest) + + let httpResponse = response as! HTTPURLResponse + guard (200..<300).contains(httpResponse.statusCode) else { + throw IPInfoPlus.Error.unacceptableStatusCode(httpResponse.statusCode) + } + + return try jsonDecoder.decode(Response.self, from: data) + } +} + +@available(iOS 13.0.0, macOS 10.15.0, *) +extension IPInfoPlus { + public enum Response: Equatable, Decodable { + private enum CodingKeys: CodingKey { + case bogon + } + + case ip(IPResponse) + case bogon(BogonResponse) + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let isBogon = try container.decodeIfPresent(Bool.self, forKey: .bogon) ?? false + + if isBogon { + self = .bogon(try BogonResponse(from: decoder)) + } else { + self = .ip(try IPResponse(from: decoder)) + } + } + } + + public struct BogonResponse: Equatable, Decodable { + public let ip: String + + public init(ip: String) { + self.ip = ip + } + } + + public struct IPResponse: Equatable, Decodable { + public let ip: String + public let hostname: String? + public let geo: Geo + public let `as`: AS + public let isAnonymous: Bool + public let isAnycast: Bool + public let isHosting: Bool + public let isMobile: Bool + public let isSatellite: Bool + public let mobile: Mobile + public let anonymous: Anonymous + + public init( + ip: String, + hostname: String?, + geo: Geo, + as: AS, + isAnonymous: Bool, + isAnycast: Bool, + isHosting: Bool, + isMobile: Bool, + isSatellite: Bool, + mobile: Mobile, + anonymous: Anonymous + ) { + self.ip = ip + self.hostname = hostname + self.geo = geo + self.`as` = `as` + self.isAnonymous = isAnonymous + self.isAnycast = isAnycast + self.isHosting = isHosting + self.isMobile = isMobile + self.isSatellite = isSatellite + self.mobile = mobile + self.anonymous = anonymous + } + } + + public struct Geo: Equatable, Decodable { + public let city: String + public let region: String + public let regionCode: String + public let country: String + public let countryCode: String + public let continent: String + public let continentCode: String + public let latitude: Double + public let longitude: Double + public let timezone: String + public let postalCode: String + public let dmaCode: String? + public let geonameId: String? + public let radius: Int? + public let lastChanged: String? + + public init( + city: String, + region: String, + regionCode: String, + country: String, + countryCode: String, + continent: String, + continentCode: String, + latitude: Double, + longitude: Double, + timezone: String, + postalCode: String, + dmaCode: String? = nil, + geonameId: String? = nil, + radius: Int? = nil, + lastChanged: String? = nil + ) { + self.city = city + self.region = region + self.regionCode = regionCode + self.country = country + self.countryCode = countryCode + self.continent = continent + self.continentCode = continentCode + self.latitude = latitude + self.longitude = longitude + self.timezone = timezone + self.postalCode = postalCode + self.dmaCode = dmaCode + self.geonameId = geonameId + self.radius = radius + self.lastChanged = lastChanged + } + } + + public struct AS: Equatable, Decodable { + public let asn: String + public let name: String + public let domain: String + public let type: String + public let lastChanged: String? + + public init( + asn: String, + name: String, + domain: String, + type: String, + lastChanged: String? = nil + ) { + self.asn = asn + self.name = name + self.domain = domain + self.type = type + self.lastChanged = lastChanged + } + } + + public struct Mobile: Equatable, Decodable { + public let name: String? + public let mcc: String? + public let mnc: String? + + public init(name: String? = nil, mcc: String? = nil, mnc: String? = nil) { + self.name = name + self.mcc = mcc + self.mnc = mnc + } + } + + public struct Anonymous: Equatable, Decodable { + public let isProxy: Bool + public let isRelay: Bool + public let isTor: Bool + public let isVpn: Bool + public let name: String? + + public init( + isProxy: Bool, + isRelay: Bool, + isTor: Bool, + isVpn: Bool, + name: String? = nil + ) { + self.isProxy = isProxy + self.isRelay = isRelay + self.isTor = isTor + self.isVpn = isVpn + self.name = name + } + } +} + +@available(iOS 13.0.0, macOS 10.15.0, *) +extension IPInfoPlus { + public enum Error: Swift.Error, LocalizedError { + case unacceptableStatusCode(Int) + + public var errorDescription: String? { + switch self { + case .unacceptableStatusCode(let statusCode): + return "Response status code was unacceptable: \(statusCode)." + } + } + } +} diff --git a/Tests/ipinfoKitTests/IPInfoPlusTests.swift b/Tests/ipinfoKitTests/IPInfoPlusTests.swift new file mode 100644 index 0000000..f54aa66 --- /dev/null +++ b/Tests/ipinfoKitTests/IPInfoPlusTests.swift @@ -0,0 +1,88 @@ +import ipinfoKit + +import Foundation +import Testing + +@MainActor +struct IPInfoPlusTests { + @Test func plusGoogleDNSTest() async throws { + let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "") + + let response = try await client.lookup(ip: "8.8.8.8") + + guard case .ip(let ipResponse) = response else { + Issue.record("Expected IP response, got bogon") + return + } + + // Test basic fields + #expect(ipResponse.ip == "8.8.8.8") + #expect(ipResponse.hostname != nil) + + // Test geo fields + #expect(!ipResponse.geo.city.isEmpty) + #expect(!ipResponse.geo.region.isEmpty) + #expect(!ipResponse.geo.regionCode.isEmpty) + #expect(!ipResponse.geo.country.isEmpty) + #expect(!ipResponse.geo.countryCode.isEmpty) + #expect(!ipResponse.geo.continent.isEmpty) + #expect(!ipResponse.geo.continentCode.isEmpty) + #expect(ipResponse.geo.latitude != 0.0) + #expect(ipResponse.geo.longitude != 0.0) + #expect(!ipResponse.geo.timezone.isEmpty) + #expect(!ipResponse.geo.postalCode.isEmpty) + #expect(ipResponse.geo.dmaCode != nil) + #expect(ipResponse.geo.geonameId != nil) + #expect(ipResponse.geo.radius != nil) + #expect(ipResponse.geo.lastChanged == nil) + + // Test AS fields + #expect(ipResponse.as.asn == "AS15169") + #expect(!ipResponse.as.name.isEmpty) + #expect(!ipResponse.as.domain.isEmpty) + #expect(!ipResponse.as.type.isEmpty) + #expect(ipResponse.as.lastChanged != nil) + + // Test network flags + #expect(!ipResponse.isAnonymous) + #expect(ipResponse.isAnycast) + #expect(ipResponse.isHosting) + #expect(!ipResponse.isMobile) + #expect(!ipResponse.isSatellite) + + // Test anonymous object + #expect(!ipResponse.anonymous.isProxy) + #expect(!ipResponse.anonymous.isRelay) + #expect(!ipResponse.anonymous.isTor) + #expect(!ipResponse.anonymous.isVpn) + + // Test mobile object (can be empty for non-mobile IPs) + #expect(ipResponse.mobile.name == nil) + #expect(ipResponse.mobile.mcc == nil) + #expect(ipResponse.mobile.mnc == nil) + } + + @Test func plusBogonTest() async throws { + let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "") + + let response = try await client.lookup(ip: "192.168.1.1") + + #expect(response == .bogon(.init(ip: "192.168.1.1"))) + } + + @Test func plusNoIPTest() async throws { + let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "") + + let response = try await client.lookup() + + guard case .ip(let ipResponse) = response else { + Issue.record("Expected IP response, got bogon") + return + } + + // Should return details for the caller's IP + #expect(ipResponse.ip != "") + #expect(ipResponse.hostname != nil) + #expect(!ipResponse.geo.country.isEmpty) + } +}