Skip to content

Commit 1eff175

Browse files
authored
Merge pull request #444 from Countly/content_post
feat: content post support
2 parents 3f638a1 + 56b97f9 commit 1eff175

6 files changed

Lines changed: 487 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
## XX.XX.XX
1+
## XX.XX.XX
2+
* Added POST method support for contents.
23
* Added robust resource loading checks before displaying content
34
* Added Content feature method `previewContent:(NSString *)contentId` (Experimental!).
45
* Improved refreshing contents.

Countly.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
965A2E9D2DDDCDAC00F28F6A /* CountlyHealthTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = 965A2E9A2DDDCDAC00F28F6A /* CountlyHealthTracker.h */; };
9393
9673567F2EC60CD400C742D8 /* TestURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9673567E2EC60CD400C742D8 /* TestURLProtocol.swift */; };
9494
968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */; };
95+
96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */; };
9596
9691B7652F1FA35500A6ADCB /* CountlyOverlayWindow.h in Headers */ = {isa = PBXBuildFile; fileRef = 9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */; };
9697
9691B7672F1FA35C00A6ADCB /* CountlyOverlayWindow.m in Sources */ = {isa = PBXBuildFile; fileRef = 9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */; };
9798
969E5BCE2ECC4D3200AB406A /* CountlyConsentManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */; };
@@ -216,6 +217,7 @@
216217
96681A9B2D97D9B300A4845A /* CountlyTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CountlyTests.xctestplan; sourceTree = "<group>"; };
217218
9673567E2EC60CD400C742D8 /* TestURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLProtocol.swift; sourceTree = "<group>"; };
218219
968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConnectionManagerTests.swift; sourceTree = "<group>"; };
220+
96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyContentBuilderTests.swift; sourceTree = "<group>"; };
219221
9691B7642F1FA35500A6ADCB /* CountlyOverlayWindow.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CountlyOverlayWindow.h; sourceTree = "<group>"; };
220222
9691B7662F1FA35C00A6ADCB /* CountlyOverlayWindow.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CountlyOverlayWindow.m; sourceTree = "<group>"; };
221223
969E5BCD2ECC4D2C00AB406A /* CountlyConsentManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountlyConsentManagerTests.swift; sourceTree = "<group>"; };
@@ -270,6 +272,7 @@
270272
1A50D7042B3C5AA3009C6938 /* CountlyBaseTestCase.swift */,
271273
1AFD79012B3EF82C00772FBD /* CountlyTests-Bridging-Header.h */,
272274
968426802BF2302C007B303E /* CountlyConnectionManagerTests.swift */,
275+
96CB10A02F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift */,
273276
96E680412BFF89AC0091E105 /* CountlyCrashReporterTests.swift */,
274277
EBD1642D826B471A80175BE3 /* CountlyWebViewManagerTests.swift */,
275278
265156CB5F59417EA14BAC2F /* CountlyWebViewManager+Tests.h */,
@@ -551,6 +554,7 @@
551554
1A50D7052B3C5AA3009C6938 /* CountlyBaseTestCase.swift in Sources */,
552555
3979E47D2C0760E900FA1CA4 /* CountlyUserProfileTests.swift in Sources */,
553556
968426812BF2302C007B303E /* CountlyConnectionManagerTests.swift in Sources */,
557+
96CB10A12F04B3A100D1E2F0 /* CountlyContentBuilderTests.swift in Sources */,
554558
96329DE42D952F1500BFD641 /* MockURLProtocol.swift in Sources */,
555559
96329DE02D9426F300BFD641 /* CountlyServerConfigTests.swift in Sources */,
556560
3966DBCF2C11EE270002ED97 /* CountlyDeviceIDTests.swift in Sources */,

CountlyContentBuilderInternal.m

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,21 @@ - (NSURLRequest *)fetchContentsRequest:(NSString *)contentId
267267

268268
queryString = [CountlyConnectionManager.sharedInstance appendChecksum:queryString];
269269

