Skip to content

Commit 91565f2

Browse files
author
Clément Le Provost
authored
New Searchable protocol & static User-Agent header (#321)
* [refact] `User-Agent` header now a static property This is required to abstract InstantSearch Core’s `Searcher` class from the `Index` class. Otherwise we cannot access the `Client` instance, therefore we cannot patch the user agent. By making it a global property, we allow patching from anywhere. There is now an `addUserAgent(…)` method that takes care of deduplication. Patching of headers is now done at the `Request` level, which means that users cannot accidentally erase the `User-Agent` header: they can only add to it. WARNING: Small backward-compatibility break since the instance property is no longer writable. But it was documented as for “internal purposes only”, so nobody should use it. * New `Searchable` protocol This unites all indices (`Index`, `MirroredIndex` and `OfflineIndex`) under a common protocol, which can be leveraged in InstantSearch Core to support searching on an offline index.
1 parent c85fa9f commit 91565f2

File tree

9 files changed

+160
-38
lines changed

9 files changed

+160
-38
lines changed

AlgoliaSearch.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
BC23A6FC1D63541500DF9034 /* Query.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7495A61A8E499B00B0263F /* Query.swift */; };
5252
BC23A6FD1D63541500DF9034 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC01E66C1CA43CEE0067670B /* Request.swift */; };
5353
BC23A6FE1D63541B00DF9034 /* BrowseIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC09F7761CAE743900ABB395 /* BrowseIterator.swift */; };
54+
BC28C4D71E5B5A0E00EFC4A0 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */; };
55+
BC28C4D81E5B5A0E00EFC4A0 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */; };
56+
BC28C4D91E5B5A0E00EFC4A0 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */; };
57+
BC28C4DA1E5B5A0E00EFC4A0 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */; };
58+
BC28C4DB1E5B5A0E00EFC4A0 /* Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */; };
5459
BC3DC7521E02DD7900862F5B /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC15FAC01E01C041005E3B56 /* NetworkReachability.swift */; };
5560
BC3DC7531E02DD7A00862F5B /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC15FAC01E01C041005E3B56 /* NetworkReachability.swift */; };
5661
BC3DC7541E02DD7B00862F5B /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC15FAC01E01C041005E3B56 /* NetworkReachability.swift */; };
@@ -220,6 +225,7 @@
220225
BC0A01631C9C19CD00CD4A7C /* QueryTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QueryTests.swift; sourceTree = "<group>"; };
221226
BC15FAC01E01C041005E3B56 /* NetworkReachability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = "<group>"; };
222227
BC23A6EC1D63539A00DF9034 /* AlgoliaSearch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AlgoliaSearch.framework; sourceTree = BUILT_PRODUCTS_DIR; };
228+
BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Searchable.swift; sourceTree = "<group>"; };
223229
BC4A7F381CB5308100AF1DCB /* AsyncOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = "<group>"; };
224230
BC4A7F3B1CB5373E00AF1DCB /* CancelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CancelTests.swift; sourceTree = "<group>"; };
225231
BC4BD2931D54E48900170ECC /* AlgoliaSearchOffline.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AlgoliaSearchOffline.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -381,6 +387,7 @@
381387
BC4DDF071DD12BCC004D9A6E /* PlacesQuery.swift */,
382388
5D7495A61A8E499B00B0263F /* Query.swift */,
383389
BC01E66C1CA43CEE0067670B /* Request.swift */,
390+
BC28C4D61E5B5A0E00EFC4A0 /* Searchable.swift */,
384391
BC4DDEF51DD10EBA004D9A6E /* Types.swift */,
385392
BC09F7751CAE742800ABB395 /* Helpers */,
386393
BC0A015F1C9BEDE000CD4A7C /* Offline */,
@@ -906,6 +913,7 @@
906913
BC4DDEFC1DD11527004D9A6E /* PlacesClient.swift in Sources */,
907914
BCD57D1F1D89947500C5DE68 /* DisjunctiveFaceting.swift in Sources */,
908915
BC4DDF021DD11697004D9A6E /* AbstractQuery.swift in Sources */,
916+
BC28C4D71E5B5A0E00EFC4A0 /* Searchable.swift in Sources */,
909917
BC4DDEF01DD10E8B004D9A6E /* AbstractClient.swift in Sources */,
910918
5D09E1DC1AC0773A00B799A6 /* Network.swift in Sources */,
911919
5D7495A21A8E277400B0263F /* Client.swift in Sources */,
@@ -951,6 +959,7 @@
951959
BC4DDEFD1DD11527004D9A6E /* PlacesClient.swift in Sources */,
952960
BCD57D201D89947500C5DE68 /* DisjunctiveFaceting.swift in Sources */,
953961
BC4DDF031DD11697004D9A6E /* AbstractQuery.swift in Sources */,
962+
BC28C4D81E5B5A0E00EFC4A0 /* Searchable.swift in Sources */,
954963
BC4DDEF11DD10E8B004D9A6E /* AbstractClient.swift in Sources */,
955964
5DB251591AAD9F2A00945339 /* Query.swift in Sources */,
956965
5D09E1DD1AC0773A00B799A6 /* Network.swift in Sources */,
@@ -996,6 +1005,7 @@
9961005
BC4DDF001DD11527004D9A6E /* PlacesClient.swift in Sources */,
9971006
BCD57D231D89947500C5DE68 /* DisjunctiveFaceting.swift in Sources */,
9981007
BC4DDF061DD11697004D9A6E /* AbstractQuery.swift in Sources */,
1008+
BC28C4DB1E5B5A0E00EFC4A0 /* Searchable.swift in Sources */,
9991009
BC4DDEF41DD10E8B004D9A6E /* AbstractClient.swift in Sources */,
10001010
BC23A6F41D63541500DF9034 /* AsyncOperation.swift in Sources */,
10011011
BC23A6F81D63541500DF9034 /* Helpers.swift in Sources */,
@@ -1023,6 +1033,7 @@
10231033
BC4BD2881D54E48900170ECC /* Network.swift in Sources */,
10241034
BC4DDF051DD11697004D9A6E /* AbstractQuery.swift in Sources */,
10251035
BCD57D271D89970D00C5DE68 /* OfflineIndex.swift in Sources */,
1036+
BC28C4DA1E5B5A0E00EFC4A0 /* Searchable.swift in Sources */,
10261037
BCD57D221D89947500C5DE68 /* DisjunctiveFaceting.swift in Sources */,
10271038
BC4BD2891D54E48900170ECC /* Client.swift in Sources */,
10281039
BC4BD2961D54E50000170ECC /* MirrorSettings.swift in Sources */,
@@ -1051,6 +1062,7 @@
10511062
BC4DDEFE1DD11527004D9A6E /* PlacesClient.swift in Sources */,
10521063
BCD57D211D89947500C5DE68 /* DisjunctiveFaceting.swift in Sources */,
10531064
BC4DDF041DD11697004D9A6E /* AbstractQuery.swift in Sources */,
1065+
BC28C4D91E5B5A0E00EFC4A0 /* Searchable.swift in Sources */,
10541066
BC4DDEF21DD10E8B004D9A6E /* AbstractClient.swift in Sources */,
10551067
BCD1F5661CC61D100006E227 /* Index.swift in Sources */,
10561068
BCD1F5611CC61CFE0006E227 /* AsyncOperation.swift in Sources */,

