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
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ public final class AuthenticationInterceptor<AuthenticatorType: Authenticator>:

public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {
await lock.lock()
defer { lock.unlock() }

if let credential = await authenticator.getCredential() {
if let refreshableCredential = credential as? RefreshableCredential, refreshableCredential.requiresRefresh {
try await refresh(credential: credential)
}
try await authenticator.apply(credential: credential, to: &urlRequest)
}
lock.unlock()
}

public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult {
Expand Down
34 changes: 30 additions & 4 deletions Sources/GoodNetworking/Interception/Interceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import Foundation

// MARK: - Interceptor

/// Interceptor merges request adaptation and retry behavior.
public protocol Interceptor: Adapter, Retrier {}

// MARK: - Default interceptor

/// Default interceptor does not adapt (modify) requests in any way.
/// Default retrying behaviour is applied as per RFC9110 specification.
///
/// - warning: Retrying is currently not implemented and all requests
/// are resolved as `.doNotRetry`.
public final class DefaultInterceptor: Interceptor {

public init() {}
Expand All @@ -28,22 +34,42 @@ public final class DefaultInterceptor: Interceptor {

// MARK: - Composite interceptor

/// Merges multiple interceptors, adapters and retriers into single interceptor instance.
///
/// Adapters have priority over general interceptors and are executed first when adapting
/// requests. All adapters execute in order they are passed in at initialization.
///
/// Retriers have priority over general interceptors and are executed first when retrying
/// requests. The first retrier to allow retrying the request is used, rest are not executed.
///
/// This behaviour effectively accomplishes that request authentication is executed
/// last, and requests are retried if a specific retrier allows it.
public final class CompositeInterceptor: Interceptor {

private let interceptors: [Interceptor]

public init(interceptors: [Interceptor]) {
private let adapters: [Adapter]
private let retriers: [Retrier]

public init(
interceptors: [Interceptor] = [],
adapters: [Adapter] = [],
retriers: [Retrier] = []
) {
self.interceptors = interceptors
self.adapters = adapters
self.retriers = retriers
}

public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {
for adapter in interceptors {
let allAdapters: [Adapter] = adapters + interceptors
for adapter in allAdapters {
try await adapter.adapt(urlRequest: &urlRequest)
}
}

public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult {
for retrier in interceptors {
let allRetriers: [Retrier] = retriers + interceptors
for retrier in allRetriers {
let retryResult = try await retrier.retry(urlRequest: &urlRequest, for: session, dueTo: error)
switch retryResult {
case .doNotRetry:
Expand Down
8 changes: 8 additions & 0 deletions Sources/GoodNetworking/Interception/Retrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ public protocol Retrier: Sendable {

// MARK: - Retry result

/// Result of a retry operation.
///
/// See ``Retrier``.
public enum RetryResult: Sendable {

/// Request will not be retried
case doNotRetry

/// Request will be retried only after the specified time interval has passed
case retryAfter(TimeInterval)

/// Request will be retried immediately
case retry

}
4 changes: 3 additions & 1 deletion Sources/GoodNetworking/Models/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ public extension Endpoint {
let path = await path.resolveUrl()

guard let baseUrl, let path else { return nil }
return baseUrl.appendingPathComponent(path.absoluteString)

// merge URLs as strings to avoid URL escaping
return URL(baseUrl.absoluteString + path.absoluteString)
}

}
Expand Down
6 changes: 6 additions & 0 deletions Sources/GoodNetworking/Models/EndpointBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public extension EndpointBuilder {
return self
}

func query(_ items: URLQueryItem...) -> Self {
assertBothQueryAndBodyUsage()
self.parameters = .query(items)
return self
}

func query(_ items: [URLQueryItem]) -> Self {
assertBothQueryAndBodyUsage()
self.parameters = .query(items)
Expand Down
130 changes: 130 additions & 0 deletions Tests/GoodNetworkingTests/NetworkSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,134 @@
//

@testable import GoodNetworking
import Testing
import Foundation
import Sextant
import Hitch

let session = NetworkSession(baseUrl: "https://dummyjson.com")

// MARK: - Decoding

@Test func decodeDynamicPosts() async throws {
let responseData = try await session.get("/products?limit=1000") as Data

print(responseData.count)

measure {
let jsonResponse = JSON(responseData)
print(jsonResponse.products[100].title.string as Any)
print(jsonResponse.products[100]["description"].string as Any)
print(jsonResponse.products[100].price.double as Any)
print(jsonResponse.products[100].reviews.array?.first?.comment.string as Any)
print(jsonResponse.products[100].images.array?.first?.string as Any)
}

measure {
let structResponse = try? JSONDecoder().decode(ProductsResponse.self, from: responseData)
print(structResponse?.products[100].title as Any)
print(structResponse?.products[100].description as Any)
print(structResponse?.products[100].price as Any)
print(structResponse?.products[100].reviews?.first?.comment as Any)
print(structResponse?.products[100].images?.first as Any)
}

// measure {
// let results = Sextant.shared.query(responseData, values: Hitch(string: "$.products..[?(@.price>10)]..['title', 'description', 'price']")) as [String]?
// print(results as Any)
// }

}

func measure(_ block: () -> ()) {
var duration: UInt64 = 0
for _ in 0..<50 {
let startTime: UInt64 = mach_absolute_time()
block()
let finishTime: UInt64 = mach_absolute_time()
let timeDelta = (finishTime - startTime) / 1000
duration += timeDelta
}

let averageDuration = duration / 50
print(averageDuration, "us")
}

struct ProductsResponse: Decodable {

struct Product: Decodable {
let id: Int
let title: String?
let description: String?
let category: String?
let price: Double?
let discountPercentage: Double?
let rating: Double?
let stock: Int?
let tags: [String]?
let brand: String?
let sku: String?
let weight: Double?
let dimensions: Dimensions?
let warrantyInformation: String?
let shippingInformation: String?
let availabilityStatus: String?
let reviews: [Review]?
let returnPolicy: String?
let minimumOrderQuantity: Int?
let meta: Meta?
let images: [String]?
let thumbnail: String?
}

struct Dimensions: Decodable {
let width: Double?
let height: Double?
let depth: Double?
}

struct Review: Decodable {
let rating: Int?
let comment: String?
let date: String?
let reviewerName: String?
let reviewerEmail: String?
}

struct Meta: Decodable {
let createdAt: String?
let updatedAt: String?
let barcode: String?
let qrCode: String?
}

let products: [Product]

}

// MARK: - Encoding

@Test func encodeDynamicJSON() async throws {
let newUser = NewUserRequest(
name: "Alice",
email: "alice@example.com",
age: 30
)

let newUserJson = [
"name": "Alice",
"email": "alice@example.com",
"age": 30
] as JSON

_ = try await session.post("/users", newUser) as JSON
_ = try await session.post("/users", newUserJson) as JSON
}

struct NewUserRequest: Encodable {

let name: String
let email: String
let age: Int

}