Skip to content

Commit

Permalink
✨ Add support for Authorization header on API requests
Browse files Browse the repository at this point in the history
  • Loading branch information
iujames committed Feb 21, 2023
1 parent 71e201d commit f0e0922
Show file tree
Hide file tree
Showing 14 changed files with 154 additions and 30 deletions.
2 changes: 2 additions & 0 deletions Sources/AppcuesKit/Appcues.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,11 @@ public class Appcues: NSObject {
return
}

var properties = properties
let userChanged = userID != storage.userID
storage.userID = userID
storage.isAnonymous = isAnonymous
storage.userSignature = properties?.removeValue(forKey: "appcues:user_id_signature") as? String
if userChanged {
// when the identified user changes from last known value, we must start a new session
sessionMonitor.start()
Expand Down
2 changes: 2 additions & 0 deletions Sources/AppcuesKit/Data/Analytics/ActivityProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ internal class ActivityProcessor: ActivityProcessing {

private func handleActivity(activity: ActivityStorage, completion: @escaping () -> Void) {
networking.post(to: APIEndpoint.activity(userID: activity.userID),
authorization: Authorization(bearerToken: activity.userSignature),
body: activity.data,
requestId: nil) { [weak self] (result: Result<ActivityResponse, Error>) in
guard let self = self else { return }
Expand All @@ -148,6 +149,7 @@ internal class ActivityProcessor: ActivityProcessing {

private func handleQualify(activity: ActivityStorage, completion: @escaping (Result<QualifyResponse, Error>) -> Void) {
networking.post(to: APIEndpoint.qualify(userID: activity.userID),
authorization: Authorization(bearerToken: activity.userSignature),
body: activity.data,
requestId: activity.requestID) { [weak self] (result: Result<QualifyResponse, Error>) in
guard let self = self else { return }
Expand Down
12 changes: 8 additions & 4 deletions Sources/AppcuesKit/Data/Analytics/AnalyticsTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,28 +141,32 @@ extension Activity {
userID: storage.userID,
events: [Event(name: name, attributes: update.properties, context: update.context)],
profileUpdate: update.eventAutoProperties,
groupID: storage.groupID)
groupID: storage.groupID,
userSignature: storage.userSignature)

case let .screen(title):
self.init(accountID: config.accountID,
userID: storage.userID,
events: [Event(screen: title, attributes: update.properties, context: update.context)],
profileUpdate: update.eventAutoProperties,
groupID: storage.groupID)
groupID: storage.groupID,
userSignature: storage.userSignature)

case .profile:
self.init(accountID: config.accountID,
userID: storage.userID,
events: nil,
profileUpdate: update.properties,
groupID: storage.groupID)
groupID: storage.groupID,
userSignature: storage.userSignature)

case .group:
self.init(accountID: config.accountID,
userID: storage.userID,
events: nil,
groupID: storage.groupID,
groupUpdate: update.properties)
groupUpdate: update.properties,
userSignature: storage.userSignature)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal struct ActivityStorage: Codable {
let requestID: UUID
let data: Data
let created: Date
let userSignature: String?

// could have a more advanced policy for things like only attempting after x seconds
var lastAttempt: Date?
Expand All @@ -31,5 +32,6 @@ internal struct ActivityStorage: Codable {
self.requestID = activity.requestID
self.data = data
self.created = Date()
self.userSignature = activity.userSignature
}
}
6 changes: 5 additions & 1 deletion Sources/AppcuesKit/Data/Models/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ internal struct Activity {
let accountID: String
let groupID: String?
let groupUpdate: [String: Any]?
let userSignature: String?

internal init(accountID: String,
userID: String,
events: [Event]?,
profileUpdate: [String: Any]? = nil,
groupID: String? = nil,
groupUpdate: [String: Any]? = nil) {
groupUpdate: [String: Any]? = nil,
userSignature: String? = nil) {
self.accountID = accountID
self.userID = userID
self.events = events
self.profileUpdate = profileUpdate
self.groupID = groupID
self.groupUpdate = groupUpdate
self.userSignature = userSignature
}
}

Expand All @@ -42,6 +45,7 @@ extension Activity: Encodable {
case accountID = "account_id"
case groupID = "group_id"
case groupUpdate = "group_update"
// note: userSignature is not serialized - used on request Authentication header when present
}

func encode(to encoder: Encoder) throws {
Expand Down
28 changes: 28 additions & 0 deletions Sources/AppcuesKit/Data/Networking/Authentication.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// Authorization.swift
// AppcuesKit
//
// Created by James Ellis on 2/14/23.
// Copyright © 2023 Appcues. All rights reserved.
//

import Foundation

internal enum Authorization {
case bearer(String)

init?(bearerToken: String?) {
guard let bearerToken = bearerToken else { return nil }
self = .bearer(bearerToken)
}
}

extension URLRequest {
mutating func authorize(_ auth: Authorization?) {
guard let auth = auth else { return }
switch auth {
case let .bearer(token):
self.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
}
}
17 changes: 14 additions & 3 deletions Sources/AppcuesKit/Data/Networking/NetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
import Foundation

internal protocol Networking: AnyObject {
func get<T: Decodable>(from endpoint: Endpoint, completion: @escaping (_ result: Result<T, Error>) -> Void)
func post<T: Decodable>(to endpoint: Endpoint, body: Data, requestId: UUID?, completion: @escaping (_ result: Result<T, Error>) -> Void)
func get<T: Decodable>(from endpoint: Endpoint,
authorization: Authorization?,
completion: @escaping (_ result: Result<T, Error>) -> Void)
func post<T: Decodable>(to endpoint: Endpoint,
authorization: Authorization?,
body: Data,
requestId: UUID?,
completion: @escaping (_ result: Result<T, Error>) -> Void)
}

internal class NetworkClient: Networking {
Expand All @@ -22,19 +28,23 @@ internal class NetworkClient: Networking {
self.storage = container.resolve(DataStoring.self)
}

func get<T: Decodable>(from endpoint: Endpoint, completion: @escaping (_ result: Result<T, Error>) -> Void) {
func get<T: Decodable>(from endpoint: Endpoint,
authorization: Authorization?,
completion: @escaping (_ result: Result<T, Error>) -> Void) {
guard let requestURL = endpoint.url(config: config, storage: storage) else {
completion(.failure(NetworkingError.invalidURL))
return
}

var request = URLRequest(url: requestURL)
request.httpMethod = "GET"
request.authorize(authorization)

handleRequest(request, requestId: nil, completion: completion)
}

func post<T: Decodable>(to endpoint: Endpoint,
authorization: Authorization?,
body: Data,
requestId: UUID?,
completion: @escaping (_ result: Result<T, Error>) -> Void) {
Expand All @@ -46,6 +56,7 @@ internal class NetworkClient: Networking {
var request = URLRequest(url: requestURL)
request.httpMethod = "POST"
request.httpBody = body
request.authorize(authorization)

handleRequest(request, requestId: requestId, completion: completion)
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/AppcuesKit/Data/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ internal protocol DataStoring: AnyObject {

/// The date of the last known time that an experience/flow was shown to the user in this application
var lastContentShownAt: Date? { get set }

/// Optional, base 64 encoded signature to use as bearer token on API requests from the current user
var userSignature: String? { get set }
}

internal class Storage: DataStoring {
Expand All @@ -33,6 +36,7 @@ internal class Storage: DataStoring {
case isAnonymous
case lastContentShownAt
case groupID
case userSignature
}

private let config: Appcues.Config
Expand Down Expand Up @@ -86,6 +90,15 @@ internal class Storage: DataStoring {
}
}

internal var userSignature: String? {
get {
return read(.userSignature, defaultValue: nil)
}
set {
write(.userSignature, newValue: newValue)
}
}

init(container: DIContainer) {
self.config = container.resolve(Appcues.Config.self)
self.deviceID = UIDevice.identifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ internal class DebugViewModel: ObservableObject {
connectedStatus.subtitle = nil
connectedStatus.detailText = nil

networking.get(from: APIEndpoint.health) { [weak self] (result: Result<ActivityResponse, Error>) in
networking.get(from: APIEndpoint.health, authorization: nil) { [weak self] (result: Result<ActivityResponse, Error>) in
DispatchQueue.main.async {
switch result {
case .success:
Expand Down
5 changes: 4 additions & 1 deletion Sources/AppcuesKit/Presentation/ExperienceLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal protocol ExperienceLoading: AnyObject {
internal class ExperienceLoader: ExperienceLoading {

private let config: Appcues.Config
private let storage: DataStoring
private let networking: Networking
private let experienceRenderer: ExperienceRendering
private let notificationCenter: NotificationCenter
Expand All @@ -26,6 +27,7 @@ internal class ExperienceLoader: ExperienceLoading {

init(container: DIContainer) {
self.config = container.resolve(Appcues.Config.self)
self.storage = container.resolve(DataStoring.self)
self.networking = container.resolve(Networking.self)
self.experienceRenderer = container.resolve(ExperienceRendering.self)
self.notificationCenter = container.resolve(NotificationCenter.self)
Expand All @@ -40,7 +42,8 @@ internal class ExperienceLoader: ExperienceLoading {
APIEndpoint.preview(experienceID: experienceID)

networking.get(
from: endpoint
from: endpoint,
authorization: Authorization(bearerToken: storage.userSignature)
) { [weak self] (result: Result<Experience, Error>) in
switch result {
case .success(let experience):
Expand Down
14 changes: 7 additions & 7 deletions Tests/AppcuesKitTests/Analytics/ActivityProcessorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ActivityProcessorTests: XCTestCase {
let onPostExpectation = expectation(description: "Activity request")
let resultCallbackExpectation = expectation(description: "Process result")
let activity = generateMockActivity(userID: "user1", event: Event(name: "eventName", attributes: ["my_key": "my_value", "another_key": 33]))
appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
do {
let apiEndpoint = try XCTUnwrap(endpoint as? APIEndpoint)
guard case .qualify(activity.userID) = apiEndpoint else { return XCTFail() }
Expand Down Expand Up @@ -80,7 +80,7 @@ class ActivityProcessorTests: XCTestCase {
let activity2 = generateMockActivity(userID: "user2", event: Event(name: "event2", attributes: ["my_key": "my_value2", "another_key": 34]))
var postCount = 0

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
do {
postCount += 1
if postCount == 1 {
Expand Down Expand Up @@ -145,7 +145,7 @@ class ActivityProcessorTests: XCTestCase {
let activity2 = generateMockActivity(userID: "user2", event: Event(name: "event2", attributes: ["my_key": "my_value2", "another_key": 34]))
var postCount = 0

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
do {
postCount += 1
if postCount == 1 {
Expand Down Expand Up @@ -195,7 +195,7 @@ class ActivityProcessorTests: XCTestCase {
var currentError = URLError(networkIssues.first!)
resultCallbackExpectation.expectedFulfillmentCount = networkIssues.count

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
completion(.failure(currentError))
}

Expand All @@ -220,7 +220,7 @@ class ActivityProcessorTests: XCTestCase {
var currentError = URLError(networkIssues.first!)
resultCallbackExpectation.expectedFulfillmentCount = networkIssues.count

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
completion(.failure(currentError))
}

Expand Down Expand Up @@ -252,7 +252,7 @@ class ActivityProcessorTests: XCTestCase {
let activity2 = generateMockActivity(userID: "user2", event: Event(name: "event2", attributes: ["my_key": "my_value2", "another_key": 34]))
var postCount = 0

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
do {
postCount += 1
if postCount == 1 {
Expand Down Expand Up @@ -306,7 +306,7 @@ class ActivityProcessorTests: XCTestCase {
let resultCallbackExpectation = expectation(description: "Process result 1")
let activity = generateMockActivity(userID: "user1", event: Event(name: "event1", attributes: ["my_key": "my_value1", "another_key": 33]))

appcues.networking.onPost = { endpoint, body, requestId, completion in
appcues.networking.onPost = { endpoint, authorization, body, requestId, completion in
completion(.success(QualifyResponse(experiences: [.decoded(self.mockExperience)], performedQualification: true, qualificationReason: nil, experiments: nil)))
onPostExpectation.fulfill()
}
Expand Down
10 changes: 5 additions & 5 deletions Tests/AppcuesKitTests/Experiences/ExperienceLoaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class ExperienceLoaderTests: XCTestCase {

func testLoadPublished() throws {
// Arrange
appcues.networking.onGet = { endpoint in
appcues.networking.onGet = { endpoint, authorization in
XCTAssertEqual(
endpoint.url(config: self.appcues.config, storage: self.appcues.storage),
APIEndpoint.content(experienceID: "123").url(config: self.appcues.config, storage: self.appcues.storage)
Expand Down Expand Up @@ -51,7 +51,7 @@ class ExperienceLoaderTests: XCTestCase {

func testLoadUnpublished() throws {
// Arrange
appcues.networking.onGet = { endpoint in
appcues.networking.onGet = { endpoint, authorization in
XCTAssertEqual(
endpoint.url(config: self.appcues.config, storage: self.appcues.storage),
APIEndpoint.preview(experienceID: "123").url(config: self.appcues.config, storage: self.appcues.storage)
Expand Down Expand Up @@ -80,7 +80,7 @@ class ExperienceLoaderTests: XCTestCase {

func testLoadFail() throws {
// Arrange
appcues.networking.onGet = { endpoint in
appcues.networking.onGet = { endpoint, authorization in
return .failure(URLError(.resourceUnavailable))
}

Expand All @@ -104,7 +104,7 @@ class ExperienceLoaderTests: XCTestCase {
// Load the initial preview
experienceLoader.load(experienceID: "123", published: false, trigger: .preview, completion: nil)

appcues.networking.onGet = { endpoint in
appcues.networking.onGet = { endpoint, authorization in
XCTAssertEqual(
endpoint.url(config: self.appcues.config, storage: self.appcues.storage),
APIEndpoint.preview(experienceID: "123").url(config: self.appcues.config, storage: self.appcues.storage)
Expand All @@ -131,7 +131,7 @@ class ExperienceLoaderTests: XCTestCase {
// Load a published experience
experienceLoader.load(experienceID: "abc", published: true, trigger: .preview, completion: nil)

appcues.networking.onGet = { endpoint in
appcues.networking.onGet = { endpoint, authorization in
reloadExpectation.fulfill()
XCTFail("Experience should not be loaded on notification")
return .success(Experience.mock)
Expand Down
Loading

0 comments on commit f0e0922

Please sign in to comment.