270-
NSString *URLString = [NSString stringWithFormat:@"%@%@?%@", CountlyConnectionManager.sharedInstance.host, kCountlyEndpointContent, queryString];
270+
NSString *contentEndpoint = [NSString stringWithFormat:@"%@%@", CountlyConnectionManager.sharedInstance.host, kCountlyEndpointContent];
271271

272-
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:URLString]];
273-
return request;
272+
if (queryString.length > kCountlyGETRequestMaxLength || CountlyConnectionManager.sharedInstance.alwaysUsePOST)
273+
{
274+
NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:contentEndpoint]];
275+
request.HTTPMethod = @"POST";
276+
request.HTTPBody = [queryString cly_dataUTF8];
277+
return request.copy;
278+
}
279+
else
280+
{
281+
NSString* withQueryString = [contentEndpoint stringByAppendingFormat:@"?%@", queryString];
282+
NSURLRequest* request = [NSURLRequest requestWithURL:[NSURL URLWithString:withQueryString]];
283+
return request;
284+
}
274285
}
275286

276287
- (NSString *)resolutionJson {

CountlyTests/CountlyConnectionManagerTests.swift

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,236 @@ class CountlyConnectionManagerTests: CountlyBaseTestCase {
170170
XCTAssertEqual(captured?.count, 1)
171171
}
172172

173+
/**
174+
* <pre>
175+
* Test that all outgoing requests use GET method by default
176+
* when alwaysUsePOST is false and query strings are short.
177+
*
178+
* 1- Init SDK with MockURLProtocol, alwaysUsePOST = false
179+
* 2- Call all possible SDK functions: sessions, events, user details,
180+
* location, direct request, attribution, views, crashes, APM, remote config, rating, feedbacks
181+
* 3- Wait for requests to be sent
182+
* 4- Verify all intercepted requests use GET method
183+
* 5- Verify request URL contains query string (no HTTP body)
184+
* </pre>
185+
*/
186+
func test_allRequests_useGET_whenAlwaysUsePOSTDisabled() {
187+
let expectation = self.expectation(description: "Requests intercepted")
188+
expectation.assertForOverFulfill = false
189+
190+
let expectedMinimumRequests = 10
191+
var capturedRequests: [URLRequest] = []
192+
let lock = NSLock()
193+
MockURLProtocol.requestHandler = { request in
194+
lock.lock()
195+
capturedRequests.append(request)
196+
if capturedRequests.count >= expectedMinimumRequests {
197+
expectation.fulfill()
198+
}
199+
lock.unlock()
200+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
201+
let body = "{\"result\":[]}".data(using: .utf8)!
202+
return (body, response, nil)
203+
}
204+
205+
let config = createBaseConfig()
206+
config.alwaysUsePOST = false
207+
config.manualSessionHandling = true
208+
config.disableSDKBehaviorSettingsUpdates = true
209+
config.enablePerformanceMonitoring = true
210+
let sessionConfig = URLSessionConfiguration.default
211+
sessionConfig.protocolClasses = [MockURLProtocol.self]
212+
config.urlSessionConfiguration = sessionConfig
213+
214+
Countly.sharedInstance().start(with: config)
215+
216+
// Session begin
217+
Countly.sharedInstance().beginSession()
218+
219+
// Events
220+
Countly.sharedInstance().recordEvent("test_event_1")
221+
Countly.sharedInstance().recordEvent("test_event_2", segmentation: ["key": "value"], count: 1, sum: 1.5, duration: 10)
222+
223+
// User details
224+
Countly.user().set("custom_property", value: "custom_value")
225+
Countly.user().increment("login_count")
226+
Countly.user().push("tags", value: "vip")
227+
Countly.user().save()
228+
229+
// Location
230+
Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: "1.2.3.4")
231+
232+
// Direct request
233+
Countly.sharedInstance().addDirectRequest(["custom_key": "custom_value"])
234+
235+
// Attribution
236+
Countly.sharedInstance().recordDirectAttribution(withCampaignType: "_special_test", andCampaignData: "{\"cid\":\"test_campaign\"}")
237+
Countly.sharedInstance().recordIndirectAttribution(["idfa": "test_idfa_value"])
238+
239+
// Views
240+
let _ = Countly.sharedInstance().views().startAutoStoppedView("TestView")
241+
let _ = Countly.sharedInstance().views().startView("TestView2")
242+
Countly.sharedInstance().views().stopView(withName: "TestView2")
243+
244+
// Crash/Error reporting
245+
Countly.sharedInstance().recordError("TestError", isFatal: false, stackTrace: ["frame1", "frame2"], segmentation: ["errorType": "test"])
246+
247+
// Performance monitoring (APM)
248+
Countly.sharedInstance().recordNetworkTrace("test_trace", requestPayloadSize: 100, responsePayloadSize: 200, responseStatusCode: 200, startTime: 1111, endTime: 2222)
249+
250+
// Remote config
251+
Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in }
252+
Countly.sharedInstance().remoteConfig().enrollIntoABTests(forKeys: ["test_key"])
253+
Countly.sharedInstance().remoteConfig().exitABTests(forKeys: ["test_key"])
254+
255+
// Feedbacks and Rating (iOS only)
256+
#if os(iOS)
257+
Countly.sharedInstance().recordRatingWidget(withID: "test_widget_id", rating: 5, email: "test@test.com", comment: "Great", userCanBeContacted: true)
258+
Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { _, _ in }
259+
#endif
260+
261+
// Session update (flushes queued events)
262+
Countly.sharedInstance().updateSession()
263+
264+
// Session end
265+
Countly.sharedInstance().endSession()
266+
267+
waitForExpectations(timeout: 15)
268+
269+
XCTAssertGreaterThanOrEqual(capturedRequests.count, expectedMinimumRequests, "Should have captured at least \(expectedMinimumRequests) requests from various SDK functions")
270+
for request in capturedRequests {
271+
XCTAssertEqual(request.httpMethod, "GET", "Request to \(request.url?.path ?? "") should use GET")
272+
XCTAssertTrue(request.url?.query?.isEmpty == false, "GET request should have query string in URL")
273+
}
274+
}
275+
276+
/**
277+
* <pre>
278+
* Test that all outgoing requests use POST method
279+
* when alwaysUsePOST config flag is enabled.
280+
*
281+
* 1- Init SDK with MockURLProtocol, alwaysUsePOST = true
282+
* 2- Call all possible SDK functions: sessions, events, user details,
283+
* location, direct request, attribution, views, crashes, APM, remote config, rating, feedbacks
284+
* 3- Wait for requests to be sent
285+
* 4- Verify all intercepted requests use POST method
286+
* 5- Verify requests have HTTP body and no query string in URL
287+
* </pre>
288+
*/
289+
func test_allRequests_usePOST_whenAlwaysUsePOSTEnabled() {
290+
let expectation = self.expectation(description: "Requests intercepted")
291+
expectation.assertForOverFulfill = false
292+
293+
let expectedMinimumRequests = 10
294+
var capturedRequests: [URLRequest] = []
295+
var capturedBodies: [Data] = []
296+
let lock = NSLock()
297+
MockURLProtocol.requestHandler = { request in
298+
lock.lock()
299+
capturedRequests.append(request)
300+
// Read body from stream if needed
301+
if let body = request.httpBody {
302+
capturedBodies.append(body)
303+
} else if let stream = request.httpBodyStream {
304+
stream.open()
305+
let bufferSize = 4096
306+
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
307+
var data = Data()
308+
while stream.hasBytesAvailable {
309+
let read = stream.read(buffer, maxLength: bufferSize)
310+
if read > 0 {
311+
data.append(buffer, count: read)
312+
}
313+
}
314+
buffer.deallocate()
315+
stream.close()
316+
capturedBodies.append(data)
317+
}
318+
if capturedRequests.count >= expectedMinimumRequests {
319+
expectation.fulfill()
320+
}
321+
lock.unlock()
322+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
323+
let body = "{\"result\":[]}".data(using: .utf8)!
324+
return (body, response, nil)
325+
}
326+
327+
let config = createBaseConfig()
328+
config.alwaysUsePOST = true
329+
config.manualSessionHandling = true
330+
config.disableSDKBehaviorSettingsUpdates = true
331+
config.enablePerformanceMonitoring = true
332+
let sessionConfig = URLSessionConfiguration.default
333+
sessionConfig.protocolClasses = [MockURLProtocol.self]
334+
config.urlSessionConfiguration = sessionConfig
335+
336+
Countly.sharedInstance().start(with: config)
337+
338+
// Session begin
339+
Countly.sharedInstance().beginSession()
340+
341+
// Events
342+
Countly.sharedInstance().recordEvent("test_event_1")
343+
Countly.sharedInstance().recordEvent("test_event_2", segmentation: ["key": "value"], count: 1, sum: 1.5, duration: 10)
344+
345+
// User details
346+
Countly.user().set("custom_property", value: "custom_value")
347+
Countly.user().increment("login_count")
348+
Countly.user().push("tags", value: "vip")
349+
Countly.user().save()
350+
351+
// Location
352+
Countly.sharedInstance().recordLocation(CLLocationCoordinate2D(latitude: 41.0082, longitude: 28.9784), city: "Istanbul", isoCountryCode: "TR", ip: "1.2.3.4")
353+
354+
// Direct request
355+
Countly.sharedInstance().addDirectRequest(["custom_key": "custom_value"])
356+
357+
// Attribution
358+
Countly.sharedInstance().recordDirectAttribution(withCampaignType: "_special_test", andCampaignData: "{\"cid\":\"test_campaign\"}")
359+
Countly.sharedInstance().recordIndirectAttribution(["idfa": "test_idfa_value"])
360+
361+
// Views
362+
let _ = Countly.sharedInstance().views().startAutoStoppedView("TestView")
363+
let _ = Countly.sharedInstance().views().startView("TestView2")
364+
Countly.sharedInstance().views().stopView(withName: "TestView2")
365+
366+
// Crash/Error reporting
367+
Countly.sharedInstance().recordError("TestError", isFatal: false, stackTrace: ["frame1", "frame2"], segmentation: ["errorType": "test"])
368+
369+
// Performance monitoring (APM)
370+
Countly.sharedInstance().recordNetworkTrace("test_trace", requestPayloadSize: 100, responsePayloadSize: 200, responseStatusCode: 200, startTime: 1111, endTime: 2222)
371+
372+
// Remote config
373+
Countly.sharedInstance().remoteConfig().downloadKeys { _, _, _, _ in }
374+
Countly.sharedInstance().remoteConfig().enrollIntoABTests(forKeys: ["test_key"])
375+
Countly.sharedInstance().remoteConfig().exitABTests(forKeys: ["test_key"])
376+
377+
// Feedbacks and Rating (iOS only)
378+
#if os(iOS)
379+
Countly.sharedInstance().recordRatingWidget(withID: "test_widget_id", rating: 5, email: "test@test.com", comment: "Great", userCanBeContacted: true)
380+
Countly.sharedInstance().feedback().getAvailableFeedbackWidgets { _, _ in }
381+
#endif
382+
383+
// Session update (flushes queued events)
384+
Countly.sharedInstance().updateSession()
385+
386+
// Session end
387+
Countly.sharedInstance().endSession()
388+
389+
waitForExpectations(timeout: 15)
390+
391+
XCTAssertGreaterThanOrEqual(capturedRequests.count, expectedMinimumRequests, "Should have captured at least \(expectedMinimumRequests) requests from various SDK functions")
392+
for (index, request) in capturedRequests.enumerated() {
393+
XCTAssertEqual(request.httpMethod, "POST", "Request to \(request.url?.path ?? "") should use POST")
394+
XCTAssertNil(request.url?.query, "POST request URL should not contain query string")
395+
396+
if index < capturedBodies.count {
397+
let bodyString = String(data: capturedBodies[index], encoding: .utf8) ?? ""
398+
XCTAssertTrue(bodyString.contains("app_key="), "POST body should contain app_key for request \(index)")
399+
}
400+
}
401+
}
402+
173403
func addRequests(count: Int) {
174404
for loop in 0...count-1 {
175405
CountlyPersistency.sharedInstance().add(toQueue: "&request=REQUEST\(loop)")

0 commit comments

Comments
 (0)