Skip to content

Commit 35404e8

Browse files
feat: added AuthorizedHTTPLogger for posting logged events to scoped endpoints (#73)
* feat: added AuthorizedHTTPLogger for making posting logged events to scoped services * feat: made init public * chore: renaming file * chore: renaming message in assertion failures * chore: removing logging import * chore: changing order of properties * feat: added task cancellation and async version of log event * feat: adjusted task cancellation * chore: tidying up * feat: added line to HTTPLogging documentation * chore: minor tweaks
1 parent 8ed48c0 commit 35404e8

5 files changed

Lines changed: 251 additions & 1 deletion

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Foundation
2+
import Networking
3+
4+
/// AuthorizedHTTPLogger
5+
///
6+
/// A struct for sending HTTP requests to endpoints secured with scoped access tokens.
7+
/// This logger can be used to log user/journey specific insights for app metrics and performance.
8+
public struct AuthorizedHTTPLogger {
9+
/// `URL` address for sending HTTP requests
10+
let loggingURL: URL
11+
/// `NetworkClient` from the Networking package dependency to handle HTTP networking
12+
let networkClient: NetworkClient
13+
/// Scope for service access token
14+
let scope: String
15+
/// callback to handle possible errors resulting from `NetworkClient`'s `makeRequest` method
16+
let handleError: ((Error) -> Void)?
17+
18+
/// Initialiser for class with default methods for `networkClient` and `handleError` parameters
19+
public init(
20+
url: URL,
21+
networkClient: NetworkClient,
22+
scope: String,
23+
handleError: ((Error) -> Void)? = nil
24+
) {
25+
loggingURL = url
26+
self.scope = scope
27+
self.networkClient = networkClient
28+
self.handleError = handleError
29+
}
30+
31+
/// Sends HTTP POST request to designated URL, handling errors received back from `NetworkClient`'s `makeAuthorizedRequest` method
32+
/// - Parameters:
33+
/// - event: the encodable object to be logged in the request body as JSON
34+
@discardableResult
35+
public func logEvent(requestBody: any Encodable) -> Task<Void, Never>? {
36+
guard let jsonData = try? JSONEncoder().encode(requestBody) else {
37+
assertionFailure("Failed to encode object")
38+
return nil
39+
}
40+
41+
return Task {
42+
await createAndMakeRequest(data: jsonData)
43+
}
44+
}
45+
46+
public func logEvent(requestBody: any Encodable) async {
47+
guard let jsonData = try? JSONEncoder().encode(requestBody) else {
48+
assertionFailure("Failed to encode object")
49+
return
50+
}
51+
52+
await createAndMakeRequest(data: jsonData)
53+
}
54+
55+
private func createAndMakeRequest(data: Data) async {
56+
var request = URLRequest(url: loggingURL)
57+
request.httpMethod = "POST"
58+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
59+
request.httpBody = data
60+
61+
do {
62+
_ = try await networkClient.makeAuthorizedRequest(
63+
scope: scope,
64+
request: request
65+
)
66+
} catch {
67+
handleError?(error)
68+
}
69+
}
70+
}

Sources/HTTPLogging/HTTPLogger.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public struct HTTPLogger: LoggingService {
2020
parameters: [String: Any]) {
2121
let httpLogRequest = HTTPLogRequest(authSessionID: sessionID, event: event)
2222
guard let jsonData = try? JSONEncoder().encode(httpLogRequest) else {
23-
assertionFailure("Failed to decode object")
23+
assertionFailure("Failed to encode object")
2424
return
2525
}
2626

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Foundation
2+
3+
enum HTTPLoggerError: Error {
4+
case couldNotSerializeData
5+
}

Sources/HTTPLogging/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ The `HTTPLogging` module contains Types that can be used to build HTTP logging i
3232
`HTTPLogger` is usable for logging HTTP events through a HTTP network client.
3333

3434
`HTTPLogRequest` is usable as a model for posting `LoggableEvent`s in JSON format.
35+
36+
`AuthorizedHTTPLogger` is similar to `HTTPLogger` but it makes authorized requests.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
@testable import HTTPLogging
2+
import Logging
3+
import MockNetworking
4+
@testable import Networking
5+
import XCTest
6+
7+
struct MockAuthorizedRequestBody: Codable {
8+
struct MockSubType: Codable {
9+
let specialInfo: String
10+
11+
init(specialInfo: String = "special info") {
12+
self.specialInfo = specialInfo
13+
}
14+
}
15+
16+
let name: String
17+
let timestamp: Int
18+
19+
let subType: MockSubType
20+
21+
22+
init(
23+
name: String = "mock request name",
24+
timestamp: Int = 123456789012,
25+
subType: MockSubType = MockSubType()
26+
) {
27+
self.name = name
28+
self.timestamp = timestamp
29+
self.subType = subType
30+
}
31+
}
32+
33+
struct MockTokenHolder: AuthorizationProvider {
34+
func fetchToken(withScope scope: String) async throws -> String {
35+
"mockToken"
36+
}
37+
}
38+
39+
final class AuthorizedHTTPLoggerTests: XCTestCase {
40+
private var sut: AuthorizedHTTPLogger!
41+
private var client: NetworkClient!
42+
private var configuration: URLSessionConfiguration!
43+
44+
override func setUp() {
45+
super.setUp()
46+
configuration = URLSessionConfiguration.ephemeral
47+
configuration.protocolClasses = [MockURLProtocol.self]
48+
let mockTokenHolder = MockTokenHolder()
49+
client = NetworkClient(configuration: configuration)
50+
client.authorizationProvider = mockTokenHolder
51+
52+
sut = AuthorizedHTTPLogger(
53+
url: URL(string: "https://example.com/dev")!,
54+
networkClient: client,
55+
scope: "this.is.scoped"
56+
)
57+
}
58+
59+
override func tearDown() {
60+
sut = nil
61+
client = nil
62+
MockURLProtocol.clear()
63+
configuration = nil
64+
super.tearDown()
65+
}
66+
}
67+
68+
extension AuthorizedHTTPLoggerTests {
69+
func test_successfulTXMAEventLog() throws {
70+
// GIVEN network client returns 200
71+
MockURLProtocol.handler = {
72+
(Data(), HTTPURLResponse(statusCode: 200))
73+
}
74+
75+
// WHEN an event is logged
76+
let requestBody = MockAuthorizedRequestBody()
77+
let task = sut.logEvent(requestBody: requestBody)
78+
79+
wait(for: [
80+
XCTNSPredicateExpectation(
81+
predicate: .init(
82+
block: { _, _ in
83+
MockURLProtocol.requests.count == 1
84+
}
85+
),
86+
object: nil
87+
)
88+
], timeout: 3)
89+
90+
task?.cancel()
91+
92+
XCTAssertEqual(task?.isCancelled, true)
93+
94+
// THEN the request succeeds
95+
let request = try XCTUnwrap(MockURLProtocol.requests.first)
96+
XCTAssertEqual(request.httpMethod, "POST")
97+
XCTAssertEqual(request.url?.scheme, "https")
98+
XCTAssertEqual(request.url?.host, "example.com")
99+
XCTAssertEqual(request.url?.path, "/dev")
100+
101+
let httpData = try XCTUnwrap(request.httpBodyData())
102+
let decoder = JSONDecoder()
103+
let httpBody = try decoder.decode(MockAuthorizedRequestBody.self, from: httpData)
104+
105+
XCTAssertEqual(httpBody.name, "mock request name")
106+
XCTAssertEqual(httpBody.timestamp, 123456789012)
107+
XCTAssertEqual(httpBody.subType.specialInfo, "special info")
108+
}
109+
110+
func test_successfulTXMAEventLogAsync() async throws {
111+
// GIVEN network client returns 200
112+
MockURLProtocol.handler = {
113+
(Data(), HTTPURLResponse(statusCode: 200))
114+
}
115+
116+
// WHEN an event is logged
117+
let requestBody = MockAuthorizedRequestBody()
118+
await sut.logEvent(requestBody: requestBody)
119+
120+
let exp = expectation(description: "awaiting request count")
121+
122+
if MockURLProtocol.requests.count == 1 {
123+
exp.fulfill()
124+
}
125+
126+
await fulfillment(of: [exp], timeout: 3)
127+
128+
// THEN the request succeeds
129+
let request = try XCTUnwrap(MockURLProtocol.requests.first)
130+
XCTAssertEqual(request.httpMethod, "POST")
131+
XCTAssertEqual(request.url?.scheme, "https")
132+
XCTAssertEqual(request.url?.host, "example.com")
133+
XCTAssertEqual(request.url?.path, "/dev")
134+
135+
let httpData = try XCTUnwrap(request.httpBodyData())
136+
let decoder = JSONDecoder()
137+
let httpBody = try decoder.decode(MockAuthorizedRequestBody.self, from: httpData)
138+
139+
XCTAssertEqual(httpBody.name, "mock request name")
140+
XCTAssertEqual(httpBody.timestamp, 123456789012)
141+
XCTAssertEqual(httpBody.subType.specialInfo, "special info")
142+
}
143+
144+
func test_TXMAEventLog_throwsError() throws {
145+
var error: ServerError?
146+
147+
sut = AuthorizedHTTPLogger(
148+
url: URL(string: "https://example.com/dev")!,
149+
networkClient: client,
150+
scope: "this.is.scoped",
151+
handleError: { receivedError in
152+
error = receivedError as? ServerError
153+
}
154+
)
155+
156+
// GIVEN network client returns 401 error
157+
MockURLProtocol.handler = {
158+
(Data(), HTTPURLResponse(statusCode: 401))
159+
}
160+
161+
// WHEN a TXMA event is logged
162+
let requestBody = MockAuthorizedRequestBody()
163+
sut.logEvent(requestBody: requestBody)
164+
wait(for: [
165+
XCTNSPredicateExpectation(predicate: .init(block: { _, _ in
166+
MockURLProtocol.requests.count == 1
167+
}), object: nil)
168+
], timeout: 3)
169+
170+
// THEN a 401 error is received in response
171+
XCTAssertEqual(error?.errorCode, 401)
172+
}
173+
}

0 commit comments

Comments
 (0)