Skip to content

Commit 647dbe2

Browse files
authored
Merge pull request #15 from ipinfo/silvano/eng-508-add-core-bundle-support-in-ipinfoswift-library
Add support for Core bundle
2 parents 621b10e + 34f5b24 commit 647dbe2

File tree

2 files changed

+244
-0
lines changed

2 files changed

+244
-0
lines changed

Sources/ipinfoKit/IPInfoCore.swift

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import Foundation
2+
3+
@available(iOS 13.0.0, macOS 10.15.0, *)
4+
@MainActor
5+
open class IPInfoCore {
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 IPInfoCore.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 IPInfoCore {
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 geo: Geo
74+
public let `as`: AS
75+
public let isAnonymous: Bool
76+
public let isAnycast: Bool
77+
public let isHosting: Bool
78+
public let isMobile: Bool
79+
public let isSatellite: Bool
80+
81+
public init(
82+
ip: String,
83+
geo: Geo,
84+
as: AS,
85+
isAnonymous: Bool,
86+
isAnycast: Bool,
87+
isHosting: Bool,
88+
isMobile: Bool,
89+
isSatellite: Bool
90+
) {
91+
self.ip = ip
92+
self.geo = geo
93+
self.`as` = `as`
94+
self.isAnonymous = isAnonymous
95+
self.isAnycast = isAnycast
96+
self.isHosting = isHosting
97+
self.isMobile = isMobile
98+
self.isSatellite = isSatellite
99+
}
100+
}
101+
102+
public struct Geo: Equatable, Decodable {
103+
public let city: String
104+
public let region: String
105+
public let regionCode: String
106+
public let country: String
107+
public let countryCode: String
108+
public let continent: String
109+
public let continentCode: String
110+
public let latitude: Double
111+
public let longitude: Double
112+
public let timezone: String
113+
public let postalCode: String
114+
115+
public init(
116+
city: String,
117+
region: String,
118+
regionCode: String,
119+
country: String,
120+
countryCode: String,
121+
continent: String,
122+
continentCode: String,
123+
latitude: Double,
124+
longitude: Double,
125+
timezone: String,
126+
postalCode: String
127+
) {
128+
self.city = city
129+
self.region = region
130+
self.regionCode = regionCode
131+
self.country = country
132+
self.countryCode = countryCode
133+
self.continent = continent
134+
self.continentCode = continentCode
135+
self.latitude = latitude
136+
self.longitude = longitude
137+
self.timezone = timezone
138+
self.postalCode = postalCode
139+
}
140+
}
141+
142+
public struct AS: Equatable, Decodable {
143+
public let asn: String
144+
public let name: String
145+
public let domain: String
146+
public let type: String
147+
148+
public init(
149+
asn: String,
150+
name: String,
151+
domain: String,
152+
type: String
153+
) {
154+
self.asn = asn
155+
self.name = name
156+
self.domain = domain
157+
self.type = type
158+
}
159+
}
160+
}
161+
162+
@available(iOS 13.0.0, macOS 10.15.0, *)
163+
extension IPInfoCore {
164+
public enum Error: Swift.Error, LocalizedError {
165+
case unacceptableStatusCode(Int)
166+
167+
public var errorDescription: String? {
168+
switch self {
169+
case .unacceptableStatusCode(let statusCode):
170+
return "Response status code was unacceptable: \(statusCode)."
171+
}
172+
}
173+
}
174+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import ipinfoKit
2+
3+
import Foundation
4+
import Testing
5+
6+
@MainActor
7+
struct IPInfoCoreTests {
8+
@Test func coreGoogleDNSTest() async throws {
9+
let client = IPInfoCore(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+
21+
// Test geo fields
22+
#expect(!ipResponse.geo.city.isEmpty)
23+
#expect(!ipResponse.geo.region.isEmpty)
24+
#expect(!ipResponse.geo.regionCode.isEmpty)
25+
#expect(!ipResponse.geo.country.isEmpty)
26+
#expect(!ipResponse.geo.countryCode.isEmpty)
27+
#expect(!ipResponse.geo.continent.isEmpty)
28+
#expect(!ipResponse.geo.continentCode.isEmpty)
29+
#expect(ipResponse.geo.latitude != 0.0)
30+
#expect(ipResponse.geo.longitude != 0.0)
31+
#expect(!ipResponse.geo.timezone.isEmpty)
32+
#expect(!ipResponse.geo.postalCode.isEmpty)
33+
34+
// Test AS fields
35+
#expect(ipResponse.as.asn == "AS15169")
36+
#expect(!ipResponse.as.name.isEmpty)
37+
#expect(!ipResponse.as.domain.isEmpty)
38+
#expect(!ipResponse.as.type.isEmpty)
39+
40+
// Test network flags
41+
#expect(!ipResponse.isAnonymous)
42+
#expect(ipResponse.isAnycast)
43+
#expect(ipResponse.isHosting)
44+
#expect(!ipResponse.isMobile)
45+
#expect(!ipResponse.isSatellite)
46+
}
47+
48+
@Test func coreBogonTest() async throws {
49+
let client = IPInfoCore(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "")
50+
51+
let response = try await client.lookup(ip: "192.168.1.1")
52+
53+
#expect(response == .bogon(.init(ip: "192.168.1.1")))
54+
}
55+
56+
@Test func coreNoIPTest() async throws {
57+
let client = IPInfoCore(token: ProcessInfo.processInfo.environment["IPInfoKitAccessToken"] ?? "")
58+
59+
let response = try await client.lookup()
60+
61+
guard case .ip(let ipResponse) = response else {
62+
Issue.record("Expected IP response, got bogon")
63+
return
64+
}
65+
66+
// Should return details for the caller's IP
67+
#expect(ipResponse.ip != "")
68+
#expect(!ipResponse.geo.country.isEmpty)
69+
}
70+
}

0 commit comments

Comments
 (0)