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

AuthV2 / Adding v2 classes #1194

Merged
merged 19 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
37 changes: 37 additions & 0 deletions Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Dictionary+URLQueryItem.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import Common

extension Dictionary where Key == String, Value == String {

/// Convert a Dictionary key:String, value:String into an array of URLQueryItem ordering alphabetically the items by key
/// - Returns: An ordered array of URLQueryItem
public func toURLQueryItems(allowedReservedCharacters: CharacterSet? = nil) -> [URLQueryItem] {
return self.sorted(by: <).map {
if let allowedReservedCharacters {
URLQueryItem(percentEncodingName: $0.key,
value: $0.value,
withAllowedCharacters: allowedReservedCharacters)
} else {
URLQueryItem(name: $0.key, value: $0.value)
}
}
}
}
20 changes: 20 additions & 0 deletions Sources/NetworkingTestingUtils/APIMockResponseFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ public struct APIMockResponseFactory {
}
}

public static func mockRefreshAccessTokenResponse(destinationMockAPIService apiService: MockAPIService, success: Bool) {
let request = OAuthRequest.refreshAccessToken(baseURL: OAuthEnvironment.staging.url,
clientID: "clientID",
refreshToken: "someExpiredToken")!
if success {
let jsonString = """
{"access_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJxWHk2TlRjeEI2UkQ0UUtSU05RYkNSM3ZxYU1SQU1RM1Q1UzVtTWdOWWtCOVZTVnR5SHdlb1R4bzcxVG1DYkJKZG1GWmlhUDVWbFVRQnd5V1dYMGNGUjo3ZjM4MTljZi0xNTBmLTRjYjEtOGNjNy1iNDkyMThiMDA2ZTgiLCJzY29wZSI6InByaXZhY3lwcm8iLCJhdWQiOiJQcml2YWN5UHJvIiwic3ViIjoiZTM3NmQ4YzQtY2FhOS00ZmNkLThlODYtMTlhNmQ2M2VlMzcxIiwiZXhwIjoxNzMwMzAxNTcyLCJlbWFpbCI6bnVsbCwiaWF0IjoxNzMwMjg3MTcyLCJpc3MiOiJodHRwczovL3F1YWNrZGV2LmR1Y2tkdWNrZ28uY29tIiwiZW50aXRsZW1lbnRzIjpbXSwiYXBpIjoidjIifQ.wOYgz02TXPJjDcEsp-889Xe1zh6qJG0P1UNHUnFBBELmiWGa91VQpqdl41EOOW3aE89KGvrD8YphRoZKiA3nHg",
"refresh_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOiJ2MiIsImlzcyI6Imh0dHBzOi8vcXVhY2tkZXYuZHVja2R1Y2tnby5jb20iLCJleHAiOjE3MzI4NzkxNzIsInN1YiI6ImUzNzZkOGM0LWNhYTktNGZjZC04ZTg2LTE5YTZkNjNlZTM3MSIsImF1ZCI6IkF1dGgiLCJpYXQiOjE3MzAyODcxNzIsInNjb3BlIjoicmVmcmVzaCIsImp0aSI6InFYeTZOVGN4QjZSRDRRS1JTTlFiQ1IzdnFhTVJBTVEzVDVTNW1NZ05Za0I5VlNWdHlId2VvVHhvNzFUbUNiQkpkbUZaaWFQNVZsVVFCd3lXV1gwY0ZSOmU2ODkwMDE5LWJmMDUtNGQxZC04OGFhLThlM2UyMDdjOGNkOSJ9.OQaGCmDBbDMM5XIpyY-WCmCLkZxt5Obp4YAmtFP8CerBSRexbUUp6SNwGDjlvCF0-an2REBsrX92ZmQe5ewqyQ","expires_in": 14400,"token_type": "Bearer"}
"""
let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!,
statusCode: request.httpSuccessCode.rawValue,
httpVersion: nil,
headerFields: [:])!
let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse)
apiService.set(response: response, forRequest: request.apiRequest)
} else {
setErrorResponse(forRequest: request.apiRequest, apiService: apiService)
}
}

public static func mockGetJWKS(destinationMockAPIService apiService: MockAPIService, success: Bool) {
let request = OAuthRequest.jwks(baseURL: OAuthEnvironment.staging.url)!
if success {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio

private let statisticsStore: StatisticsStore
private let vpnActivationDateStore: VPNActivationDateProviding
private let subscription: Subscription?
private let subscription: PrivacyProSubscription?
private let localeIdentifier: String

public init(statisticsStore: StatisticsStore,
vpnActivationDateStore: VPNActivationDateProviding,
subscription: Subscription?,
subscription: PrivacyProSubscription?,
localeIdentifier: String = Locale.current.identifier) {
self.statisticsStore = statisticsStore
self.vpnActivationDateStore = vpnActivationDateStore
Expand Down Expand Up @@ -134,7 +134,7 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio

}

extension Subscription {
extension PrivacyProSubscription {
var privacyProStatusSurveyParameter: String {
switch status {
case .autoRenewable:
Expand Down
7 changes: 4 additions & 3 deletions Sources/Subscription/API/AuthEndpointService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import Foundation
import Common
import Networking

public struct AccessTokenResponse: Decodable {
public let accessToken: String
Expand Down Expand Up @@ -68,9 +69,9 @@ public protocol AuthEndpointService {

public struct DefaultAuthEndpointService: AuthEndpointService {
private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment
private let apiService: APIService
private let apiService: SubscriptionAPIService

public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) {
public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: SubscriptionAPIService) {
self.currentServiceEnvironment = currentServiceEnvironment
self.apiService = apiService
}
Expand All @@ -79,7 +80,7 @@ public struct DefaultAuthEndpointService: AuthEndpointService {
self.currentServiceEnvironment = currentServiceEnvironment
let baseURL = currentServiceEnvironment == .production ? URL(string: "https://quack.duckduckgo.com/api/auth")! : URL(string: "https://quackdev.duckduckgo.com/api/auth")!
let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
self.apiService = DefaultAPIService(baseURL: baseURL, session: session)
self.apiService = DefaultSubscriptionAPIService(baseURL: baseURL, session: session)
}

public func getAccessToken(token: String) async -> Result<AccessTokenResponse, APIServiceError> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Subscription.swift
// PrivacyProSubscription.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
Expand All @@ -17,18 +17,20 @@
//

import Foundation
import Networking

public typealias DDGSubscription = Subscription // to avoid conflicts when Combine is imported

public struct Subscription: Codable, Equatable {
public struct PrivacyProSubscription: Codable, Equatable, CustomDebugStringConvertible {
public let productId: String
public let name: String
public let billingPeriod: Subscription.BillingPeriod
public let billingPeriod: BillingPeriod
public let startedAt: Date
public let expiresOrRenewsAt: Date
public let platform: Subscription.Platform
public let platform: Platform
public let status: Status

/// Not parsed from
public var features: [SubscriptionEntitlement]?

Comment on lines +31 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose for this var if it is not a part of parsed model?

Copy link
Member Author

@federicocappelli federicocappelli Jan 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SubscriptionEntitlement are created with a combination of (A) user entitlements and (B) subscription entitlements, both A and B are fetched after the subscription is fetched and saved here, and then the entire subscription is saved in the cache. So this is a way of saving the features without overcomplicating with separate models and handling logic.

public enum BillingPeriod: String, Codable {
case monthly = "Monthly"
case yearly = "Yearly"
Expand Down Expand Up @@ -64,4 +66,26 @@ public struct Subscription: Codable, Equatable {
public var isActive: Bool {
status != .expired && status != .inactive
}

public var debugDescription: String {
return """
Subscription:
- Product ID: \(productId)
- Name: \(name)
- Billing Period: \(billingPeriod.rawValue)
- Started At: \(formatDate(startedAt))
- Expires/Renews At: \(formatDate(expiresOrRenewsAt))
- Platform: \(platform.rawValue)
- Status: \(status.rawValue)
- Features: \(features?.map { $0.debugDescription } ?? [])
"""
}

private func formatDate(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
dateFormatter.timeZone = TimeZone.current
return dateFormatter.string(from: date)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// APIService.swift
// SubscriptionAPIService.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
Expand Down Expand Up @@ -32,7 +32,7 @@ struct ErrorResponse: Decodable {
let error: String
}

public protocol APIService {
public protocol SubscriptionAPIService {
func executeAPICall<T>(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result<T, APIServiceError> where T: Decodable
func makeAuthorizationHeader(for token: String) -> [String: String]
}
Expand All @@ -43,7 +43,7 @@ public enum APICachePolicy {
case returnCacheDataDontLoad
}

public struct DefaultAPIService: APIService {
public struct DefaultSubscriptionAPIService: SubscriptionAPIService {
private let baseURL: URL
private let session: URLSession

Expand Down
50 changes: 25 additions & 25 deletions Sources/Subscription/API/SubscriptionEndpointService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,27 @@

import Common
import Foundation

public struct GetProductsItem: Decodable {
public let productId: String
public let productLabel: String
public let billingPeriod: String
public let price: String
public let currency: String
}
//
// public struct GetProductsItem: Decodable {
// public let productId: String
// public let productLabel: String
// public let billingPeriod: String
// public let price: String
// public let currency: String
// }

public struct GetSubscriptionFeaturesResponse: Decodable {
public let features: [Entitlement.ProductName]
}

public struct GetCustomerPortalURLResponse: Decodable {
public let customerPortalUrl: String
}
// public struct GetCustomerPortalURLResponse: Decodable {
// public let customerPortalUrl: String
// }

public struct ConfirmPurchaseResponse: Decodable {
public let email: String?
public let entitlements: [Entitlement]
public let subscription: Subscription
public let subscription: PrivacyProSubscription
}

public enum SubscriptionServiceError: Error {
Expand All @@ -47,8 +47,8 @@ public enum SubscriptionServiceError: Error {
}

public protocol SubscriptionEndpointService {
func updateCache(with subscription: Subscription)
func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result<Subscription, SubscriptionServiceError>
func updateCache(with subscription: PrivacyProSubscription)
func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result<PrivacyProSubscription, SubscriptionServiceError>
func signOut()
func getProducts() async -> Result<[GetProductsItem], APIServiceError>
func getSubscriptionFeatures(for subscriptionID: String) async -> Result<GetSubscriptionFeaturesResponse, APIServiceError>
Expand All @@ -73,19 +73,19 @@ public protocol SubscriptionEndpointService {

extension SubscriptionEndpointService {

public func getSubscription(accessToken: String) async -> Result<Subscription, SubscriptionServiceError> {
public func getSubscription(accessToken: String) async -> Result<PrivacyProSubscription, SubscriptionServiceError> {
await getSubscription(accessToken: accessToken, cachePolicy: .returnCacheDataElseLoad)
}
}

/// Communicates with our backend
public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {
private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment
private let apiService: APIService
private let subscriptionCache = UserDefaultsCache<Subscription>(key: UserDefaultsCacheKey.subscription,
settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))
private let apiService: SubscriptionAPIService
private let subscriptionCache = UserDefaultsCache<PrivacyProSubscription>(key: UserDefaultsCacheKey.subscription,
settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))

public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) {
public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: SubscriptionAPIService) {
self.currentServiceEnvironment = currentServiceEnvironment
self.apiService = apiService
}
Expand All @@ -94,14 +94,14 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {
self.currentServiceEnvironment = currentServiceEnvironment
let baseURL = currentServiceEnvironment == .production ? URL(string: "https://subscriptions.duckduckgo.com/api")! : URL(string: "https://subscriptions-dev.duckduckgo.com/api")!
let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
self.apiService = DefaultAPIService(baseURL: baseURL, session: session)
self.apiService = DefaultSubscriptionAPIService(baseURL: baseURL, session: session)
}

// MARK: - Subscription fetching with caching

private func getRemoteSubscription(accessToken: String) async -> Result<Subscription, SubscriptionServiceError> {
private func getRemoteSubscription(accessToken: String) async -> Result<PrivacyProSubscription, SubscriptionServiceError> {

let result: Result<Subscription, APIServiceError> = await apiService.executeAPICall(method: "GET", endpoint: "subscription", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil)
let result: Result<PrivacyProSubscription, APIServiceError> = await apiService.executeAPICall(method: "GET", endpoint: "subscription", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil)
switch result {
case .success(let subscriptionResponse):
updateCache(with: subscriptionResponse)
Expand All @@ -111,16 +111,16 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService {
}
}

public func updateCache(with subscription: Subscription) {
public func updateCache(with subscription: PrivacyProSubscription) {

let cachedSubscription: Subscription? = subscriptionCache.get()
let cachedSubscription = subscriptionCache.get()
if subscription != cachedSubscription {
subscriptionCache.set(subscription)
NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscription: subscription])
}
}

public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result<Subscription, SubscriptionServiceError> {
public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result<PrivacyProSubscription, SubscriptionServiceError> {

switch cachePolicy {
case .reloadIgnoringLocalCacheData:
Expand Down
Loading
Loading