mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-22 00:01:00 +00:00
Instance filter
This commit is contained in:
parent
bb44676a73
commit
d3d737da86
12 changed files with 232 additions and 34 deletions
|
@ -6,7 +6,7 @@ import Foundation
|
|||
|
||||
public typealias Session = Alamofire.Session
|
||||
|
||||
open class Client {
|
||||
open class HTTPClient {
|
||||
private let session: Session
|
||||
private let decoder: DataDecoder
|
||||
|
||||
|
@ -16,7 +16,7 @@ open class Client {
|
|||
}
|
||||
|
||||
open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
||||
requestPublisher(target).value().mapError { $0 as Error }.eraseToAnyPublisher()
|
||||
requestPublisher(target).value().mapError { $0.underlyingOrTypeErased }.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func request<T: DecodableTarget, E: Error & Decodable>(
|
||||
|
@ -35,14 +35,14 @@ open class Client {
|
|||
throw decodedError
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error.underlyingOrTypeErased
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private extension Client {
|
||||
private extension HTTPClient {
|
||||
func requestPublisher<T: DecodableTarget>(_ target: T) -> DataResponsePublisher<T.ResultType> {
|
||||
if let protocolClasses = session.sessionConfiguration.protocolClasses {
|
||||
for protocolClass in protocolClasses {
|
||||
|
@ -55,3 +55,9 @@ private extension Client {
|
|||
.publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AFError {
|
||||
var underlyingOrTypeErased: Error {
|
||||
underlyingError ?? self
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import HTTP
|
|||
|
||||
public class StubbingURLProtocol: URLProtocol {
|
||||
private static var targetsForURLs = [URL: Target]()
|
||||
private static var stubsForURLs = [URL: HTTPStub]()
|
||||
|
||||
override public class func canInit(with task: URLSessionTask) -> Bool {
|
||||
true
|
||||
|
@ -21,24 +22,31 @@ public class StubbingURLProtocol: URLProtocol {
|
|||
override public func startLoading() {
|
||||
guard
|
||||
let url = request.url,
|
||||
let stub = Self.stub(request: request, target: Self.targetsForURLs[url]) else {
|
||||
// preconditionFailure("Stub for request not found")
|
||||
let stub = Self.stubsForURLs[url]
|
||||
?? Self.stub(request: request, target: Self.targetsForURLs[url]) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch stub {
|
||||
case let .success((response, data)):
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
case let .failure(error):
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override public func stopLoading() {}
|
||||
}
|
||||
|
||||
public extension StubbingURLProtocol {
|
||||
static func setStub(_ stub: HTTPStub, forURL url: URL) {
|
||||
stubsForURLs[url] = stub
|
||||
}
|
||||
}
|
||||
|
||||
private extension StubbingURLProtocol {
|
||||
class func stub(
|
||||
request: URLRequest,
|
||||
|
|
|
@ -5,7 +5,7 @@ import Foundation
|
|||
import HTTP
|
||||
import Mastodon
|
||||
|
||||
public final class MastodonAPIClient: Client {
|
||||
public final class MastodonAPIClient: HTTPClient {
|
||||
public var instanceURL: URL?
|
||||
public var accessToken: String?
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ let package = Package(
|
|||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")),
|
||||
.package(path: "CodableBloomFilter"),
|
||||
.package(path: "DB"),
|
||||
.package(path: "Keychain"),
|
||||
.package(path: "MastodonAPI"),
|
||||
|
@ -26,7 +27,7 @@ let package = Package(
|
|||
targets: [
|
||||
.target(
|
||||
name: "ServiceLayer",
|
||||
dependencies: ["DB", "MastodonAPI", "Secrets"]),
|
||||
dependencies: ["CodableBloomFilter", "DB", "MastodonAPI", "Secrets"]),
|
||||
.target(
|
||||
name: "ServiceLayerMocks",
|
||||
dependencies: [
|
||||
|
|
|
@ -9,6 +9,7 @@ import Secrets
|
|||
|
||||
public struct AllIdentitiesService {
|
||||
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
|
||||
public let instanceFilterService: InstanceFilterService
|
||||
|
||||
private let identityDatabase: IdentityDatabase
|
||||
private let environment: AppEnvironment
|
||||
|
@ -22,6 +23,7 @@ public struct AllIdentitiesService {
|
|||
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||
.replaceError(with: nil)
|
||||
.eraseToAnyPublisher()
|
||||
instanceFilterService = InstanceFilterService(environment: environment)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ private extension AuthenticationService {
|
|||
case codeNotFound
|
||||
}
|
||||
|
||||
private func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
||||
func authorizationURL(instanceURL: URL, clientID: String) -> URL? {
|
||||
guard var authorizationURLComponents = URLComponents(url: instanceURL, resolvingAgainstBaseURL: true) else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import CodableBloomFilter
|
||||
import Combine
|
||||
import Foundation
|
||||
import HTTP
|
||||
|
||||
public struct InstanceFilterService {
|
||||
private let httpClient: HTTPClient
|
||||
private var userDefaultsClient: UserDefaultsClient
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
httpClient = HTTPClient(session: environment.session, decoder: JSONDecoder())
|
||||
userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults)
|
||||
}
|
||||
}
|
||||
|
||||
public extension InstanceFilterService {
|
||||
func isFiltered(url: URL) -> Bool {
|
||||
guard let host = url.host else { return true }
|
||||
|
||||
let allHostComponents = host.components(separatedBy: ".")
|
||||
var hostComponents = [String]()
|
||||
|
||||
for component in allHostComponents.reversed() {
|
||||
hostComponents.insert(component, at: 0)
|
||||
|
||||
if filter.contains(hostComponents.joined(separator: ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func updateFilter() -> AnyPublisher<Never, Never> {
|
||||
httpClient.request(UpdatedFilterTarget())
|
||||
.handleEvents(receiveOutput: { userDefaultsClient.updatedInstanceFilter = $0 })
|
||||
.map { _ in () }
|
||||
.replaceError(with: ())
|
||||
.ignoreOutput()
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
||||
private struct UpdatedFilterTarget: DecodableTarget {
|
||||
typealias ResultType = BloomFilter<String>
|
||||
|
||||
let baseURL = URL(string: "https://filter.metabolist.com")!
|
||||
let pathComponents = ["filter.json"]
|
||||
let method = HTTPMethod.get
|
||||
let encoding: ParameterEncoding = JSONEncoding.default
|
||||
let parameters: [String: Any]? = nil
|
||||
let headers: HTTPHeaders? = nil
|
||||
}
|
||||
|
||||
private extension InstanceFilterService {
|
||||
var filter: BloomFilter<String> {
|
||||
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter
|
||||
}
|
||||
|
||||
static let updatedFilterUserDefaultsKey = "updatedFilter"
|
||||
// Ugly, but baking this into the compiled app instead of loading the data from the bundle is more secure
|
||||
// swiftlint:disable line_length
|
||||
static let defaultFilterData = #"{"hashers":["djb2","djb2a","fnv1","fnv1a","sdbm"],"data":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAIAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAgAAAAAQAAAAAABAAACAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAABAAAEAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAIAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAIAAAQAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAQAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADAAAAAAAAAAAAA=="}"#
|
||||
.data(using: .utf8)!
|
||||
// swiftlint:enable line_length
|
||||
// swiftlint:disable force_try
|
||||
static let defaultFilter = try! JSONDecoder().decode(BloomFilter<String>.self, from: defaultFilterData)
|
||||
// swiftlint:enable force_try
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import CodableBloomFilter
|
||||
import Foundation
|
||||
|
||||
class UserDefaultsClient {
|
||||
private let userDefaults: UserDefaults
|
||||
|
||||
init(userDefaults: UserDefaults) {
|
||||
self.userDefaults = userDefaults
|
||||
}
|
||||
}
|
||||
|
||||
extension UserDefaultsClient {
|
||||
var updatedInstanceFilter: BloomFilter<String>? {
|
||||
get {
|
||||
guard let data = self[.updatedFilter] as Data? else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? JSONDecoder().decode(BloomFilter<String>.self, from: data)
|
||||
}
|
||||
|
||||
set {
|
||||
var data: Data?
|
||||
|
||||
if let newValue = newValue {
|
||||
data = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
|
||||
self[.updatedFilter] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UserDefaultsClient {
|
||||
enum Item: String {
|
||||
case updatedFilter
|
||||
}
|
||||
|
||||
subscript<T>(index: Item) -> T? {
|
||||
get { userDefaults.value(forKey: index.rawValue) as? T }
|
||||
set { userDefaults.set(newValue, forKey: index.rawValue) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import CodableBloomFilter
|
||||
import Combine
|
||||
import CombineExpectations
|
||||
@testable import ServiceLayer
|
||||
@testable import ServiceLayerMocks
|
||||
import Stubbing
|
||||
import XCTest
|
||||
|
||||
class InstanceFilterServiceTests: XCTestCase {
|
||||
func testFiltering() throws {
|
||||
let sut = InstanceFilterService(environment: .mock())
|
||||
let unfilteredInstanceURL = URL(string: "https://unfiltered.instance")!
|
||||
let filteredInstanceURL = URL(string: "https://filtered.instance")!
|
||||
let subdomainFilteredInstanceURL = URL(string: "https://subdomain.filtered.instance")!
|
||||
|
||||
XCTAssertFalse(sut.isFiltered(url: unfilteredInstanceURL))
|
||||
XCTAssertTrue(sut.isFiltered(url: filteredInstanceURL))
|
||||
XCTAssertTrue(sut.isFiltered(url: subdomainFilteredInstanceURL))
|
||||
}
|
||||
|
||||
func testUpdating() throws {
|
||||
let environment = AppEnvironment.mock()
|
||||
var sut = InstanceFilterService(environment: environment)
|
||||
let previouslyFilteredInstanceURL = URL(string: "https://filtered.instance")!
|
||||
let newlyFilteredInstanceURL = URL(string: "https://instance.filtered")!
|
||||
|
||||
XCTAssertTrue(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||
XCTAssertFalse(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||
|
||||
var updatedFilter = BloomFilter<String>(hashers: [.djb2, .sdbm], byteCount: 16)
|
||||
|
||||
updatedFilter.insert("instance.filtered")
|
||||
|
||||
let updatedFilterData = try JSONEncoder().encode(updatedFilter)
|
||||
let stub: HTTPStub = .success((URLResponse(), updatedFilterData))
|
||||
|
||||
StubbingURLProtocol.setStub(stub, forURL: URL(string: "https://filter.metabolist.com/filter.json")!)
|
||||
|
||||
let updateRecorder = sut.updateFilter().collect().record()
|
||||
|
||||
_ = try wait(for: updateRecorder.next(), timeout: 1)
|
||||
|
||||
XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||
XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||
|
||||
sut = InstanceFilterService(environment: environment)
|
||||
|
||||
XCTAssertFalse(sut.isFiltered(url: previouslyFilteredInstanceURL))
|
||||
XCTAssertTrue(sut.isFiltered(url: newlyFilteredInstanceURL))
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ public extension AddIdentityViewModel {
|
|||
let instanceURL: URL
|
||||
|
||||
do {
|
||||
try instanceURL = urlFieldText.url()
|
||||
instanceURL = try checkedURL()
|
||||
} catch {
|
||||
alertItem = AlertItem(error: error)
|
||||
|
||||
|
@ -37,6 +37,9 @@ public extension AddIdentityViewModel {
|
|||
.collect()
|
||||
.map { _ in (identityID, instanceURL) }
|
||||
.flatMap(allIdentitiesService.createIdentity(id:instanceURL:))
|
||||
.mapError {
|
||||
return $0
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assignErrorsToAlertItem(to: \.alertItem, on: self)
|
||||
.handleEvents(
|
||||
|
@ -55,7 +58,7 @@ public extension AddIdentityViewModel {
|
|||
let instanceURL: URL
|
||||
|
||||
do {
|
||||
try instanceURL = urlFieldText.url()
|
||||
instanceURL = try checkedURL()
|
||||
} catch {
|
||||
alertItem = AlertItem(error: error)
|
||||
|
||||
|
@ -72,4 +75,33 @@ public extension AddIdentityViewModel {
|
|||
} receiveValue: { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refreshFilter() {
|
||||
allIdentitiesService.instanceFilterService.updateFilter()
|
||||
.sink { _ in }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddIdentityViewModel {
|
||||
private static let filteredURL = URL(string: "https://filtered")!
|
||||
private static let HTTPSPrefix = "https://"
|
||||
|
||||
func checkedURL() throws -> URL {
|
||||
let url: URL
|
||||
|
||||
if urlFieldText.hasPrefix(Self.HTTPSPrefix), let prefixedURL = URL(string: urlFieldText) {
|
||||
url = prefixedURL
|
||||
} else if let unprefixedURL = URL(string: Self.HTTPSPrefix + urlFieldText) {
|
||||
url = unprefixedURL
|
||||
} else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
if allIdentitiesService.instanceFilterService.isFiltered(url: url) {
|
||||
return Self.filteredURL
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
private static let HTTPSPrefix = "https://"
|
||||
|
||||
func url() throws -> URL {
|
||||
let url: URL?
|
||||
|
||||
if hasPrefix(Self.HTTPSPrefix) {
|
||||
url = URL(string: self)
|
||||
} else {
|
||||
url = URL(string: Self.HTTPSPrefix + self)
|
||||
}
|
||||
|
||||
guard let validURL = url else { throw URLError(.badURL) }
|
||||
|
||||
return validURL
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ struct AddIdentityView: View {
|
|||
rootViewModel.newIdentitySelected(id: id)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: viewModel.refreshFilter)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue