Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add networking logic #14

Merged
merged 14 commits into from
Aug 18, 2021
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
16 changes: 0 additions & 16 deletions Sources/SDK/CustomerIO.swift
Original file line number Diff line number Diff line change
@@ -1,21 +1,5 @@
import Foundation

// disabling lint rule to allow 2 letter enum names: US, EU, etc.
// swiftlint:disable identifier_name
/**
Region that your Customer.io Workspace is located in.

The SDK will route traffic to the correct data center location depending on the `Region` that you use.
*/
public enum Region: String, Equatable {
/// The United States (US) data center
case US
/// The European Union (EU) data center
case EU
}

// swiftlint:enable identifier_name

/**
Welcome to the Customer.io iOS SDK!

Expand Down
17 changes: 17 additions & 0 deletions Sources/SDK/Extensions/ResultExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

internal extension Result {
var error: Failure? {
switch self {
case .failure(let error): return error
default: return nil
}
}

var success: Success? {
switch self {
case .success(let success): return success
default: return nil
}
}
}
27 changes: 27 additions & 0 deletions Sources/SDK/SdkConfig.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import Foundation

// disabling lint rule to allow 2 letter enum names: US, EU, etc.
// swiftlint:disable identifier_name
/**
Region that your Customer.io Workspace is located in.

The SDK will route traffic to the correct data center location depending on the `Region` that you use.
*/
public enum Region: String, Equatable {
/// The United States (US) data center
case US
/// The European Union (EU) data center
case EU

internal var subdomainSuffix: String {
switch self {
case .US: return ""
case .EU: return "-eu"
}
}

internal var trackingUrl: String {
"https://track\(subdomainSuffix).customer.io/api/v1"
levibostian marked this conversation as resolved.
Show resolved Hide resolved
}
}

// swiftlint:enable identifier_name

