diff --git a/Sources/InstantSearchCore/Helper/Decodable+JSON.swift b/Sources/InstantSearchCore/Helper/Decodable+JSON.swift new file mode 100644 index 00000000..295ed15b --- /dev/null +++ b/Sources/InstantSearchCore/Helper/Decodable+JSON.swift @@ -0,0 +1,17 @@ +// +// Decodable+JSON.swift +// +// +// Created by Vladislav Fitc on 12/10/2020. +// + +import Foundation + +extension Decodable { + + init(json: JSON) throws { + let data = try JSONEncoder().encode(json) + self = try JSONDecoder().decode(Self.self, from: data) + } + +} diff --git a/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector+Controller.swift b/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector+Controller.swift new file mode 100644 index 00000000..17f81f0a --- /dev/null +++ b/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector+Controller.swift @@ -0,0 +1,112 @@ +// +// QueryRuleCustomDataConnector+Controller.swift +// +// +// Created by Vladislav Fitc on 10/10/2020. +// + +import Foundation + +public extension QueryRuleCustomDataConnector { + + /** + - Parameters: + - searcher: Searcher that handles your searches + - interactor: External custom data interactor + - controller: Controller interfacing with a concrete custom data view + - presenter: Presenter defining how a model appears in the controller + */ + convenience init(searcher: SingleIndexSearcher, + interactor: Interactor = .init(), + controller: Controller, + presenter: @escaping (Model?) -> Output) where Controller.Item == Output { + + self.init(searcher: searcher, interactor: interactor) + let controllerConnection = interactor.connectController(controller, presenter: presenter) + controllerConnections.append(controllerConnection) + } + + /** + - Parameters: + - searcher: Searcher that handles your searches. + - interactor: External custom data interactor + - controller: Controller interfacing with a concrete custom data view + */ + convenience init(searcher: SingleIndexSearcher, + interactor: Interactor = .init(), + controller: Controller) where Controller.Item == Model? { + self.init(searcher: searcher, interactor: interactor) + let controllerConnection = interactor.connectController(controller, presenter: { $0 }) + controllerConnections.append(controllerConnection) + } + +} + +public extension QueryRuleCustomDataConnector { + + /** + - Parameters: + - searcher: Searcher that handles your searches. + - queryIndex: Index of query from response of which the user data will be extracted + - interactor: External custom data interactor + - controller: Controller interfacing with a concrete custom data view + - presenter: Presenter defining how a model appears in the controller + */ + convenience init(searcher: MultiIndexSearcher, + queryIndex: Int, + interactor: Interactor = .init(), + controller: Controller, + presenter: @escaping (Model?) -> Output) where Controller.Item == Output { + + self.init(searcher: searcher, queryIndex: queryIndex, interactor: interactor) + let controllerConnection = interactor.connectController(controller, presenter: presenter) + controllerConnections = [controllerConnection] + } + + /** + - Parameters: + - searcher: Searcher that handles your searches. + - queryIndex: Index of query from response of which the user data will be extracted + - interactor: External custom data interactor + - controller: Controller interfacing with a concrete custom data view + */ + convenience init(searcher: MultiIndexSearcher, + queryIndex: Int, + interactor: Interactor = .init(), + controller: Controller) where Controller.Item == Model? { + self.init(searcher: searcher, queryIndex: queryIndex, interactor: interactor) + let controllerConnection = interactor.connectController(controller, presenter: { $0 }) + controllerConnections.append(controllerConnection) + } + +} + +public extension QueryRuleCustomDataConnector { + + /** + Establishes a connection with the controller + - Parameters: + - controller: Controller interfacing with a concrete custom data view + - presenter: Presenter defining how a model appears in the controller + - Returns: Established connection + */ + @discardableResult func connectController(_ controller: Controller, + presenter: @escaping (Model?) -> Output) -> QueryRuleCustomDataInteractor.ControllerConnection { + let connection = interactor.connectController(controller, presenter: presenter) + controllerConnections.append(connection) + return connection + } + + /** + Establishes a connection with the controller + - Parameters: + - controller: Controller interfacing with a concrete custom data view + - Returns: Established connection + */ + @discardableResult func connectController(_ controller: Controller) -> QueryRuleCustomDataInteractor.ControllerConnection { + let connection = interactor.connectController(controller, presenter: { $0 }) + controllerConnections.append(connection) + return connection + } + +} diff --git a/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector.swift b/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector.swift new file mode 100644 index 00000000..baebc4bf --- /dev/null +++ b/Sources/InstantSearchCore/QueryRuleCustomData/Connector/QueryRuleCustomDataConnector.swift @@ -0,0 +1,78 @@ +// +// QueryRuleCustomDataConnector.swift +// +// +// Created by Vladislav Fitc on 09/10/2020. +// + +import Foundation + +/// Component that displays custom data from rules. +/// +/// [Documentation](https://www.algolia.com/doc/api-reference/widgets/query-rule-custom-data/ios/) +public class QueryRuleCustomDataConnector { + + public typealias Interactor = QueryRuleCustomDataInteractor + + /// Logic applied to the custom model + public let interactor: Interactor + + /// Connection between hits interactor and searcher + public let searcherConnection: Connection + + /// Connections between interactor and controllers + public var controllerConnections: [Connection] + + internal init(interactor: Interactor, + connectSearcher: (Interactor) -> Connection) { + self.interactor = interactor + searcherConnection = connectSearcher(interactor) + controllerConnections = [] + searcherConnection.connect() + } + +} + +public extension QueryRuleCustomDataConnector { + + /** + - Parameters: + - searcher: Searcher that handles your searches + - interactor: External custom data interactor + */ + convenience init(searcher: SingleIndexSearcher, + interactor: Interactor = .init()) { + self.init(interactor: interactor) { + QueryRuleCustomDataInteractor.SingleIndexSearcherConnection(interactor: $0, searcher: searcher) + } + } + + /** + - Parameters: + - searcher: Searcher that handles your searches + - queryIndex: Index of query from response of which the user data will be extracted + - interactor: External custom data interactor + */ + convenience init(searcher: MultiIndexSearcher, + queryIndex: Int, + interactor: Interactor = .init()) { + self.init(interactor: interactor) { + QueryRuleCustomDataInteractor.MultiIndexSearcherConnection(interactor: $0, searcher: searcher, queryIndex: queryIndex) + } + } + +} + +extension QueryRuleCustomDataConnector: Connection { + + public func connect() { + searcherConnection.connect() + controllerConnections.forEach { $0.connect() } + } + + public func disconnect() { + searcherConnection.disconnect() + controllerConnections.forEach { $0.disconnect() } + } + +} diff --git a/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+MultiIndexSearcher.swift b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+MultiIndexSearcher.swift new file mode 100644 index 00000000..f4b2e4d0 --- /dev/null +++ b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+MultiIndexSearcher.swift @@ -0,0 +1,67 @@ +// +// QueryRuleCustomDataInteractor+MultiIndexSearcher.swift +// +// +// Created by Vladislav Fitc on 10/10/2020. +// + +import Foundation + +extension QueryRuleCustomDataInteractor { + + /// Connection between a rule custom data logic and a multi-index searcher + public struct MultiIndexSearcherConnection: Connection { + + /// Logic applied to the custom model + public let interactor: QueryRuleCustomDataInteractor + + /// Searcher that handles your searches + public let searcher: MultiIndexSearcher + + /// Index of query from response of which the user data will be extracted + public let queryIndex: Int + + /** + - Parameters: + - interactor: Interactor to connect + - searcher: Searcher to connect + - queryIndex: Index of query from response of which the user data will be extracted + */ + public init(interactor: QueryRuleCustomDataInteractor, + searcher: MultiIndexSearcher, + queryIndex: Int) { + self.searcher = searcher + self.queryIndex = queryIndex + self.interactor = interactor + } + + public func connect() { + searcher.onResults.subscribe(with: interactor) { (interactor, searchResponse) in + interactor.extractModel(from: searchResponse.results[queryIndex]) + } + } + + public func disconnect() { + searcher.onResults.cancelSubscription(for: interactor) + } + + } + +} + +public extension QueryRuleCustomDataInteractor { + + /** + - Parameters: + - searcher: Searcher to connect + - queryIndex: Index of query from response of which the user data will be extracted + */ + @discardableResult func connectSearcher(_ searcher: MultiIndexSearcher, + toQueryAtIndex queryIndex: Int) -> MultiIndexSearcherConnection { + let connection = MultiIndexSearcherConnection(interactor: self, searcher: searcher, queryIndex: queryIndex) + connection.connect() + return connection + } + +} + diff --git a/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+SingleIndexSearcher.swift b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+SingleIndexSearcher.swift new file mode 100644 index 00000000..8ab1d3e7 --- /dev/null +++ b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor+SingleIndexSearcher.swift @@ -0,0 +1,58 @@ +// +// QueryRuleCustomDataInteractor+SingleIndexSearcher.swift +// +// +// Created by Vladislav Fitc on 10/10/2020. +// + +import Foundation + +extension QueryRuleCustomDataInteractor { + + /// Connection between a rule custom data logic and a single index searcher + public struct SingleIndexSearcherConnection: Connection { + + /// Logic applied to the custom model + public let interactor: QueryRuleCustomDataInteractor + + /// Searcher that handles your searches + public let searcher: SingleIndexSearcher + + /** + - Parameters: + - interactor: Interactor to connect + - searcher: Searcher to connect + */ + public init(interactor: QueryRuleCustomDataInteractor, + searcher: SingleIndexSearcher) { + self.searcher = searcher + self.interactor = interactor + } + + public func connect() { + searcher.onResults.subscribe(with: interactor) { (interactor, searchResponse) in + interactor.extractModel(from: searchResponse) + } + } + + public func disconnect() { + searcher.onResults.cancelSubscription(for: interactor) + } + + } + +} + +public extension QueryRuleCustomDataInteractor { + + /** + - Parameters: + - searcher: Searcher to connect + */ + @discardableResult func connectSearcher(_ searcher: SingleIndexSearcher) -> SingleIndexSearcherConnection { + let connection = SingleIndexSearcherConnection(interactor: self, searcher: searcher) + connection.connect() + return connection + } + +} diff --git a/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor.swift b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor.swift new file mode 100644 index 00000000..070e78ed --- /dev/null +++ b/Sources/InstantSearchCore/QueryRuleCustomData/QueryRuleCustomDataInteractor.swift @@ -0,0 +1,44 @@ +// +// QueryRuleCustomDataInteractor.swift +// +// +// Created by Vladislav Fitc on 10/10/2020. +// + +import Foundation + +/// Component encapsulating the logic applied to the custom model +public class QueryRuleCustomDataInteractor: ItemInteractor { + + public override init(item: Model? = nil) { + super.init(item: item) + } + +} + +extension QueryRuleCustomDataInteractor { + + func extractModel(from searchResponse: SearchResponse) { + if let userData = searchResponse.userData, + let model = userData.compactMap({ try? Model(json: $0) }).first { + item = model + } else { + item = nil + } + } + +} + +public extension QueryRuleCustomDataInteractor { + + /** + Establishes a connection with the controller + - Parameters: + - controller: Controller interfacing with a concrete custom data view + - Returns: Established connection + */ + @discardableResult func connectController(_ controller: Controller) -> ItemInteractor.ControllerConnection { + super.connectController(controller, presenter: { $0 }) + } + +} diff --git a/Tests/InstantSearchCoreTests/Unit/QueryRuleCustomData/QueryRuleCustomDataSearcherConnectionTests.swift b/Tests/InstantSearchCoreTests/Unit/QueryRuleCustomData/QueryRuleCustomDataSearcherConnectionTests.swift new file mode 100644 index 00000000..af639b69 --- /dev/null +++ b/Tests/InstantSearchCoreTests/Unit/QueryRuleCustomData/QueryRuleCustomDataSearcherConnectionTests.swift @@ -0,0 +1,67 @@ +// +// QueryRuleCustomDataSearcherConnectionTests.swift +// +// +// Created by Vladislav Fitc on 12/10/2020. +// + +import Foundation +import XCTest +@testable import InstantSearchCore + +class QueryRuleCustomDataSearcherConnectionTests: XCTestCase { + + struct TestModel: Codable { + let number: Int + let text: String + } + + func testSingleIndexSearcherConnection() { + + let searcher = SingleIndexSearcher(appID: "", apiKey: "", indexName: "") + let interactor = QueryRuleCustomDataInteractor() + + interactor.connectSearcher(searcher) + + let response = SearchResponse().set(\.userData, to: [["number": 10, "text": "test"]]) + + let itemChangedExpectation = expectation(description: "Item changed") + + interactor.onItemChanged.subscribe(with: self) { (test, model) in + XCTAssertEqual(model?.number, 10) + XCTAssertEqual(model?.text, "test") + itemChangedExpectation.fulfill() + } + + searcher.onResults.fire(response) + + waitForExpectations(timeout: 10, handler: nil) + + } + + + func testMultiIndexSearcherConnection() { + + let searcher = MultiIndexSearcher(appID: "", apiKey: "", indexNames: ["a", "b"]) + let interactor = QueryRuleCustomDataInteractor() + + interactor.connectSearcher(searcher, toQueryAtIndex: 1) + + let response1 = SearchResponse().set(\.userData, to: [["number": 10, "text": "test1"]]) + let response2 = SearchResponse().set(\.userData, to: [["number": 20, "text": "test2"]]) + + let itemChangedExpectation = expectation(description: "Item changed") + + interactor.onItemChanged.subscribe(with: self) { (test, model) in + XCTAssertEqual(model?.number, 20) + XCTAssertEqual(model?.text, "test2") + itemChangedExpectation.fulfill() + } + + searcher.onResults.fire(.init(results: [response1, response2])) + + waitForExpectations(timeout: 10, handler: nil) + + } + +} diff --git a/Tests/InstantSearchTests/Snippets/QueryRuleCustomDataSnippets.swift b/Tests/InstantSearchTests/Snippets/QueryRuleCustomDataSnippets.swift new file mode 100644 index 00000000..1caa9f8d --- /dev/null +++ b/Tests/InstantSearchTests/Snippets/QueryRuleCustomDataSnippets.swift @@ -0,0 +1,66 @@ +// +// QueryRuleCustomDataSnippets.swift +// +// +// Created by Vladislav Fitc on 13/10/2020. +// + +import Foundation +import InstantSearch +#if canImport(UIKit) +import UIKit + +class QueryRuleCustomDataSnippets { + + struct Banner: Decodable { + let bannerURL: URL + } + + class BannerViewController: UIViewController, ItemController { + + let bannerView: UIImageView = .init() + + override func viewDidLoad() { + super.viewDidLoad() + // some layout code + } + + func setItem(_ item: Banner?) { + guard let bannerURL = item?.bannerURL else { + bannerView.image = nil + return + } + URLSession.shared.dataTask(with: bannerURL) { (data, _, _) in + self.bannerView.image = data.flatMap(UIImage.init) + }.resume() + } + + } + + func widgetExample() { + let searcher: SingleIndexSearcher = .init(appID: "YourApplicationID", + apiKey: "YourSearchOnlyAPIKey", + indexName: "YourIndexName") + let bannerViewController = BannerViewController() + let queryRuleCustomDataConnector = QueryRuleCustomDataConnector(searcher: searcher, + controller: bannerViewController) + + searcher.search() + + _ = queryRuleCustomDataConnector + + } + + func advancedExample() { + let searcher: SingleIndexSearcher = .init(appID: "YourApplicationID", + apiKey: "YourSearchOnlyAPIKey", + indexName: "YourIndexName") + let queryRuleCustomDataInteractor: QueryRuleCustomDataInteractor = .init() + let bannerViewController: BannerViewController = .init() + + queryRuleCustomDataInteractor.connectSearcher(searcher) + queryRuleCustomDataInteractor.connectController(bannerViewController) + } + +} +#endif diff --git a/Tests/InstantSearchTests/Snippets/RedirectGuideSnippets.swift b/Tests/InstantSearchTests/Snippets/RedirectGuideSnippets.swift new file mode 100644 index 00000000..c4ce21e6 --- /dev/null +++ b/Tests/InstantSearchTests/Snippets/RedirectGuideSnippets.swift @@ -0,0 +1,62 @@ +// +// RedirectGuideSnippets.swift +// +// +// Created by Vladislav Fitc on 13/10/2020. +// + +import Foundation +import InstantSearch + +class RedirectGuideSnippets { + + let index = SearchClient(appID: "", apiKey: "").index(withName: "") + + func addRedirectRule() { + + let rule = Rule(objectID: "a-rule-id") + .set(\.conditions, to: [ + .init(anchoring: .is, pattern: .literal("star wars")) + ]) + .set(\.consequence, to: Rule.Consequence() + .set(\.userData, to: ["redirect": "https://www.google.com/#q=star+wars"]) + ) + + index.saveRule(rule) { result in + if case .success(let response) = result { + print("Response: \(response)") + } + } + + } + + struct Redirect: Decodable { + let url: URL + } + + func redirectWidget() { + + let searcher: SingleIndexSearcher = .init(appID: "YourApplicationID", + apiKey: "YourSearchOnlyAPIKey", + indexName: "YourIndexName") + + let queryRuleCustomDataConnector = QueryRuleCustomDataConnector(searcher: searcher) + + queryRuleCustomDataConnector.interactor.onItemChanged.subscribe(with: self) { (_, redirect) in + if let redirectURL = redirect?.url { + /// perform redirect with URL + } + } + + } + + func configureIndex() { + index.setSettings(Settings().set(\.attributesForFaceting, to: ["query_terms"])) { result in + if case .success(let response) = result { + print("Response: \(response)") + } + } + } + + +}