Source/AbstractClient.swift

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,33 @@ internal struct HostStatus {
102102
headers["X-Algolia-API-Key"] = _apiKey
103103
}
104104

105-
/// The list of libraries used by this client, passed in the `User-Agent` HTTP header of every request.
106-
/// It is initially set to contain only this API Client, but may be overridden to include other libraries.
105+
/// The list of libraries used by this client.
106+
///
107+
/// + Warning: Deprecated. Now a static property of the `Client` class. The instance properties are just an alias
108+
/// for the static property.
109+
///
110+
@available(*, deprecated: 4.8)
111+
@objc public var userAgents: [LibraryVersion] {
112+
get { return AbstractClient.userAgents }
113+
}
114+
115+
/// The list of libraries used by instances of this class.
116+
/// They are passed in the `User-Agent` HTTP header of every request.
117+
/// It is initially set to contain only this library and the OS, but may be overridden to include other libraries.
107118
///
108119
/// + WARNING: The user agent is crucial to proper statistics in your Algolia dashboard. Please leave it as is.
109-
/// This field is publicly exposed only for the sake of other Algolia libraries.
120+
/// This field is publicly exposed only for the sake of other Algolia libraries.
110121
///
111-
@objc public var userAgents: [LibraryVersion] = [] {
122+
@objc public private(set) static var userAgents: [LibraryVersion] = defaultUserAgents() {
112123
didSet {
113-
updateHeadersFromUserAgents()
124+
userAgentHeader = computeUserAgentHeader()
114125
}
115126
}
116-
private func updateHeadersFromUserAgents() {
117-
headers["User-Agent"] = userAgents.map({ return "\($0.name) (\($0.version))"}).joined(separator: "; ")
127+
/// Precomputed `User-Agent` header (cached for improved performance).
128+
internal private(set) static var userAgentHeader: String? = computeUserAgentHeader()
129+
130+
private static func computeUserAgentHeader() -> String {
131+
return userAgents.map({ return "\($0.name) (\($0.version))"}).joined(separator: "; ")
118132
}
119133

120134
/// Default timeout for network requests. Default: 30 seconds.
@@ -225,25 +239,8 @@ internal struct HostStatus {
225239

226240
super.init()
227241

228-
// Add this library's version to the user agents.
229-
let version = Bundle(for: type(of: self)).infoDictionary!["CFBundleShortVersionString"] as! String
230-
self.userAgents = [ LibraryVersion(name: "Algolia for Swift", version: version) ]
231-
232-
// Add the operating system's version to the user agents.
233-
if #available(iOS 8.0, OSX 10.0, tvOS 9.0, *) {
234-
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
235-
var osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion)"
236-
if osVersion.patchVersion != 0 {
237-
osVersionString += ".\(osVersion.patchVersion)"
238-
}
239-
if let osName = osName {
240-
self.userAgents.append(LibraryVersion(name: osName, version: osVersionString))
241-
}
242-
}
243-
244242
// WARNING: `didSet` not called during initialization => we need to update the headers manually here.
245243
updateHeadersFromAPIKey()
246-
updateHeadersFromUserAgents()
247244
}
248245

