Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Sources/WealthsimpleDownloader/DownloaderDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation
#if canImport(FoundationNetworking)
@preconcurrency import FoundationNetworking
#endif

protocol HTTPClient {
func send(
_ request: URLRequest,
body: Data?,
completion: @escaping (Data?, URLResponse?, Error?) -> Void
)
}

private final class HTTPCompletionBox: @unchecked Sendable {
let completion: (Data?, URLResponse?, Error?) -> Void

init(_ completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
self.completion = completion
}
}

struct URLSessionHTTPClient: HTTPClient {
let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

func send(
_ request: URLRequest,
body: Data?,
completion: @escaping (Data?, URLResponse?, Error?) -> Void
) {
var request = request
request.httpBody = body
let completionBox = HTTPCompletionBox(completion)
session.dataTask(with: request) { data, response, error in
completionBox.completion(data, response, error)
}
.resume()
}
}

struct DownloaderDependencies {
static let live = Self(httpClient: URLSessionHTTPClient(), configuration: URLConfiguration())

let httpClient: HTTPClient
let configuration: URLConfiguration
}
59 changes: 37 additions & 22 deletions Sources/WealthsimpleDownloader/Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,27 @@ struct Token {
private static let credentialStorageKeyAccessToken = "accessToken"
private static let credentialStorageKeyRefreshToken = "refreshToken"
private static let credentialStorageKeyExpiry = "expiry"
private static let tokenPath = "oauth/v2/token"
private static let tokenValidationPath = "oauth/v2/token/info"

private static var url: URL { URLConfiguration.shared.urlObject(for: "oauth/v2/token")! }
private static var testUrl: URL { URLConfiguration.shared.urlObject(for: "oauth/v2/token/info")! }
private static var clientId = "4da53ac2b03225bed1550eba8e4611e086c7b905a3855e6ed12ea08c246758fa" // From the website
private static var scope = "read" // the clientId supports some write scopes, but as this library only reads we limit it for safety

private let accessToken: String
private let refreshToken: String
private let expiry: Date
private let credentialStorage: CredentialStorage
private let dependencies: DownloaderDependencies

private init(accessToken: String, refreshToken: String, expiry: Date, credentialStorage: CredentialStorage) {
private init(accessToken: String, refreshToken: String, expiry: Date, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) {
self.accessToken = accessToken
self.refreshToken = refreshToken
self.expiry = expiry
self.credentialStorage = credentialStorage
self.dependencies = dependencies
}

private init(json: [String: Any], credentialStorage: CredentialStorage) throws {
private init(json: [String: Any], credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) throws {
guard let accessToken = json["access_token"] as? String,
let expiresIn = json["expires_in"] as? Int,
let createdAt = json["created_at"] as? Int,
Expand All @@ -63,10 +65,18 @@ struct Token {
let expiresAt = createdAt + expiresIn
self.expiry = Date(timeIntervalSince1970: TimeInterval(expiresAt))
self.credentialStorage = credentialStorage
self.dependencies = dependencies
}

static func getToken(username: String, password: String, otp: String, credentialStorage: CredentialStorage, completion: @escaping (Result<Self, TokenError>) -> Void) {
var request = URLRequest(url: url)
static func getToken(
username: String,
password: String,
otp: String,
credentialStorage: CredentialStorage,
dependencies: DownloaderDependencies = .live,
completion: @escaping (Result<Self, TokenError>) -> Void
) {
var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenPath)!)
request.setValue(otp, forHTTPHeaderField: "x-wealthsimple-otp")
let json = [
"grant_type": "password",
Expand All @@ -75,18 +85,24 @@ struct Token {
"scope": scope,
"client_id": clientId
]
sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, completion: completion)
sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion)
}

static func getToken(from credentialStorage: CredentialStorage, completion: @escaping (Self?) -> Void) {
static func getToken(from credentialStorage: CredentialStorage, dependencies: DownloaderDependencies = .live, completion: @escaping (Self?) -> Void) {
guard let accessToken = credentialStorage.read(credentialStorageKeyAccessToken),
let refreshToken = credentialStorage.read(credentialStorageKeyRefreshToken),
let expiryString = credentialStorage.read(credentialStorageKeyExpiry),
let expiryDouble = Double(expiryString) else {
completion(nil)
return
}
let token = Self(accessToken: accessToken, refreshToken: refreshToken, expiry: Date(timeIntervalSince1970: expiryDouble), credentialStorage: credentialStorage)
let token = Self(
accessToken: accessToken,
refreshToken: refreshToken,
expiry: Date(timeIntervalSince1970: expiryDouble),
credentialStorage: credentialStorage,
dependencies: dependencies
)
token.refreshIfNeeded {
switch $0 {
case .failure:
Expand All @@ -107,27 +123,28 @@ struct Token {
parameters json: [String: String],
request urlRequest: URLRequest,
credentialStorage: CredentialStorage,
dependencies: DownloaderDependencies,
completion: @escaping (Result<Self, TokenError>) -> Void
) {
var request = urlRequest
let session = URLSession.shared
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else {
completion(.failure(TokenError.invalidParameters(parameters: json)))
return
}
let task = session.uploadTask(with: request, from: jsonData) { data, response, error in
handleTokenResponse(data: data, response: response, error: error, credentialStorage: credentialStorage, completion: completion)
dependencies.httpClient.send(request, body: jsonData) { data, response, error in
handleTokenResponse(data: data, response: response, error: error, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion)
}
task.resume()
}

// swiftlint:disable:next function_parameter_count
private static func handleTokenResponse(
data: Data?,
response: URLResponse?,
error: Error?,
credentialStorage: CredentialStorage,
dependencies: DownloaderDependencies,
completion: (Result<Self, TokenError>) -> Void
) {
guard let data else {
Expand All @@ -146,15 +163,15 @@ struct Token {
completion(.failure(TokenError.httpError(error: "Status code \(httpResponse.statusCode)")))
return
}
completion(parse(data: data, credentialStorage: credentialStorage))
completion(parse(data: data, credentialStorage: credentialStorage, dependencies: dependencies))
}

private static func parse(data: Data, credentialStorage: CredentialStorage) -> Result<Self, TokenError> {
private static func parse(data: Data, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) -> Result<Self, TokenError> {
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
return .failure(TokenError.invalidJsonType(json: data))
}
do {
let token = try Self(json: json, credentialStorage: credentialStorage)
let token = try Self(json: json, credentialStorage: credentialStorage, dependencies: dependencies)
token.saveToken()
return .success(token)
} catch {
Expand All @@ -163,11 +180,10 @@ struct Token {
}

private func testIfValid(completion: @escaping (Bool) -> Void) {
var request = URLRequest(url: Self.testUrl)
let session = URLSession.shared
var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenValidationPath)!)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
authenticateRequest(request) { request in
let task = session.dataTask(with: request) { _, response, error in
dependencies.httpClient.send(request, body: nil) { _, response, error in
guard error == nil else {
completion(false)
return
Expand All @@ -178,7 +194,6 @@ struct Token {
}
completion(httpResponse.statusCode == 200)
}
task.resume()
}
}

Expand All @@ -201,13 +216,13 @@ struct Token {
}

private func refresh(completion: @escaping (Result<Self, TokenError>) -> Void) {
let request = URLRequest(url: Self.url)
let request = URLRequest(url: dependencies.configuration.urlObject(for: Self.tokenPath)!)
let json = [
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": Self.clientId
]
Self.sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, completion: completion)
Self.sendTokenRequest(parameters: json, request: request, credentialStorage: credentialStorage, dependencies: dependencies, completion: completion)
}

private func saveToken() {
Expand Down
36 changes: 10 additions & 26 deletions Sources/WealthsimpleDownloader/URLConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,16 @@ import Foundation
import FoundationNetworking
#endif

/// Singleton class that manages the base URL configuration for all Wealthsimple API endpoints
/// Configures the REST and GraphQL endpoints used by Wealthsimple requests.
final class URLConfiguration {

private static let defaultBaseURL = "https://api.production.wealthsimple.com/v1/"
private static let defaultGraphQLURL = "https://my.wealthsimple.com/graphql"

/// Shared singleton instance
static let shared = URLConfiguration()

/// Base URL for all Wealthsimple API endpoints
private var baseURL: String = URLConfiguration.defaultBaseURL
private let baseURL: String
/// GraphQL URL for Wealthsimple
private var graphQLURL: String = URLConfiguration.defaultGraphQLURL
private let graphQLURL: String

/// Get the current base URL
var base: String {
Expand All @@ -34,21 +31,13 @@ final class URLConfiguration {
graphQLURL
}

/// Private initializer to enforce singleton pattern
private init() {
// Singleton initialization
}

/// Set a new base URL (internal access for testing)
/// - Parameter url: The new base URL to use
func setBaseURL(_ url: String) {
baseURL = url
}

/// Set a new graphQL URL (internal access for testing)
/// - Parameter url: The new base URL to use
func setGraphQLURL(_ url: String) {
graphQLURL = url
/// Creates a configuration for the provided REST and GraphQL endpoints.
init(
baseURL: String = URLConfiguration.defaultBaseURL,
graphQLURL: String = URLConfiguration.defaultGraphQLURL
) {
self.baseURL = baseURL
self.graphQLURL = graphQLURL
}

/// Create a full URL by appending a path to the base URL
Expand Down Expand Up @@ -85,9 +74,4 @@ final class URLConfiguration {
return request
}

func reset() {
baseURL = Self.defaultBaseURL
graphQLURL = Self.defaultGraphQLURL
}

}
18 changes: 12 additions & 6 deletions Sources/WealthsimpleDownloader/WealthsimpleAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public final class WealthsimpleAPI {

private let authenticationCallback: AuthenticationCallback
private let credentialStorage: CredentialStorage
private let dependencies: DownloaderDependencies
private var token: Token?

/// Creates the Downloader instance
Expand All @@ -45,9 +46,14 @@ public final class WealthsimpleAPI {
/// Needs to return username, password, and one time password. Might be called during any call.
/// - credentialStorage: A CredentialStore to save API tokens to. Implementation can be empty,
/// in this case the authenticationCallback will be called every time and not only when the refresh token expired
public init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage) {
public convenience init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage) {
self.init(authenticationCallback: authenticationCallback, credentialStorage: credentialStorage, dependencies: .live)
}

init(authenticationCallback: @escaping AuthenticationCallback, credentialStorage: CredentialStorage, dependencies: DownloaderDependencies) {
self.authenticationCallback = authenticationCallback
self.credentialStorage = credentialStorage
self.dependencies = dependencies
}

/// Authneticates against the API. Call before calling any other method.
Expand All @@ -65,7 +71,7 @@ public final class WealthsimpleAPI {
}
return
}
Token.getToken(from: credentialStorage) {
Token.getToken(from: credentialStorage, dependencies: dependencies) {
if let token = $0 {
self.token = token
completion(nil)
Expand All @@ -84,7 +90,7 @@ public final class WealthsimpleAPI {
completion(.failure(.tokenError(.noToken)))
return
}
WealthsimpleAccount.getAccounts(token: token) {
WealthsimpleAccount.getAccounts(token: token, dependencies: dependencies) {
completion($0)
}
}
Expand All @@ -99,7 +105,7 @@ public final class WealthsimpleAPI {
completion(.failure(.tokenError(.noToken)))
return
}
WealthsimplePosition.getPositions(token: token, account: account, date: date) {
WealthsimplePosition.getPositions(token: token, account: account, date: date, dependencies: dependencies) {
completion($0)
}
}
Expand All @@ -114,14 +120,14 @@ public final class WealthsimpleAPI {
completion(.failure(.tokenError(.noToken)))
return
}
WealthsimpleTransaction.getTransactions(token: token, account: account, startDate: startDate) {
WealthsimpleTransaction.getTransactions(token: token, account: account, startDate: startDate, dependencies: dependencies) {
completion($0)
}
}

private func getNewToken(completion: @escaping (Error?) -> Void) {
authenticationCallback { username, password, otp in
Token.getToken(username: username, password: password, otp: otp, credentialStorage: self.credentialStorage) {
Token.getToken(username: username, password: password, otp: otp, credentialStorage: self.credentialStorage, dependencies: self.dependencies) {
switch $0 {
case let .failure(error):
completion(error)
Expand Down
10 changes: 4 additions & 6 deletions Sources/WealthsimpleDownloader/WealthsimpleAccount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public protocol Account {

struct WealthsimpleAccount: Account {

private static var url: URL { URLConfiguration.shared.urlObject(for: "accounts")! }
private static let path = "accounts"

let accountType: AccountType
let currency: String
Expand All @@ -94,15 +94,13 @@ struct WealthsimpleAccount: Account {
self.number = number
}

static func getAccounts(token: Token, completion: @escaping (Result<[Account], AccountError>) -> Void) {
var request = URLRequest(url: url)
let session = URLSession.shared
static func getAccounts(token: Token, dependencies: DownloaderDependencies = .live, completion: @escaping (Result<[Account], AccountError>) -> Void) {
var request = URLRequest(url: dependencies.configuration.urlObject(for: Self.path)!)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
token.authenticateRequest(request) { request in
let task = session.dataTask(with: request) { data, response, error in
dependencies.httpClient.send(request, body: nil) { data, response, error in
handleResponse(data: data, response: response, error: error, completion: completion)
}
task.resume()
}
}

Expand Down
Loading
Loading