diff --git a/Sources/Api/Reader/DatabaseType.swift b/Sources/Api/DatabaseType.swift similarity index 90% rename from Sources/Api/Reader/DatabaseType.swift rename to Sources/Api/DatabaseType.swift index 568f568..cc846da 100644 --- a/Sources/Api/Reader/DatabaseType.swift +++ b/Sources/Api/DatabaseType.swift @@ -1,6 +1,6 @@ import Foundation -enum DatabaseType: String { +public enum DatabaseType: String { case city = "City" case country = "Country" case anonymousIp = "GeoIP2-Anonymous-IP" diff --git a/Sources/Api/DictionaryInitialisable.swift b/Sources/Api/DictionaryInitialisable.swift new file mode 100644 index 0000000..d1d4076 --- /dev/null +++ b/Sources/Api/DictionaryInitialisable.swift @@ -0,0 +1,6 @@ +import Foundation +import enum Decoder.Payload + +public protocol DictionaryInitialisable { + init(_ dictionary: [String: Payload]?) +} diff --git a/Sources/Api/Model/CityModel.swift b/Sources/Api/Model/CityModel.swift index 0697676..f4bc6a7 100644 --- a/Sources/Api/Model/CityModel.swift +++ b/Sources/Api/Model/CityModel.swift @@ -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()), diff --git a/Sources/Api/Reader.swift b/Sources/Api/Reader.swift new file mode 100644 index 0000000..5372405 --- /dev/null +++ b/Sources/Api/Reader.swift @@ -0,0 +1,17 @@ +import Foundation +import protocol DBReader.Reader + +public class Reader 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) + } + +} diff --git a/Sources/Api/Reader/CityFileBased.swift b/Sources/Api/Reader/CityFileBased.swift deleted file mode 100644 index b1a4b43..0000000 --- a/Sources/Api/Reader/CityFileBased.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/Api/ReaderFactory.swift b/Sources/Api/ReaderFactory.swift new file mode 100644 index 0000000..0a31344 --- /dev/null +++ b/Sources/Api/ReaderFactory.swift @@ -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(source: URL, type: DatabaseType) throws -> Reader? { + if !source.isFileURL { return nil } + + return Reader( + dbReader: try fileReaderFactory.makeInMemoryReader { + InputStream(url: source) ?? InputStream() + } + ) + } + +} diff --git a/Sources/DBReader/MediatorFactory.swift b/Sources/DBReader/MediatorFactory.swift new file mode 100644 index 0000000..0f87a07 --- /dev/null +++ b/Sources/DBReader/MediatorFactory.swift @@ -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(metadata: metadata, stream: inputStream()) + let inMemoryDataSection = InMemoryDataSection(metadata: metadata, stream: inputStream()) + return Mediator(index: inMemoryIndex, dataSection: inMemoryDataSection, metadata: metadata) + } +} diff --git a/Sources/DBReader/ReaderFactory.swift b/Sources/DBReader/ReaderFactory.swift index 4eca5d7..d0bd6bc 100644 --- a/Sources/DBReader/ReaderFactory.swift +++ b/Sources/DBReader/ReaderFactory.swift @@ -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(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 } diff --git a/Tests/ApiTests/Reader/CityFileBasedTest.swift b/Tests/ApiTests/Reader/CityFileBasedTest.swift deleted file mode 100644 index fab08b9..0000000 --- a/Tests/ApiTests/Reader/CityFileBasedTest.swift +++ /dev/null @@ -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) } -} \ No newline at end of file diff --git a/Tests/ApiTests/ReaderFactoryTest.swift b/Tests/ApiTests/ReaderFactoryTest.swift new file mode 100644 index 0000000..059768f --- /dev/null +++ b/Tests/ApiTests/ReaderFactoryTest.swift @@ -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, + "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) { + 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) + } + } + + 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 + 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]?) {} +} \ No newline at end of file