Skip to content

Commit 640c7a1

Browse files
authored
Merge pull request #16 from ipinfo/silvano/eng-509-add-plus-bundle-support-in-ipinfoswift-library
Add support for Plus bundle
2 parents 647dbe2 + 1f616fb commit 640c7a1

File tree

2 files changed

+320
-0
lines changed

2 files changed

+320
-0
lines changed

Sources/ipinfoKit/IPInfoPlus.swift

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import Foundation
2+
3+
@available(iOS 13.0.0, macOS 10.15.0, *)
4+
@MainActor
5+
open class IPInfoPlus {
6+
private let urlSession: URLSession
7+
private let jsonDecoder: JSONDecoder = {
8+
let decoder = JSONDecoder()
9+
decoder.keyDecodingStrategy = .convertFromSnakeCase
10+
return decoder
11+
}()
12+
13+
private var token: String
14+
15+
public init(token: String, urlSession: URLSession = .shared) {
16+
self.token = token
17+
self.urlSession = urlSession
18+
}
19+
20+
public func lookup(ip: String? = nil) async throws -> Response {
21+
let endpoint = ip ?? "me"
22+
var urlRequest = URLRequest(url: URL(string: "https://api.ipinfo.io/lookup/\(endpoint)")!)
23+
urlRequest.allHTTPHeaderFields = [
24+
"accept": "application/json",
25+
"authorization": "Bearer \(token)",
26+
"content-type": "application/json",
27+
"user-agent": "IPinfoClient/Swift/\(Constants.SDK_VERSION)",
28+
]
29+
30+
let (data, response) = try await urlSession.data(for: urlRequest)
31+
32+
let httpResponse = response as! HTTPURLResponse
33+
guard (200..<300).contains(httpResponse.statusCode) else {
34+
throw IPInfoPlus.Error.unacceptableStatusCode(httpResponse.statusCode)
35+
}
36+
37+
return try jsonDecoder.decode(Response.self, from: data)
38+
}
39+
}
40+
41+
@available(iOS 13.0.0, macOS 10.15.0, *)
42+
extension IPInfoPlus {
43+
public enum Response: Equatable, Decodable {
44+
private enum CodingKeys: CodingKey {
45+
case bogon
46+
}
47+
48+
case ip(IPResponse)
49+
case bogon(BogonResponse)
50+
51+
public init(from decoder: any Decoder) throws {
52+
let container = try decoder.container(keyedBy: CodingKeys.self)
53+
let isBogon = try container.decodeIfPresent(Bool.self, forKey: .bogon) ?? false
54+
55+
if isBogon {
56+
self = .bogon(try BogonResponse(from: decoder))
57+
} else {
58+
self = .ip(try IPResponse(from: decoder))
59+
}
60+
}
61+
}
62+
63+
public struct BogonResponse: Equatable, Decodable {
64+
public let ip: String
65+
66+
public init(ip: String) {
67+
self.ip = ip
68+
}
69+
}
70+
71+
public struct IPResponse: Equatable, Decodable {
72+
public let ip: String
73+
public let hostname: String?
74+
public let geo: Geo
75+
public let `as`: AS
76+
public let isAnonymous: Bool
77+
public let isAnycast: Bool
78+
public let isHosting: Bool
79+
public let isMobile: Bool
80+
public let isSatellite: Bool
81+
public let mobile: Mobile
82+
public let anonymous: Anonymous
83+
84+
public init(
85+
ip: String,
86+
hostname: String?,
87+
geo: Geo,
88+
as: AS,
89+
isAnonymous: Bool,
90+
isAnycast: Bool,
91+
isHosting: Bool,
92+
isMobile: Bool,
93+
isSatellite: Bool,
94+
mobile: Mobile,
95+
anonymous: Anonymous
96+
) {
97+
self.ip = ip
98+
self.hostname = hostname
99+
self.geo = geo
100+
self.`as` = `as`
101+
self.isAnonymous = isAnonymous
102+
self.isAnycast = isAnycast
103+
self.isHosting = isHosting
104+
self.isMobile = isMobile
105+
self.isSatellite = isSatellite
106+
self.mobile = mobile
107+
self.anonymous = anonymous
108+
}
109+
}
110+
111+
public struct Geo: Equatable, Decodable {
112+
public let city: String
113+
public let region: String
114+
public let regionCode: String
115+
public let country: String
116+
public let countryCode: String
117+
public let continent: String
118+
public let continentCode: String
119+
public let latitude: Double
120+
public let longitude: Double
121+
public let timezone: String
122+
public let postalCode: String
123+
public let dmaCode: String?
124+
public let geonameId: String?
125+
public let radius: Int?
126+
public let lastChanged: String?
127+
128+
public init(
129+
city: String,
130+
region: String,
131+
regionCode: String,
132+
country: String,
133+
countryCode: String,
134+
continent: String,
135+
continentCode: String,
136+
latitude: Double,
137+
longitude: Double,
138+
timezone: String,
139+
postalCode: String,
140+
dmaCode: String? = nil,
141+
geonameId: String? = nil,
142+
radius: Int? = nil,
143+
lastChanged: String? = nil
144+
) {
145+
self.city = city
146+
self.region = region
147+
self.regionCode = regionCode
148+
self.country = country
149+
self.countryCode = countryCode
150+
self.continent = continent
151+
self.continentCode = continentCode
152+
self.latitude = latitude
153+
self.longitude = longitude
154+
self.timezone = timezone
155+
self.postalCode = postalCode
156+
self.dmaCode = dmaCode
157+
self.geonameId = geonameId
158+
self.radius = radius
159+
self.lastChanged = lastChanged
160+
}
161+
}
162+
163+
public struct AS: Equatable, Decodable {
164+
public let asn: String
165+
public let name: String
166+
public let domain: String
167+
public let type: String
168+
public let lastChanged: String?
169+
170+
public init(
171+
asn: String,
172+
name: String,
173+
domain: String,
174+
type: String,
175+
lastChanged: String? = nil
176+
) {
177+
self.asn = asn
178+
self.name = name
179+
self.domain = domain
180+
self.type = type
181+
self.lastChanged = lastChanged
182+
}
183+
}
184+
185+
public struct Mobile: Equatable, Decodable {
186+
public let name: String?
187+
public let mcc: String?
188+
public let mnc: String?
189+
190+
public init(name: String? = nil, mcc: String? = nil, mnc: String? = nil) {
191+
self.name = name
192+
self.mcc = mcc
193+
self.mnc = mnc
194+
}
195+
}
196+
197+
public struct Anonymous: Equatable, Decodable {
198+
public let isProxy: Bool
199+
public let isRelay: Bool
200+
public let isTor: Bool
201+
public let isVpn: Bool
202+
public let name: String?
203+
204+
public init(
205+
isProxy: Bool,
206+
isRelay: Bool,
207+
isTor: Bool,
208+
isVpn: Bool,
209+
name: String? = nil
210+
) {
211+
self.isProxy = isProxy
212+
self.isRelay = isRelay
213+
self.isTor = isTor
214+
self.isVpn = isVpn
215+
self.name = name
216+
}
217+
}
218+
}
219+
220+
@available(iOS 13.0.0, macOS 10.15.0, *)
221+
extension IPInfoPlus {
222+
public enum Error: Swift.Error, LocalizedError {
223+
case unacceptableStatusCode(Int)
224+
225+
public var errorDescription: String? {
226+
switch self {
227+
case .unacceptableStatusCode(let statusCode):
228+
return "Response status code was unacceptable: \(statusCode)."
229+
}
230+
}
231+
}
232+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import ipinfoKit
2+
3+
import Foundation
4+
import Testing
5+
6+
@MainActor
7+
struct IPInfoPlusTests {
8+
@Test func plusGoogleDNSTest() async throws {
9+
let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "")
10+
11+
let response = try await client.lookup(ip: "8.8.8.8")
12+
13+
guard case .ip(let ipResponse) = response else {
14+
Issue.record("Expected IP response, got bogon")
15+
return
16+
}
17+
18+
// Test basic fields
19+
#expect(ipResponse.ip == "8.8.8.8")
20+
#expect(ipResponse.hostname != nil)
21+
22+
// Test geo fields
23+
#expect(!ipResponse.geo.city.isEmpty)
24+
#expect(!ipResponse.geo.region.isEmpty)
25+
#expect(!ipResponse.geo.regionCode.isEmpty)
26+
#expect(!ipResponse.geo.country.isEmpty)
27+
#expect(!ipResponse.geo.countryCode.isEmpty)
28+
#expect(!ipResponse.geo.continent.isEmpty)
29+
#expect(!ipResponse.geo.continentCode.isEmpty)
30+
#expect(ipResponse.geo.latitude != 0.0)
31+
#expect(ipResponse.geo.longitude != 0.0)
32+
#expect(!ipResponse.geo.timezone.isEmpty)
33+
#expect(!ipResponse.geo.postalCode.isEmpty)
34+
#expect(ipResponse.geo.dmaCode != nil)
35+
#expect(ipResponse.geo.geonameId != nil)
36+
#expect(ipResponse.geo.radius != nil)
37+
#expect(ipResponse.geo.lastChanged == nil)
38+
39+
// Test AS fields
40+
#expect(ipResponse.as.asn == "AS15169")
41+
#expect(!ipResponse.as.name.isEmpty)
42+
#expect(!ipResponse.as.domain.isEmpty)
43+
#expect(!ipResponse.as.type.isEmpty)
44+
#expect(ipResponse.as.lastChanged != nil)
45+
46+
// Test network flags
47+
#expect(!ipResponse.isAnonymous)
48+
#expect(ipResponse.isAnycast)
49+
#expect(ipResponse.isHosting)
50+
#expect(!ipResponse.isMobile)
51+
#expect(!ipResponse.isSatellite)
52+
53+
// Test anonymous object
54+
#expect(!ipResponse.anonymous.isProxy)
55+
#expect(!ipResponse.anonymous.isRelay)
56+
#expect(!ipResponse.anonymous.isTor)
57+
#expect(!ipResponse.anonymous.isVpn)
58+
59+
// Test mobile object (can be empty for non-mobile IPs)
60+
#expect(ipResponse.mobile.name == nil)
61+
#expect(ipResponse.mobile.mcc == nil)
62+
#expect(ipResponse.mobile.mnc == nil)
63+
}
64+
65+
@Test func plusBogonTest() async throws {
66+
let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "")
67+
68+
let response = try await client.lookup(ip: "192.168.1.1")
69+
70+
#expect(response == .bogon(.init(ip: "192.168.1.1")))
71+
}
72+
73+
@Test func plusNoIPTest() async throws {
74+
let client = IPInfoPlus(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "")
75+
76+
let response = try await client.lookup()
77+
78+
guard case .ip(let ipResponse) = response else {
79+
Issue.record("Expected IP response, got bogon")
80+
return
81+
}
82+
83+
// Should return details for the caller's IP
84+
#expect(ipResponse.ip != "")
85+
#expect(ipResponse.hostname != nil)
86+
#expect(!ipResponse.geo.country.isEmpty)
87+
}
88+
}

0 commit comments

Comments
 (0)