Work in progress. A pretty neat pattern is emerging.

Signed-off-by: Adam Rocska <adam.rocska@adams.solutions>
This commit is contained in:
Adam Rocska 2020-06-04 13:47:18 +02:00
parent e1a5976007
commit 62484d2cae
10 changed files with 203 additions and 244 deletions

View File

@ -1,6 +1,6 @@
import Foundation
enum DatabaseType: String {
public enum DatabaseType: String {
case city = "City"
case country = "Country"
case anonymousIp = "GeoIP2-Anonymous-IP"

View File

@ -0,0 +1,6 @@
import Foundation
import enum Decoder.Payload
public protocol DictionaryInitialisable {
init(_ dictionary: [String: Payload]?)
}

View File

@ -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()),

17
Sources/Api/Reader.swift Normal file
View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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()
}
)
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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) }
}

View File

@ -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]?) {}
}