Work in progress. A pretty neat pattern is emerging.
Signed-off-by: Adam Rocska <adam.rocska@adams.solutions>
This commit is contained in:
parent
e1a5976007
commit
62484d2cae
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
|
||||
enum DatabaseType: String {
|
||||
public enum DatabaseType: String {
|
||||
case city = "City"
|
||||
case country = "Country"
|
||||
case anonymousIp = "GeoIP2-Anonymous-IP"
|
|
@ -0,0 +1,6 @@
|
|||
import Foundation
|
||||
import enum Decoder.Payload
|
||||
|
||||
public protocol DictionaryInitialisable {
|
||||
init(_ dictionary: [String: Payload]?)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
import enum Decoder.Payload
|
||||
|
||||
public struct CityModel {
|
||||
public struct CityModel: DictionaryInitialisable {
|
||||
let city: CityRecord
|
||||
let location: LocationRecord
|
||||
let postal: PostalRecord
|
||||
|
@ -24,7 +24,7 @@ public struct CityModel {
|
|||
self.subdivisions = subdivisions
|
||||
}
|
||||
|
||||
init(_ dictionary: [String: Payload]?) {
|
||||
public init(_ dictionary: [String: Payload]?) {
|
||||
self.init(
|
||||
city: CityRecord(dictionary?["city"]?.unwrap()),
|
||||
location: LocationRecord(dictionary?["location"]?.unwrap()),
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
import protocol DBReader.Reader
|
||||
|
||||
public class Reader<Model> where Model: DictionaryInitialisable {
|
||||
|
||||
private let dbReader: DBReader.Reader
|
||||
|
||||
init(dbReader: DBReader.Reader) {
|
||||
self.dbReader = dbReader
|
||||
}
|
||||
|
||||
func lookup(_ ip: IpAddress) -> Model? {
|
||||
guard let dictionary = dbReader.get(ip) else { return nil }
|
||||
return Model.init(dictionary)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,150 +0,0 @@
|
|||
import Foundation
|
||||
import protocol DBReader.Reader
|
||||
|
||||
public class CityFileBased {
|
||||
|
||||
static let databaseType = DatabaseType.city
|
||||
|
||||
let dbReader: DBReader.Reader
|
||||
public var metadata: DbMetadata { get { return dbReader.metadata } }
|
||||
|
||||
public init(dbReader: Reader) {
|
||||
// php scripter style validation. Dumb as a rock, but that's what MaxMind implemented, and therefore expects.
|
||||
precondition(dbReader.metadata.databaseType.contains(CityFileBased.databaseType.rawValue))
|
||||
self.dbReader = dbReader
|
||||
}
|
||||
|
||||
public func lookup(_ ip: IpAddress) -> CityModel? {
|
||||
guard let dictionary = dbReader.get(ip) else { return nil }
|
||||
// print(dictionary)
|
||||
let model = CityModel(dictionary)
|
||||
print(model)
|
||||
// ["location": Decoder.Payload.map(
|
||||
// ["time_zone": Decoder.Payload.utf8String("Europe/Budapest"),
|
||||
// "longitude": Decoder.Payload.double(19.0404),
|
||||
// "latitude": Decoder.Payload.double(47.4984),
|
||||
// "accuracy_radius": Decoder.Payload.uInt16(20)]
|
||||
// ),
|
||||
// "continent": Decoder.Payload.map(
|
||||
// ["geoname_id": Decoder.Payload.uInt32(6255148),
|
||||
// "code": Decoder.Payload.utf8String("EU"),
|
||||
// "names": Decoder.Payload.map(
|
||||
// [
|
||||
// "es": Decoder.Payload.utf8String("Europa"),
|
||||
// "fr": Decoder.Payload.utf8String("Europe"),
|
||||
// "ru": Decoder.Payload.utf8String("Европа"),
|
||||
// "pt-BR": Decoder.Payload.utf8String("Europa"),
|
||||
// "ja": Decoder.Payload.utf8String("ヨーロッパ"),
|
||||
// "zh-CN": Decoder.Payload.utf8String("欧洲"),
|
||||
// "de": Decoder.Payload.utf8String("Europa"),
|
||||
// "en": Decoder.Payload.utf8String("Europe")
|
||||
// ]
|
||||
// )]
|
||||
// ),
|
||||
// "country": Decoder.Payload.map(
|
||||
// ["names": Decoder.Payload.map(
|
||||
// ["es": Decoder.Payload.utf8String("Hungría"),
|
||||
// "ja": Decoder.Payload.utf8String(
|
||||
// "ハンガリー共和国"
|
||||
// ),
|
||||
// "en": Decoder.Payload.utf8String("Hungary"),
|
||||
// "ru": Decoder.Payload.utf8String(
|
||||
// "Венгрия"
|
||||
// ),
|
||||
// "pt-BR": Decoder.Payload.utf8String("Hungria"),
|
||||
// "zh-CN": Decoder.Payload.utf8String(
|
||||
// "匈牙利"
|
||||
// ),
|
||||
// "de": Decoder.Payload.utf8String("Ungarn"),
|
||||
// "fr": Decoder.Payload.utf8String("Hongrie")]
|
||||
// ),
|
||||
// "is_in_european_union": Decoder.Payload.boolean(true),
|
||||
// "iso_code": Decoder.Payload.utf8String(
|
||||
// "HU"
|
||||
// ),
|
||||
// "geoname_id": Decoder.Payload.uInt32(719819)]
|
||||
// ),
|
||||
// "subdivisions": Decoder.Payload.array(
|
||||
// [Decoder.Payload.map(
|
||||
// ["geoname_id": Decoder.Payload.uInt32(3054638),
|
||||
// "iso_code": Decoder.Payload.utf8String(
|
||||
// "BU"
|
||||
// ),
|
||||
// "names": Decoder.Payload.map(["en": Decoder.Payload.utf8String("Budapest")])]
|
||||
// )]
|
||||
// ),
|
||||
// "city": Decoder.Payload.map(
|
||||
// ["geoname_id": Decoder.Payload.uInt32(3054643),
|
||||
// "names": Decoder.Payload.map(
|
||||
// ["ja": Decoder.Payload.utf8String("ブダペスト"),
|
||||
// "es": Decoder.Payload.utf8String(
|
||||
// "Budapest"
|
||||
// ),
|
||||
// "ru": Decoder.Payload.utf8String("Будапешт"),
|
||||
// "de": Decoder.Payload.utf8String(
|
||||
// "Budapest"
|
||||
// ),
|
||||
// "en": Decoder.Payload.utf8String("Budapest"),
|
||||
// "fr": Decoder.Payload.utf8String(
|
||||
// "Budapest"
|
||||
// ),
|
||||
// "zh-CN": Decoder.Payload.utf8String("布达佩斯"),
|
||||
// "pt-BR": Decoder.Payload.utf8String("Budapeste")]
|
||||
// )]
|
||||
// ),
|
||||
// "registered_country": Decoder.Payload.map(
|
||||
// ["geoname_id": Decoder.Payload.uInt32(719819),
|
||||
// "is_in_european_union": Decoder.Payload.boolean(
|
||||
// true
|
||||
// ),
|
||||
// "iso_code": Decoder.Payload.utf8String("HU"),
|
||||
// "names": Decoder.Payload.map(
|
||||
// ["de": Decoder.Payload.utf8String("Ungarn"),
|
||||
// "zh-CN": Decoder.Payload.utf8String(
|
||||
// "匈牙利"
|
||||
// ),
|
||||
// "fr": Decoder.Payload.utf8String("Hongrie"),
|
||||
// "ru": Decoder.Payload.utf8String(
|
||||
// "Венгрия"
|
||||
// ),
|
||||
// "pt-BR": Decoder.Payload.utf8String("Hungria"),
|
||||
// "en": Decoder.Payload.utf8String(
|
||||
// "Hungary"
|
||||
// ),
|
||||
// "es": Decoder.Payload.utf8String("Hungría"),
|
||||
// "ja": Decoder.Payload.utf8String("ハンガリー共和国")]
|
||||
// )]
|
||||
// ),
|
||||
// "postal": Decoder.Payload.map(["code": Decoder.Payload.utf8String("1096")])]
|
||||
|
||||
|
||||
|
||||
// CityModel(
|
||||
// city: CityRecord(
|
||||
// confidence: <#T##Int?##Swift.Int?#>,
|
||||
// geonameId: <#T##Int?##Swift.Int?#>,
|
||||
// name: <#T##String?##Swift.String?#>,
|
||||
// names: <#T##[String: String]?##[Swift.String: Swift.String]?#>
|
||||
// ),
|
||||
// location: LocationRecord(
|
||||
// averageIncome: <#T##Int?##Swift.Int?#>,
|
||||
// accuracyRadius: <#T##Int?##Swift.Int?#>,
|
||||
// latitude: <#T##Float?##Swift.Float?#>,
|
||||
// longitude: <#T##Float?##Swift.Float?#>,
|
||||
// populationDensity: <#T##Int?##Swift.Int?#>,
|
||||
// metroCode: <#T##Int?##Swift.Int?#>,
|
||||
// timeZone: <#T##String?##Swift.String?#>
|
||||
// ),
|
||||
// postal: PostalRecord(code: <#T##String?##Swift.String?#>,
|
||||
// confidence: <#T##Int?##Swift.Int?#>),
|
||||
// mostSpecificSubdivision: SubdivisionRecord(
|
||||
// confidence: <#T##Int?##Swift.Int?#>,
|
||||
// geonameId: <#T##Int?##Swift.Int?#>,
|
||||
// isoCode: <#T##String?##Swift.String?#>,
|
||||
// name: <#T##String?##Swift.String?#>,
|
||||
// names: <#T##[String: String]?##[Swift.String: Swift.String]?#>
|
||||
// )
|
||||
// )
|
||||
return nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import Foundation
|
||||
import class DBReader.MediatorFactory
|
||||
import protocol DBReader.ReaderFactory
|
||||
|
||||
public class ReaderFactory {
|
||||
|
||||
typealias DBReaderFactory = DBReader.ReaderFactory
|
||||
|
||||
private let fileReaderFactory: DBReaderFactory
|
||||
|
||||
init(fileReaderFactory: DBReaderFactory) {
|
||||
self.fileReaderFactory = fileReaderFactory
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(fileReaderFactory: DBReader.MediatorFactory())
|
||||
}
|
||||
|
||||
public func makeReader<Model>(source: URL, type: DatabaseType) throws -> Reader<Model>? {
|
||||
if !source.isFileURL { return nil }
|
||||
|
||||
return Reader<Model>(
|
||||
dbReader: try fileReaderFactory.makeInMemoryReader {
|
||||
InputStream(url: source) ?? InputStream()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
import class DataSection.InMemoryDataSection
|
||||
import class IndexReader.InMemoryIndex
|
||||
import class MetadataReader.Reader
|
||||
|
||||
public class MediatorFactory: ReaderFactory {
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makeInMemoryReader(_ inputStream: @escaping () -> InputStream) throws -> Reader {
|
||||
let reader = MetadataReader.Reader(windowSize: 2048)
|
||||
guard let metadata = reader.read(inputStream()) else { throw ReaderError.cantExtractMetadata }
|
||||
let inMemoryIndex = InMemoryIndex<UInt>(metadata: metadata, stream: inputStream())
|
||||
let inMemoryDataSection = InMemoryDataSection(metadata: metadata, stream: inputStream())
|
||||
return Mediator(index: inMemoryIndex, dataSection: inMemoryDataSection, metadata: metadata)
|
||||
}
|
||||
}
|
|
@ -1,17 +1,5 @@
|
|||
import Foundation
|
||||
import class DataSection.InMemoryDataSection
|
||||
import class IndexReader.InMemoryIndex
|
||||
import class MetadataReader.Reader
|
||||
|
||||
public class ReaderFactory {
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makeInMemoryReader(_ inputStream: @escaping () -> InputStream) throws -> Reader {
|
||||
let reader = MetadataReader.Reader(windowSize: 2048)
|
||||
guard let metadata = reader.read(inputStream()) else { throw ReaderError.cantExtractMetadata }
|
||||
let inMemoryIndex = InMemoryIndex<UInt>(metadata: metadata, stream: inputStream())
|
||||
let inMemoryDataSection = InMemoryDataSection(metadata: metadata, stream: inputStream())
|
||||
return Mediator(index: inMemoryIndex, dataSection: inMemoryDataSection, metadata: metadata)
|
||||
}
|
||||
public protocol ReaderFactory {
|
||||
func makeInMemoryReader(_ inputStream: @escaping () -> InputStream) throws -> Reader
|
||||
}
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
import Foundation
|
||||
import XCTest
|
||||
@testable import Api
|
||||
|
||||
import protocol DBReader.Reader
|
||||
import enum IndexReader.IpAddress
|
||||
import enum Decoder.Payload
|
||||
@testable import struct MetadataReader.Metadata
|
||||
|
||||
import class DBReader.ReaderFactory
|
||||
|
||||
class CityFileBasedTest: XCTestCase {
|
||||
|
||||
let stubMetadata = Metadata(
|
||||
nodeCount: 0,
|
||||
recordSize: 0,
|
||||
ipVersion: 0,
|
||||
databaseType: DatabaseType.city.rawValue,
|
||||
languages: [],
|
||||
binaryFormatMajorVersion: 0,
|
||||
binaryFormatMinorVersion: 0,
|
||||
buildEpoch: 0,
|
||||
description: [:],
|
||||
metadataSectionSize: 0,
|
||||
databaseSize: 0
|
||||
)
|
||||
|
||||
func testMetadataForwarding() {
|
||||
let mockReader = MockReader(metadata: stubMetadata)
|
||||
let cityFileBased = CityFileBased(dbReader: mockReader)
|
||||
XCTAssertEqual(stubMetadata, cityFileBased.metadata)
|
||||
}
|
||||
|
||||
func testLookup_returnsNilIfDbReaderDidntReturnResult() {
|
||||
let expectedIp = IpAddress.v4("127.0.0.1")
|
||||
var getWasCalled = false
|
||||
let mockReader = MockReader(metadata: stubMetadata) { address in
|
||||
XCTAssertEqual(expectedIp, address)
|
||||
getWasCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
let cityFileBased = CityFileBased(dbReader: mockReader)
|
||||
XCTAssertNil(cityFileBased.lookup(expectedIp))
|
||||
XCTAssertTrue(getWasCalled)
|
||||
}
|
||||
|
||||
func testLookup_wut() throws {
|
||||
let factory = ReaderFactory()
|
||||
let streamFactory: () -> InputStream = {
|
||||
InputStream(
|
||||
fileAtPath: "/Users/rocskaadam/src/adam-rocska/src/GeoIP2-swift/Tests/ApiTests/Resources/GeoLite2-City_20200526/GeoLite2-City.mmdb"
|
||||
// fileAtPath: "/Users/rocskaadam/src/adam-rocska/src/GeoIP2-swift/Tests/ApiTests/Resources/GeoLite2-ASN_20200526/GeoLite2-ASN.mmdb"
|
||||
// fileAtPath: "/Users/rocskaadam/src/adam-rocska/src/GeoIP2-swift/Tests/DBReaderTests/Resources/GeoLite2-Country_20200421/GeoLite2-Country.mmdb"
|
||||
)!
|
||||
}
|
||||
let reader = try factory.makeInMemoryReader(streamFactory)
|
||||
let cityFileBased = CityFileBased(dbReader: reader)
|
||||
cityFileBased.lookup(.v4("80.99.18.166"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class MockReader: DBReader.Reader {
|
||||
|
||||
let metadata: Metadata
|
||||
private let mockGet: (IpAddress) -> [String: Payload]?
|
||||
|
||||
init(metadata: Metadata, mockGet: @escaping (IpAddress) -> [String: Payload]?) {
|
||||
self.metadata = metadata
|
||||
self.mockGet = mockGet
|
||||
}
|
||||
|
||||
convenience init(metadata: Metadata) { self.init(metadata: metadata, mockGet: { _ in nil }) }
|
||||
|
||||
func get(_ ip: IpAddress) -> [String: Payload]? { return mockGet(ip) }
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
import Foundation
|
||||
import XCTest
|
||||
@testable import class Api.ReaderFactory
|
||||
@testable import struct MetadataReader.Metadata
|
||||
import enum Api.DatabaseType
|
||||
import enum Decoder.Payload
|
||||
import enum IndexReader.IpAddress
|
||||
import class Api.Reader
|
||||
import protocol DBReader.Reader
|
||||
import protocol DBReader.ReaderFactory
|
||||
import protocol Api.DictionaryInitialisable
|
||||
|
||||
fileprivate typealias ApiReader = Api.Reader
|
||||
fileprivate typealias ApiReaderFactory = Api.ReaderFactory
|
||||
fileprivate typealias DBReaderReader = DBReader.Reader
|
||||
fileprivate typealias DBReaderFactory = DBReader.ReaderFactory
|
||||
|
||||
class ReaderFactoryTest: XCTestCase {
|
||||
|
||||
private let databaseTypes = [
|
||||
DatabaseType.city,
|
||||
DatabaseType.country,
|
||||
DatabaseType.anonymousIp,
|
||||
DatabaseType.asn,
|
||||
DatabaseType.connectionType,
|
||||
DatabaseType.domain,
|
||||
DatabaseType.enterprise,
|
||||
DatabaseType.isp
|
||||
]
|
||||
|
||||
func testMakeReader_returnsNilIfSourceIsNotFileURL() {
|
||||
let factory = ApiReaderFactory()
|
||||
|
||||
guard let nonFileUrl = URL(string: "https://github.com/adam-rocska/GeoIP2-swift") else {
|
||||
XCTFail("This should have worked.")
|
||||
return
|
||||
}
|
||||
for databaseType in databaseTypes {
|
||||
XCTAssertNil(
|
||||
try! factory.makeReader(source: nonFileUrl, type: databaseType) as? ApiReader<StubModel>,
|
||||
"Reader factory should have failed, as URL was a non-file URL."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testMakeReader_shouldRethrowIfDbReaderConstructionFailed() {
|
||||
let mockDBReaderFactory = MockDBReaderFactory { _ in throw StubDbError.stubError }
|
||||
let apiReaderFactory = ApiReaderFactory(fileReaderFactory: mockDBReaderFactory)
|
||||
let url = URL(fileURLWithPath: #file)
|
||||
for databaseType in databaseTypes {
|
||||
XCTAssertThrowsError(try apiReaderFactory.makeReader(source: url, type: databaseType) as? ApiReader<StubModel>) {
|
||||
error in
|
||||
XCTAssertEqual(StubDbError.stubError, error as! StubDbError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testMakeReader_injectedInputStreamFactoryShouldReturnEmptyInputStreamIfSourceIsNil() {
|
||||
let mockDbReaderFactory = MockDBReaderFactory { inputStreamFactory in
|
||||
let inputStream = inputStreamFactory()
|
||||
XCTAssertEqual(InputStream.Status.notOpen, inputStream.streamStatus)
|
||||
XCTAssertFalse(inputStream.hasBytesAvailable)
|
||||
throw StubDbError.stubError
|
||||
}
|
||||
let apiReaderFactory = ApiReaderFactory(fileReaderFactory: mockDbReaderFactory)
|
||||
let url = URL(fileURLWithPath: "/not/exists/file")
|
||||
for databaseType in databaseTypes {
|
||||
XCTAssertThrowsError(try apiReaderFactory.makeReader(source: url, type: databaseType) as? ApiReader<StubModel>)
|
||||
}
|
||||
}
|
||||
|
||||
func testMakeReader_makesAndReturnsReaderOfExpectedType() {
|
||||
let mockDBReader = MockDBReader { _ in nil }
|
||||
let url = URL(fileURLWithPath: #file)
|
||||
let mockDBReaderFactory = MockDBReaderFactory { _ in mockDBReader }
|
||||
let apiReaderFactory = ApiReaderFactory(fileReaderFactory: mockDBReaderFactory)
|
||||
for databaseType in databaseTypes {
|
||||
let apiReader = try! apiReaderFactory.makeReader(source: url, type: databaseType) as? ApiReader<StubModel>
|
||||
XCTAssertNotNil(apiReader)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate class MockDBReaderFactory: DBReaderFactory {
|
||||
|
||||
private let stubInMemoryFactory: (() -> InputStream) throws -> DBReaderReader
|
||||
|
||||
init(stubInMemoryFactory: @escaping (() -> InputStream) throws -> DBReaderReader) {
|
||||
self.stubInMemoryFactory = stubInMemoryFactory
|
||||
}
|
||||
|
||||
func makeInMemoryReader(_ inputStream: @escaping () -> InputStream) throws -> DBReaderReader {
|
||||
return try stubInMemoryFactory(inputStream)
|
||||
}
|
||||
}
|
||||
|
||||
enum StubDbError: Error, Equatable { case stubError }
|
||||
|
||||
fileprivate class MockDBReader: DBReaderReader {
|
||||
var metadata = Metadata(
|
||||
nodeCount: 0,
|
||||
recordSize: 0,
|
||||
ipVersion: 0,
|
||||
databaseType: "",
|
||||
languages: [],
|
||||
binaryFormatMajorVersion: 0,
|
||||
binaryFormatMinorVersion: 0,
|
||||
buildEpoch: 0,
|
||||
description: [:],
|
||||
metadataSectionSize: 0,
|
||||
databaseSize: 0
|
||||
)
|
||||
|
||||
private let stubGet: (IpAddress) -> [String: Payload]?
|
||||
|
||||
init(stubGet: @escaping (IpAddress) -> [String: Payload]?) {
|
||||
self.stubGet = stubGet
|
||||
}
|
||||
|
||||
func get(_ ip: IpAddress) -> [String: Payload]? {
|
||||
return stubGet(ip)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct StubModel: DictionaryInitialisable {
|
||||
init(_ dictionary: [String: Payload]?) {}
|
||||
}
|
Loading…
Reference in New Issue