249246
/// Set read and write hosts to the same value (convenience method).
@@ -281,6 +278,41 @@ internal struct HostStatus {
281278
return headers[name]
282279
}
283280

281+
/// Compute the default user agents for this library.
282+
///
283+
/// - returns: Default user agents for this library.
284+
///
285+
private static func defaultUserAgents() -> [LibraryVersion] {
286+
// Add this library's version to the user agents.
287+
let version = Bundle(for: Client.self).infoDictionary!["CFBundleShortVersionString"] as! String
288+
var userAgents = [ LibraryVersion(name: "Algolia for Swift", version: version) ]
289+
290+
// Add the operating system's version to the user agents.
291+
if #available(iOS 8.0, OSX 10.0, tvOS 9.0, *) {
292+
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
293+
var osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion)"
294+
if osVersion.patchVersion != 0 {
295+
osVersionString += ".\(osVersion.patchVersion)"
296+
}
297+
if let osName = osName {
298+
userAgents.append(LibraryVersion(name: osName, version: osVersionString))
299+
}
300+
}
301+
return userAgents
302+
}
303+
304+
/// Add a library version to the global list of user agents.
305+
///
306+
/// + Note: It is safe to call this function multiple times. Adding an already existing library is a no-op.
307+
///
308+
/// - parameter libraryVersion: Library version to add.
309+
///
310+
@objc public static func addUserAgent(_ libraryVersion: LibraryVersion) {
311+
if userAgents.index(where: { $0 == libraryVersion }) == nil {
312+
userAgents.append(libraryVersion)
313+
}
314+
}
315+
284316
// MARK: - Operations
285317

