Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit 87eba25

Browse files
authored
Add Remote Feature Flag Params to Dashboard Cards endpoint (#674)
2 parents 32b5a9f + 432a336 commit 87eba25

File tree

8 files changed

+223
-34
lines changed

8 files changed

+223
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ _None._
3434

3535
### Breaking Changes
3636

37-
_None._
37+
- Add `deviceId` param to `DashboardServiceRemote.fetch` method. [#674]
3838

3939
### New Features
4040

WordPressKit.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,7 @@
612612
F3FF8A25279C960F00E5C90F /* site-email-followers-get-auth-failure.json in Resources */ = {isa = PBXBuildFile; fileRef = F3FF8A24279C960F00E5C90F /* site-email-followers-get-auth-failure.json */; };
613613
F3FF8A27279C967200E5C90F /* site-email-followers-get-failure.json in Resources */ = {isa = PBXBuildFile; fileRef = F3FF8A26279C967200E5C90F /* site-email-followers-get-failure.json */; };
614614
F3FF8A29279C991B00E5C90F /* site-email-followers-get-success-more-pages.json in Resources */ = {isa = PBXBuildFile; fileRef = F3FF8A28279C991B00E5C90F /* site-email-followers-get-success-more-pages.json */; };
615+
F41D98EA2B48602B004EC050 /* SessionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41D98E92B48602B004EC050 /* SessionDetails.swift */; };
615616
F4B0F4732ACAF498003ABC61 /* DomainsServiceRemote+AllDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B0F4722ACAF498003ABC61 /* DomainsServiceRemote+AllDomains.swift */; };
616617
F4B0F47C2ACB4B74003ABC61 /* get-all-domains-response.json in Resources */ = {isa = PBXBuildFile; fileRef = F4B0F47B2ACB4B74003ABC61 /* get-all-domains-response.json */; };
617618
F4B0F4802ACB4EA9003ABC61 /* AllDomainsResultDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B0F47F2ACB4EA9003ABC61 /* AllDomainsResultDomainTests.swift */; };
@@ -1320,6 +1321,7 @@
13201321
F3FF8A24279C960F00E5C90F /* site-email-followers-get-auth-failure.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-email-followers-get-auth-failure.json"; sourceTree = "<group>"; };
13211322
F3FF8A26279C967200E5C90F /* site-email-followers-get-failure.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-email-followers-get-failure.json"; sourceTree = "<group>"; };
13221323
F3FF8A28279C991B00E5C90F /* site-email-followers-get-success-more-pages.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-email-followers-get-success-more-pages.json"; sourceTree = "<group>"; };
1324+
F41D98E92B48602B004EC050 /* SessionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionDetails.swift; sourceTree = "<group>"; };
13231325
F4B0F4722ACAF498003ABC61 /* DomainsServiceRemote+AllDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DomainsServiceRemote+AllDomains.swift"; sourceTree = "<group>"; };
13241326
F4B0F47B2ACB4B74003ABC61 /* get-all-domains-response.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "get-all-domains-response.json"; sourceTree = "<group>"; };
13251327
F4B0F47F2ACB4EA9003ABC61 /* AllDomainsResultDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllDomainsResultDomainTests.swift; sourceTree = "<group>"; };
@@ -2099,6 +2101,7 @@
20992101
FEF7419C28085D89002C4203 /* RemoteBloggingPrompt.swift */,
21002102
FE20A6A3282A96C00025E975 /* RemoteBloggingPromptsSettings.swift */,
21012103
1DAC3D2529AF4F250068FE13 /* RemoteVideoPressVideo.swift */,
2104+
F41D98E92B48602B004EC050 /* SessionDetails.swift */,
21022105
);
21032106
name = Models;
21042107
sourceTree = "<group>";
@@ -3381,6 +3384,7 @@
33813384
82FFBF501F45EFD100F4573F /* RemoteBlogJetpackSettings.swift in Sources */,
33823385
74650F741F0EA1E200188EDB /* RemoteGravatarProfile.swift in Sources */,
33833386
40E7FEB4221063480032834E /* StatsTodayInsight.swift in Sources */,
3387+
F41D98EA2B48602B004EC050 /* SessionDetails.swift in Sources */,
33843388
436D563C2118E18D00CEAA33 /* WPState.swift in Sources */,
33853389
439A44DA2107C93000795ED7 /* RemotePlan_ApiVersion1_3.swift in Sources */,
33863390
93BD27811EE73944002BB00B /* WordPressOrgXMLRPCApi.swift in Sources */,