internal struct SdkConfig: AutoLenses, Equatable {
let siteId: String
let apiKey: String
Expand Down
106 changes: 106 additions & 0 deletions Sources/SDK/Service/HttpClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

typealias HttpHeaders = [String: String]

internal protocol HttpClient: AutoMockable {
func request(
_ endpoint: HttpEndpoint,
headers: HttpHeaders?,
body: Data?,
onComplete: @escaping (Result<Data, HttpRequestError>) -> Void
)
}

internal class CIOHttpClient: HttpClient {
private let session: URLSession
private let region: Region
private var httpRequestRunner: HttpRequestRunner

/// for testing
init(httpRequestRunner: HttpRequestRunner, region: Region) {
self.httpRequestRunner = httpRequestRunner
self.session = Self.getSession(siteId: "fake-site-id", apiKey: "fake-api-key")
self.region = region
}

init(config: SdkConfig) {
self.session = Self.getSession(siteId: config.siteId, apiKey: config.apiKey)
self.region = config.region
levibostian marked this conversation as resolved.
Show resolved Hide resolved
self.httpRequestRunner = UrlRequestHttpRequestRunner(session: session)
}

deinit {
self.session.finishTasksAndInvalidate()
}

func request(
_ endpoint: HttpEndpoint,
headers: HttpHeaders?,
body: Data?,
onComplete: @escaping (Result<Data, HttpRequestError>) -> Void
) {
guard let url = httpRequestRunner.getUrl(endpoint: endpoint, region: region) else {
onComplete(Result.failure(HttpRequestError.urlConstruction(endpoint.getUrlString(region))))
return
}

let requestParams = RequestParams(method: endpoint.method, url: url, headers: headers, body: body)

httpRequestRunner.request(requestParams) { data, response, error in
if let error = error {
onComplete(Result.failure(HttpRequestError.underlyingError(error)))
return
}

guard let response = response else {
onComplete(Result.failure(HttpRequestError.noResponse))
return
}

let statusCode = response.statusCode
guard statusCode < 300 else {
switch statusCode {
case 401:
onComplete(Result.failure(HttpRequestError.unauthorized))
default:
onComplete(Result.failure(HttpRequestError.unsuccessfulStatusCode(statusCode)))
}

return
}

guard let data = data else {
onComplete(Result.failure(HttpRequestError.noResponse))
return
}

onComplete(Result.success(data))
}
}
}

extension CIOHttpClient {
static func getSession(siteId: String, apiKey: String) -> URLSession {
let urlSessionConfig = URLSessionConfiguration.ephemeral
let basicAuthHeaderString = "Basic \(getBasicAuthHeaderString(siteId: siteId, apiKey: apiKey))"

urlSessionConfig.allowsCellularAccess = true
urlSessionConfig.timeoutIntervalForResource = 30
urlSessionConfig.timeoutIntervalForRequest = 60
urlSessionConfig.httpAdditionalHeaders = ["Content-Type": "application/json; charset=utf-8",
"Authorization": basicAuthHeaderString,
"User-Agent": "CustomerIO-SDK-iOS-/\(SdkVersion.version)"]

return URLSession(configuration: urlSessionConfig, delegate: nil, delegateQueue: nil)
}

static func getBasicAuthHeaderString(siteId: String, apiKey: String) -> String {
let rawHeader = "\(siteId):\(apiKey)"
let encodedRawHeader = rawHeader.data(using: .utf8)!

return encodedRawHeader.base64EncodedString(options: NSData.Base64EncodingOptions(rawValue: 0))
}
}
31 changes: 31 additions & 0 deletions Sources/SDK/Service/HttpEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

internal enum HttpEndpoint {
case findAccountRegion
case identifyCustomer(identifier: String)

var path: String {
switch self {
case .findAccountRegion: return "/accounts/region"
case .identifyCustomer(let identifier): return "/customers/\(identifier)"
}
}

var method: String {
switch self {
case .findAccountRegion: return "GET"
case .identifyCustomer: return "PUT"
}
}
}

internal extension HttpEndpoint {
func getUrl(_ region: Region) -> URL? {
// At this time, all endpoints use tracking endpoint so we only use only 1 base URL here.
URL(string: getUrlString(region))
}

func getUrlString(_ region: Region) -> String {
region.trackingUrl + path
}
}
30 changes: 30 additions & 0 deletions Sources/SDK/Service/HttpRequestError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/**
Errors that can happen during an HTTP request.
*/
public enum HttpRequestError: Error {
/// 401 status code. Probably need to re-configure SDK with valid credentials.
case unauthorized
/// HTTP URL for the request not a valid URL.
case urlConstruction(_ url: String)
/// A response came back, but status code > 300 and not handled already (example: 401)
case unsuccessfulStatusCode(_ code: Int)
/// Request was not able to even make a request.
case noResponse
/// An error happened to prevent the request from happening. Check the `description` to get the underlying error.
case underlyingError(_ error: Error)
}

extension HttpRequestError: CustomStringConvertible {
/// Custom description for the Error to describe the error that happened.
public var description: String {
switch self {
case .unauthorized: return "HTTP request responded with 401. Configure the SDK with valid credentials."
case .urlConstruction(let url): return "HTTP URL not a valid URL: \(url)"
case .unsuccessfulStatusCode(let code): return "Response received, but status code > 300 (\(String(code)))"
case .noResponse: return "No response was returned from server."
case .underlyingError(let error): return error.localizedDescription
}
}
}
48 changes: 48 additions & 0 deletions Sources/SDK/Service/HttpRequestRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/**
Exists to be able to mock http requests so we can test our HttpClient's response handling logic.
*/
internal protocol HttpRequestRunner: AutoMockable {
func getUrl(endpoint: HttpEndpoint, region: Region) -> URL?
func request(_ params: RequestParams, _ onComplete: @escaping (Data?, HTTPURLResponse?, Error?) -> Void)
}

internal class UrlRequestHttpRequestRunner: HttpRequestRunner {
private let session: URLSession

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

func getUrl(endpoint: HttpEndpoint, region: Region) -> URL? {
endpoint.getUrl(region)
}

func request(_ params: RequestParams, _ onComplete: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
var request = URLRequest(url: params.url)
request.httpMethod = params.method
request.httpBody = params.body
params.headers?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}

session.dataTask(with: request) { data, response, error in
onComplete(data, response as? HTTPURLResponse, error)
}.resume()
}
}