286318
/// Ping the server.

Source/Index.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import Foundation
2727
///
2828
/// + Note: You cannot construct this class directly. Please use `Client.index(withName:)` to obtain an instance.
2929
///
30-
@objc public class Index : NSObject {
30+
@objc public class Index : NSObject, Searchable {
3131
// MARK: Properties
3232

3333
/// This index's name.

Source/Offline/OfflineClient.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ typealias APIResponse = (content: JSONObject?, error: Error?)
7373

7474
// MARK: Initialization
7575

76+
// Fake global property to act as static initializer. This is the recommended (and only, AFAIK) way, as per the doc.
77+
// See <http://stackoverflow.com/a/37887068/5838753>
78+
private static let _initUserAgent: Void = {
79+
addUserAgent(LibraryVersion(name: "AlgoliaSearchOfflineCore-iOS", version: Sdk.shared.versionString))
80+
}()
81+
7682
/// Create a new offline-capable Algolia Search client.
7783
///
7884
/// + Note: Offline mode is disabled by default, until you call `enableOfflineMode(...)`.
@@ -90,7 +96,8 @@ typealias APIResponse = (content: JSONObject?, error: Error?)
9096
tmpDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("algolia").path
9197
super.init(appID: appID, apiKey: apiKey)
9298
mixedRequestQueue.maxConcurrentOperationCount = super.requestQueue.maxConcurrentOperationCount
93-
userAgents.append(LibraryVersion(name: "AlgoliaSearchOfflineCore-iOS", version: sdk.versionString))
99+
// IMPORTANT: Update user agent. This will only be invoked once (static property).
100+
OfflineClient._initUserAgent
94101
}
95102

96103
/// Enable the offline mode.

Source/Offline/OfflineIndex.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public struct IOError: CustomNSError {
130130
/// - You cannot batch arbitrary write operations in a single method call (as you would do with `Index.batch(...)`).
131131
/// However, all write operations are *de facto* batches, since they must be wrapped inside a transaction (see below).
132132
///
133-
@objc public class OfflineIndex : NSObject {
133+
@objc public class OfflineIndex : NSObject, Searchable {
134134
// TODO: Expose common behavior through a protocol.
135135
// TODO: Factorize common behavior in a base class.
136136

Source/Request.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ internal class Request: AsyncOperationWithCompletion {
7474
self.nextHostIndex = firstHostIndex
7575
assert(firstHostIndex < hosts.count)
7676
self.path = path
77-
self.headers = headers
77+
// IMPORTANT: Enforce the `User-Agent` header on all requests.
78+
var patchedHeaders = headers ?? [:]
79+
patchedHeaders["User-Agent"] = AbstractClient.userAgentHeader
80+
self.headers = patchedHeaders
7881
self.jsonBody = jsonBody
7982
assert(jsonBody == nil || (method == .POST || method == .PUT))
8083
self.timeout = timeout

Source/Searchable.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//
2+
// Copyright (c) 2016 Algolia
3+
// http://www.algolia.com/
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in
13+
// all copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
// THE SOFTWARE.
22+
//
23+
24+
import Foundation
25+
26+
27+
/// A searchable source of data.
28+
///
29+
@objc public protocol Searchable: class {
30+
31+
/// Perform a search.
32+
///
33+
/// - parameter query: Search parameters.
34+
/// - parameter completionHandler: Completion handler to be notified of the request's outcome.
35+
/// - returns: A cancellable operation.
36+
///
37+
@objc
38+
@discardableResult func search(_ query: Query, completionHandler: @escaping CompletionHandler) -> Operation
39+
40+
/// Perform a search with disjunctive facets, generating as many queries as number of disjunctive facets (helper).
41+
///
42+
/// - parameter query: The query.
43+
/// - parameter disjunctiveFacets: List of disjunctive facets.
44+
/// - parameter refinements: The current refinements, mapping facet names to a list of values.
45+
/// - parameter completionHandler: Completion handler to be notified of the request's outcome.
46+
/// - returns: A cancellable operation.
47+
///
48+
@objc
49+
@discardableResult func searchDisjunctiveFaceting(_ query: Query, disjunctiveFacets: [String], refinements: [String: [String]], completionHandler: @escaping CompletionHandler) -> Operation
50+
51+
52+
/// Search for facet values.
53+
/// This searches inside a facet's values, optionally restricting the returned values to those contained in objects
54+
/// matching other (regular) search criteria.
55+
///
56+
/// - parameter facetName: Name of the facet to search. It must have been declared in the index's
57+
/// `attributesForFaceting` setting with the `searchable()` modifier.
58+
/// - parameter text: Text to search for in the facet's values.
59+
/// - parameter query: An optional query to take extra search parameters into account. These parameters apply to
60+
/// index objects like in a regular search query. Only facet values contained in the matched objects will be
61+
/// returned.
62+
/// - parameter completionHandler: Completion handler to be notified of the request's outcome.
63+
/// - returns: A cancellable operation.
64+
///
65+
@discardableResult
66+
@objc(searchForFacetValuesOf:matching:query:completionHandler:)
67+
func searchForFacetValues(of facetName: String, matching text: String, query: Query?, completionHandler: @escaping CompletionHandler) -> Operation
68+
}

Tests/ClientTests.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -374,18 +374,21 @@ class ClientTests: OnlineTestCase {
374374

375375
func testUserAgentHeader() {
376376
// Test that the initial value of the header is correct.
377-
XCTAssert(client.headers["User-Agent"]?.range(of: "^Algolia for Swift \\([0-9.]+\\); (iOS|macOS|tvOS) \\([0-9.]+\\)$", options: .regularExpression) != nil)
377+
NSLog("User-Agent 1: \(Client.userAgentHeader!)")
378+
XCTAssert(Client.userAgentHeader!.range(of: "^Algolia for Swift \\([0-9.]+\\); (iOS|macOS|tvOS) \\([0-9.]+\\)$", options: .regularExpression) != nil)
378379

379380
// Test equality comparison on the `LibraryVersion` class.
380381
XCTAssertEqual(LibraryVersion(name: "XYZ", version: "7.8.9"), LibraryVersion(name: "XYZ", version: "7.8.9"))
381382
XCTAssertNotEqual(LibraryVersion(name: "XYZ", version: "7.8.9"), LibraryVersion(name: "XXX", version: "6.6.6"))
382383

383-
// Test that changing the user agents results in a proper format.
384-
client.userAgents = [
385-
LibraryVersion(name: "ABC", version: "1.2.3"),
386-
LibraryVersion(name: "DEF", version: "4.5.6")
387-
]
388-
XCTAssertEqual(client.headers["User-Agent"], "ABC (1.2.3); DEF (4.5.6)")
384+
// Test adding a user agent.
385+
Client.addUserAgent(LibraryVersion(name: "ABC", version: "1.2.3"))
386+
let userAgentHeader = Client.userAgentHeader!
387+
NSLog("User-Agent 2: \(Client.userAgentHeader!)")
388+
XCTAssert(Client.userAgentHeader!.range(of: "^Algolia for Swift \\([0-9.]+\\); (iOS|macOS|tvOS) \\([0-9.]+\\); ABC \\(1.2.3\\)$", options: .regularExpression) != nil)
389+
// Test that adding the same user agent a second time is a no-op.
390+
Client.addUserAgent(LibraryVersion(name: "ABC", version: "1.2.3"))
391+
XCTAssert(Client.userAgentHeader! == userAgentHeader)
389392
}
390393

391394
func testReusingIndices() {

Tests/ObjectiveCBridging.m

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,6 @@ - (void)testClient {
141141
[headers setValue:@"baz" forKey:@"Foo-Bar"];
142142
client.headers = headers;
143143

144-
// User agents.
145-
client.userAgents = [client.userAgents arrayByAddingObject:[[LibraryVersion alloc] initWithName:@"FooBar" version:@"1.2.3"]];
146-
147144
// Timeouts.
148145
client.timeout = client.timeout + 10;
149146
client.searchTimeout = client.searchTimeout + 5;

0 commit comments

Comments
 (0)