WordPressKit/DashboardServiceRemote.swift

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import Foundation
22

33
open class DashboardServiceRemote: ServiceRemoteWordPressComREST {
4-
open func fetch(cards: [String], forBlogID blogID: Int, success: @escaping (NSDictionary) -> Void, failure: @escaping (Error) -> Void) {
5-
guard let requestUrl = endpoint(for: cards, blogID: blogID) else {
6-
return
4+
open func fetch(
5+
cards: [String],
6+
forBlogID blogID: Int,
7+
deviceId: String,
8+
success: @escaping (NSDictionary) -> Void,
9+
failure: @escaping (Error) -> Void
10+
) {
11+
let requestUrl = self.path(forEndpoint: "sites/\(blogID)/dashboard/cards-data/", withVersion: ._2_0)
12+
var params: [String: AnyObject]?
13+
14+
do {
15+
params = try self.makeQueryParams(cards: cards, deviceId: deviceId)
16+
} catch {
17+
failure(error)
718
}
819

920
wordPressComRestApi.GET(requestUrl,
10-
parameters: nil,
21+
parameters: params,
1122
success: { response, _ in
1223
guard let cards = response as? NSDictionary else {
1324
failure(ResponseError.decodingFailure)
@@ -21,17 +32,14 @@ open class DashboardServiceRemote: ServiceRemoteWordPressComREST {
2132
})
2233
}
2334

24-
private func endpoint(for cards: [String], blogID: Int) -> String? {
25-
var path = URLComponents(string: "sites/\(blogID)/dashboard/cards-data/")
26-
27-
let cardsEncoded = cards.joined(separator: ",")
28-
path?.queryItems = [URLQueryItem(name: "cards", value: cardsEncoded)]
29-
30-
guard let endpoint = path?.string else {
31-
return nil
35+
private func makeQueryParams(cards: [String], deviceId: String) throws -> [String: AnyObject] {
36+
let cardsParams: [String: AnyObject] = [
37+
"cards": cards.joined(separator: ",") as NSString
38+
]
39+
let featureFlagParams = try SessionDetails(deviceId: deviceId).dictionaryRepresentation()
40+
return cardsParams.merging(featureFlagParams ?? [:]) { first, second in
41+
return first
3242
}
33-
34-
return self.path(forEndpoint: endpoint, withVersion: ._2_0)
3543
}
3644

3745
enum ResponseError: Error {

WordPressKit/FeatureFlagRemote.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@ open class FeatureFlagRemote: ServiceRemoteWordPressComREST {
99
}
1010

1111
open func getRemoteFeatureFlags(forDeviceId deviceId: String, callback: @escaping FeatureFlagResponseCallback) {
12-
12+
let params = SessionDetails(deviceId: deviceId)
1313
let endpoint = "mobile/feature-flags"
1414
let path = self.path(forEndpoint: endpoint, withVersion: ._2_0)
15+
var dictionary: [String: AnyObject]?
1516

16-
let parameters: [String: AnyObject] = [
17-
"device_id": deviceId as NSString,
18-
"platform": "ios" as NSString,
19-
"build_number": NSString(string: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"),
20-
"marketing_version": NSString(string: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"),
21-
"identifier": NSString(string: Bundle.main.bundleIdentifier ?? "Unknown")
22-
]
17+
do {
18+
dictionary = try params.dictionaryRepresentation()
19+
} catch let error {
20+
callback(.failure(error))
21+
return
22+
}
2323

2424
wordPressComRestApi.GET(path,
25-
parameters: parameters,
25+
parameters: dictionary,
2626
success: { response, _ in
2727

2828
if let featureFlagList = response as? NSDictionary {

WordPressKit/SessionDetails.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
public struct SessionDetails {
2+
let deviceId: String
3+
let platform: String
4+
let buildNumber: String
5+
let marketingVersion: String
6+
let identifier: String
7+
}
8+
9+
extension SessionDetails: Encodable {
10+
11+
enum CodingKeys: String, CodingKey {
12+
case deviceId = "device_id"
13+
case platform = "platform"
14+
case buildNumber = "build_number"
15+
case marketingVersion = "marketing_version"
16+
case identifier = "identifier"
17+
}
18+
19+
init(deviceId: String, bundle: Bundle = .main) {
20+
self.deviceId = deviceId
21+
self.platform = "ios"
22+
self.buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
23+
self.marketingVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
24+
self.identifier = bundle.bundleIdentifier ?? "Unknown"
25+
}
26+
27+
func dictionaryRepresentation() throws -> [String: AnyObject]? {
28+
let encoder = JSONEncoder()
29+
let data = try encoder.encode(self)
30+
return try JSONSerialization.jsonObject(with: data) as? [String: AnyObject]
31+
}
32+
}

WordPressKitTests/DashboardServiceRemoteTests.swift

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import XCTest
2+
import OHHTTPStubs
23

34
@testable import WordPressKit
45

@@ -13,12 +14,37 @@ class DashboardServiceRemoteTests: RemoteTestCase, RESTTestable {
1314
// Requests the correct set of cards
1415
//
1516
func testRequestCardsParam() {
16-
let expect = expectation(description: "Get cards successfully")
17-
stubRemoteResponse("wpcom/v2/sites/165243437/dashboard/cards-data/?cards=posts,todays_stats", filename: "dashboard-200-with-drafts-and-scheduled-posts.json", contentType: .ApplicationJSON)
18-
19-
dashboardServiceRemote.fetch(cards: ["posts", "todays_stats"], forBlogID: 165243437) { _ in
17+
let expect = expectation(description: "Dashboard endpoint should contain query params")
18+
let expectedPath = "/wpcom/v2/sites/165243437/dashboard/cards-data"
19+
let expectedQueryParams: Set<String> = [
20+
"identifier",
21+
"platform",
22+
"build_number",
23+
"marketing_version",
24+
"device_id",
25+
"cards",
26+
"locale"
27+
]
28+
29+
stubRemoteResponse({ req in
30+
let url = req.url?.absoluteString ?? ""
31+
let containsQueryParams = self.queryParams(expectedQueryParams, containedInRequest: req)
32+
let matchesPath = isPath(expectedPath)(req)
33+
XCTAssertTrue(matchesPath, "The URL '\(url)' doesn't match the expected path.")
34+
XCTAssertTrue(containsQueryParams, "The URL '\(url)' doesn't contain the expected query params.")
35+
return containsQueryParams && matchesPath
36+
}, filename: "dashboard-200-with-drafts-and-scheduled-posts.json", contentType: .ApplicationJSON)
37+
38+
dashboardServiceRemote.fetch(
39+
cards: ["posts", "todays_stats"],
40+
forBlogID: 165243437,
41+
deviceId: "Test"
42+
) { _ in
2043
expect.fulfill()
21-
} failure: { _ in }
44+
} failure: { error in
45+
XCTFail("Dashboard cards request failed: \(error.localizedDescription)")
46+
expect.fulfill()
47+
}
2248

2349
waitForExpectations(timeout: timeout, handler: nil)
2450
}
@@ -27,9 +53,18 @@ class DashboardServiceRemoteTests: RemoteTestCase, RESTTestable {
2753
//
2854
func testRequestCards() {
2955
let expect = expectation(description: "Get cards successfully")
30-
stubRemoteResponse("wpcom/v2/sites/165243437/dashboard/cards-data/?cards=posts,todays_stats", filename: "dashboard-200-with-drafts-and-scheduled-posts.json", contentType: .ApplicationJSON)
3156

32-
dashboardServiceRemote.fetch(cards: ["posts", "todays_stats"], forBlogID: 165243437) { cards in
57+
stubRemoteResponse(
58+
isPath("/wpcom/v2/sites/165243437/dashboard/cards-data"),
59+
filename: "dashboard-200-with-drafts-and-scheduled-posts.json",
60+
contentType: .ApplicationJSON
61+
)
62+
63+
dashboardServiceRemote.fetch(
64+
cards: ["posts", "todays_stats"],
65+
forBlogID: 165243437,
66+
deviceId: "Test"
67+
) { cards in
3368
XCTAssertTrue((cards["posts"] as! NSDictionary)["has_published"] as! Bool)
3469
XCTAssertEqual((cards["todays_stats"] as! NSDictionary)["views"] as! Int, 0)
3570
expect.fulfill()
@@ -44,7 +79,11 @@ class DashboardServiceRemoteTests: RemoteTestCase, RESTTestable {
4479
let expect = expectation(description: "Get cards successfully")
4580
stubRemoteResponse("wpcom/v2/sites/165243437/dashboard/cards-data/?cards=posts,todays_stats", filename: "dashboard-200-with-drafts-and-scheduled-posts.json", contentType: .ApplicationJSON, status: 503)
4681

47-
dashboardServiceRemote.fetch(cards: ["posts", "todays_stats"], forBlogID: 165243437) { _ in
82+
dashboardServiceRemote.fetch(
83+
cards: ["posts", "todays_stats"],
84+
forBlogID: 165243437,
85+
deviceId: "Test"
86+
) { _ in
4887
XCTFail("This call should not suceed")
4988
} failure: { error in
5089
expect.fulfill()
@@ -59,7 +98,11 @@ class DashboardServiceRemoteTests: RemoteTestCase, RESTTestable {
5998
let expect = expectation(description: "Get cards successfully")
6099
stubRemoteResponse("wpcom/v2/sites/165243437/dashboard/cards-data/?cards=invalid_card", filename: "dashboard-400-invalid-card.json", contentType: .ApplicationJSON, status: 400)
61100

62-
dashboardServiceRemote.fetch(cards: ["invalid_card"], forBlogID: 165243437) { _ in
101+
dashboardServiceRemote.fetch(
102+
cards: ["invalid_card"],
103+
forBlogID: 165243437,
104+
deviceId: "Test"
105+
) { _ in
63106
XCTFail("This call should not suceed")
64107
} failure: { error in
65108
expect.fulfill()
@@ -74,7 +117,11 @@ class DashboardServiceRemoteTests: RemoteTestCase, RESTTestable {
74117
let expect = expectation(description: "Get cards successfully")
75118
stubRemoteResponse("wpcom/v2/sites/165243437/dashboard/cards-data/?cards=posts,todays_stats", data: "foo".data(using: .utf8)!, contentType: .ApplicationJSON)
76119

77-
dashboardServiceRemote.fetch(cards: ["posts", "todays_stats"], forBlogID: 165243437) { _ in
120+
dashboardServiceRemote.fetch(
121+
cards: ["posts", "todays_stats"],
122+
forBlogID: 165243437,
123+
deviceId: "Test"
124+
) { _ in
78125
XCTFail("This call should not suceed")
79126
} failure: { error in
80127
expect.fulfill()

WordPressKitTests/RemoteTestCase.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ class RemoteTestCase: XCTestCase {
4040
//
4141
extension RemoteTestCase {
4242

43+
/// Helper function that creates a stub which uses a file for the response body.
44+
///
45+
/// - Parameters:
46+
/// - condition: The endpoint matcher block that determines if the request will be stubbed
47+
/// - filename: The name of the file to use for the response
48+
/// - contentType: The Content-Type returned in the response header
49+
/// - status: The status code to use for the response. Defaults to 200.
50+
///
51+
func stubRemoteResponse(
52+
_ condition: @escaping (URLRequest) -> Bool,
53+
filename: String,
54+
contentType: ResponseContentType,
55+
status: Int32 = 200
56+
) {
57+
stub(condition: condition) { _ in
58+
let stubPath = OHPathForFile(filename, type(of: self))
59+
var headers: [NSObject: AnyObject]?
60+
61+
if contentType != .NoContentType {
62+
headers = ["Content-Type" as NSObject: contentType.rawValue as AnyObject]
63+
}
64+
return OHHTTPStubs.fixture(filePath: stubPath!, status: status, headers: headers)
65+
}
66+
}
67+
4368
/// Helper function that creates a stub which uses a file for the response body.
4469
///
4570
/// - Parameters:
@@ -160,4 +185,34 @@ extension RemoteTestCase {
160185
print("Unable to clear cache: \(error)")
161186
}
162187
}
188+
189+
/// Checks if the specified set of query parameter names are all present in a given `URLRequest`.
190+
/// This method verifies the presence of query parameter names in the request's URL without evaluating their values.
191+
///
192+
/// - Parameters:
193+
/// - queryParams: A set of query parameter names to check for in the request.
194+
/// - request: The `URLRequest` to inspect for the presence of query parameter names.
195+
/// - Returns: A Boolean value indicating whether all specified query parameter names are present in the request's URL.
196+
func queryParams(_ queryParams: Set<String>, containedInRequest request: URLRequest) -> Bool {
197+
guard let url = request.url else {
198+
return false
199+
}
200+
return queryParamsContained(queryParams, containedInURL: url)
201+
}
202+
203+
/// Checks if the specified set of query parameter names are all present in a given `URL`.
204+
/// This method verifies the presence of query parameter names in the URL's query string without evaluating their values.
205+
///
206+
/// - Parameters:
207+
/// - queryParams: A set of query parameter names to check for in the URL.
208+
/// - url: The `URL` to inspect for the presence of query parameter names.
209+
/// - Returns: A Boolean value indicating whether all specified query parameter names are present in the URL's query string.
210+
func queryParamsContained(_ queryParams: Set<String>, containedInURL url: URL) -> Bool {
211+
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
212+
let queryItems = components.queryItems?.map({ $0.name })
213+
else {
214+
return false
215+
}
216+
return queryParams.intersection(queryItems) == queryParams
217+
}
163218
}

WordPressKitTests/Utilities/FeatureFlagRemoteTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
import XCTest
2+
import OHHTTPStubs
23
@testable import WordPressKit
34

45
class FeatureFlagRemoteTests: RemoteTestCase, RESTTestable {
56

67
private let endpoint = "/wpcom/v2/mobile/feature-flags"
78

9+
func testThatRequestContainsQueryParams() throws {
10+
let expectation = expectation(description: "Get Remote Feature Flags Endpoint should contain query params")
11+
12+
let response = try makeResponse()
13+
let expectedQueryParams: Set<String> = [
14+
"identifier",
15+
"platform",
16+
"build_number",
17+
"marketing_version",
18+
"device_id"
19+
]
20+
21+
stub { req -> Bool in
22+
let containsQueryParams = self.queryParams(expectedQueryParams, containedInRequest: req)
23+
let matchesPath = isPath(self.endpoint)(req)
24+
let matchesURL = containsQueryParams && matchesPath
25+
XCTAssertTrue(matchesURL)
26+
return matchesURL
27+
} response: { request in
28+
return response
29+
}
30+
31+
FeatureFlagRemote(wordPressComRestApi: getRestApi()).getRemoteFeatureFlags(forDeviceId: "Test") { _ in
32+
expectation.fulfill()
33+
}
34+
35+
wait(for: [expectation], timeout: 1)
36+
}
37+
838
func testThatResponsesAreHandledCorrectly() throws {
939
let flags = [
1040
FeatureFlag(title: UUID().uuidString, value: true),
@@ -78,4 +108,17 @@ class FeatureFlagRemoteTests: RemoteTestCase, RESTTestable {
78108
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
79109
return try encoder.encode(object)
80110
}
111+
112+
private func makeResponse() throws -> HTTPStubsResponse {
113+
return try XCTUnwrap({
114+
let flags = [
115+
FeatureFlag(title: UUID().uuidString, value: true),
116+
FeatureFlag(title: UUID().uuidString, value: false)
117+
]
118+
guard let data = try? JSONEncoder().encode(flags.dictionaryValue) else {
119+
return nil
120+
}
121+
return HTTPStubsResponse(data: data, statusCode: 200, headers: [:])
122+
}())
123+
}
81124
}

0 commit comments

Comments
 (0)