/**
Using struct to avoid having a request function with lots of parameters.
This makes a request function easier to mock in tests.
*/
internal struct RequestParams {
let method: String
let url: URL
let headers: HttpHeaders?
let body: Data?
}
80 changes: 80 additions & 0 deletions Sources/SDK/autogenerated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// swiftlint:disable all

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/**
######################################################
Expand Down Expand Up @@ -63,6 +66,83 @@ import Foundation

*/

class HttpClientMock: HttpClient {
var mockCalled: Bool = false // if *any* interactions done on mock. Sets/gets or methods called.

// MARK: - request

var requestHeadersBodyOnCompleteCallsCount = 0
var requestHeadersBodyOnCompleteCalled: Bool {
requestHeadersBodyOnCompleteCallsCount > 0
}

var requestHeadersBodyOnCompleteReceivedArguments: (endpoint: HttpEndpoint, headers: HttpHeaders?, body: Data?,
onComplete: (Result<Data, HttpRequestError>) -> Void)?
var requestHeadersBodyOnCompleteReceivedInvocations: [(endpoint: HttpEndpoint, headers: HttpHeaders?, body: Data?,
onComplete: (Result<Data, HttpRequestError>) -> Void)] = []
var requestHeadersBodyOnCompleteClosure: ((HttpEndpoint, HttpHeaders?, Data?,
@escaping (Result<Data, HttpRequestError>) -> Void) -> Void)?

func request(
_ endpoint: HttpEndpoint,
headers: HttpHeaders?,
body: Data?,
onComplete: @escaping (Result<Data, HttpRequestError>) -> Void
) {
mockCalled = true
requestHeadersBodyOnCompleteCallsCount += 1
requestHeadersBodyOnCompleteReceivedArguments = (endpoint: endpoint, headers: headers, body: body,
onComplete: onComplete)
requestHeadersBodyOnCompleteReceivedInvocations
.append((endpoint: endpoint, headers: headers, body: body, onComplete: onComplete))
requestHeadersBodyOnCompleteClosure?(endpoint, headers, body, onComplete)
}
}

class HttpRequestRunnerMock: HttpRequestRunner {
var mockCalled: Bool = false // if *any* interactions done on mock. Sets/gets or methods called.

// MARK: - getUrl

var getUrlEndpointRegionCallsCount = 0
var getUrlEndpointRegionCalled: Bool {
getUrlEndpointRegionCallsCount > 0
}

var getUrlEndpointRegionReceivedArguments: (endpoint: HttpEndpoint, region: Region)?
var getUrlEndpointRegionReceivedInvocations: [(endpoint: HttpEndpoint, region: Region)] = []
var getUrlEndpointRegionReturnValue: URL?
var getUrlEndpointRegionClosure: ((HttpEndpoint, Region) -> URL?)?

func getUrl(endpoint: HttpEndpoint, region: Region) -> URL? {
mockCalled = true
getUrlEndpointRegionCallsCount += 1
getUrlEndpointRegionReceivedArguments = (endpoint: endpoint, region: region)
getUrlEndpointRegionReceivedInvocations.append((endpoint: endpoint, region: region))
return getUrlEndpointRegionClosure.map { $0(endpoint, region) } ?? getUrlEndpointRegionReturnValue
}

// MARK: - request

var requestCallsCount = 0
var requestCalled: Bool {
requestCallsCount > 0
}

var requestReceivedArguments: (params: RequestParams, onComplete: (Data?, HTTPURLResponse?, Error?) -> Void)?
var requestReceivedInvocations: [(params: RequestParams, onComplete: (Data?, HTTPURLResponse?, Error?) -> Void)] =
[]
var requestClosure: ((RequestParams, @escaping (Data?, HTTPURLResponse?, Error?) -> Void) -> Void)?

func request(_ params: RequestParams, _ onComplete: @escaping (Data?, HTTPURLResponse?, Error?) -> Void) {
mockCalled = true
requestCallsCount += 1
requestReceivedArguments = (params: params, onComplete: onComplete)
requestReceivedInvocations.append((params: params, onComplete: onComplete))
requestClosure?(params, onComplete)
}
}

class KeyValueStorageMock: KeyValueStorage {
var mockCalled: Bool = false // if *any* interactions done on mock. Sets/gets or methods called.

Expand Down
3 changes: 3 additions & 0 deletions Sources/Templates/AutoMockable.stencil
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// swiftlint:disable all

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/**
######################################################
Expand Down
Loading