Skip to content

Commit

Permalink
feat: HTTPRequestData improvements (#33)
Browse files Browse the repository at this point in the history
Co-authored-by: danthorpe <[email protected]>
  • Loading branch information
danthorpe and danthorpe authored Oct 25, 2023
1 parent 36b6065 commit d795c1d
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 199 deletions.
21 changes: 21 additions & 0 deletions Sources/Helpers/RangeReplaceableCollection+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

extension Optional where Wrapped: RangeReplaceableCollection {
public mutating func append(_ newElement: Wrapped.Element, default defaultValue: Wrapped) {
switch self {
case var .some(this):
this.append(newElement)
self = .some(this)
case .none:
var copy = defaultValue
copy.append(newElement)
self = .some(copy)
}
}
}

extension Optional where Wrapped: RangeReplaceableCollection, Wrapped: ExpressibleByArrayLiteral {
public mutating func append(_ newElement: Wrapped.Element) {
append(newElement, default: [])
}
}
7 changes: 3 additions & 4 deletions Sources/Networking/Components/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import os.log

extension NetworkingComponent {

public func server(authority: String?) -> some NetworkingComponent {
public func server(authority: String) -> some NetworkingComponent {
server(mutate: \.authority) { _ in
authority
} log: { logger, request in
logger?.info("💁 authority -> '\(authority ?? "no value")' \(request.debugDescription)")
logger?.info("💁 authority -> '\(authority)' \(request.debugDescription)")
}
}

Expand Down Expand Up @@ -38,8 +38,7 @@ extension NetworkingComponent {

public func server(prefixPath: String, delimiter: String = "/") -> some NetworkingComponent {
server(mutate: \.path) { path in
guard let path else { return prefixPath }
return prefixPath + delimiter + path
return delimiter + prefixPath + path
} log: { logger, request in
logger?.info("💁 prefix path -> '\(prefixPath)' \(request.debugDescription)")
}
Expand Down
111 changes: 93 additions & 18 deletions Sources/Networking/Core/HTTPRequestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,72 @@ public struct HTTPRequestData: Sendable, Identifiable {
public typealias ID = Tagged<Self, String>
public let id: ID
public var body: Data?
public var queryItems: [URLQueryItem]? {
get { _queryItems }
set {
_queryItems = newValue
syncPathFromQueryItems()
}
}

fileprivate var _queryItems: [URLQueryItem]?
@Sanitized fileprivate var request: HTTPRequest
internal fileprivate(set) var options: [ObjectIdentifier: HTTPRequestDataOptionContainer] = [:]

public var identifier: String {
id.rawValue
}

public subscript<Value>(
dynamicMember dynamicMember: WritableKeyPath<HTTPRequest, Value>
) -> Value {
get { $request[keyPath: dynamicMember] }
set { $request[keyPath: dynamicMember] = newValue }
public var method: HTTPRequest.Method {
get { request.method }
set { $request.method = newValue }
}

public var scheme: String {
get { request.scheme ?? Defaults.scheme }
set { $request.scheme = newValue }
}

public var authority: String {
get { request.authority ?? Defaults.authority }
set { $request.authority = newValue }
}

public var path: String {
get { request.path ?? Defaults.path }
set {
$request.path = newValue
syncQueryItemsFromPath()
}
}

public var headerFields: HTTPFields {
get { request.headerFields }
set { $request.headerFields = newValue }
}

/// Get/Set the first query parameter
public subscript(
dynamicMember key: String
) -> String? {
get {
queryItems?.first(where: { $0.name == key })?.value
}
set {
guard let newValue else {
queryItems?.removeAll(where: { $0.name == key })
return
}
queryItems.append(URLQueryItem(name: key, value: newValue))
}
}

init(
id: ID,
method: HTTPRequest.Method = .get,
scheme: String? = "https",
authority: String? = nil,
path: String? = nil,
method: HTTPRequest.Method = Defaults.method,
scheme: String = Defaults.scheme,
authority: String = Defaults.authority,
path: String = Defaults.path,
headerFields: HTTPFields = [:],
body: Data? = nil
) {
Expand All @@ -49,10 +94,10 @@ public struct HTTPRequestData: Sendable, Identifiable {
}

public init(
method: HTTPRequest.Method = .get,
scheme: String? = "https",
authority: String? = nil,
path: String? = nil,
method: HTTPRequest.Method = Defaults.method,
scheme: String = Defaults.scheme,
authority: String = Defaults.authority,
path: String = Defaults.path,
headerFields: HTTPFields = [:],
body: Data? = nil
) {
Expand All @@ -69,10 +114,10 @@ public struct HTTPRequestData: Sendable, Identifiable {
}

public init(
method: HTTPRequest.Method = .get,
scheme: String? = "https",
authority: String? = nil,
path: String? = nil,
method: HTTPRequest.Method = Defaults.method,
scheme: String = Defaults.scheme,
authority: String = Defaults.authority,
path: String = Defaults.path,
headerFields: HTTPFields = [:],
body: any HTTPRequestBody
) throws {
Expand All @@ -93,6 +138,34 @@ public struct HTTPRequestData: Sendable, Identifiable {
body: data
)
}

public enum Defaults {
public static let method: HTTPRequest.Method = .get
public static let scheme = "https"
public static let authority = "example.com"
public static let path = "/"
}

internal mutating func syncQueryItemsFromPath() {
guard let url = $request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
assertionFailure("Unable to create URL or URLComponents needed to set the query")
return
}
_queryItems = components.queryItems
}

internal mutating func syncPathFromQueryItems() {
guard let url = $request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
assertionFailure("Unable to create URL or URLComponents needed to set the query")
return
}
components.queryItems = _queryItems
guard let url = components.url else {
assertionFailure("Unable to create URL after settings queryItems")
return
}
$request.url = url
}
}

// MARK: - Options
Expand Down Expand Up @@ -188,7 +261,9 @@ extension HTTPRequest {
fileprivate mutating func sanitize() {
// Trim any trailing / from authority
authority = authority?.trimSlashSuffix()
// Ensure there is a single / on the path
// Remove any trailing slashes from the path
path = path?.trimSlashSuffix()
// Ensure there is a single / on the start of path
if let trimmedPath = path?.trimSlashPrefix(), !trimmedPath.isEmpty {
path = "/" + trimmedPath
}
Expand Down
52 changes: 23 additions & 29 deletions Tests/NetworkingTests/Components/CheckedStatusCodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ import XCTest

final class CheckedStatusCodeTests: XCTestCase {

override func invokeTest() {
withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
withMainSerialExecutor {
super.invokeTest()
}
}
}

func configureNetwork(
for status: HTTPResponse.Status
) -> (network: some NetworkingComponent, response: HTTPResponseData) {
Expand All @@ -17,44 +28,27 @@ final class CheckedStatusCodeTests: XCTestCase {
let network = TerminalNetworkingComponent()
.mocked(request, stub: stubbed)
.checkedStatusCode()

return (network, stubbed.expectedResponse(request))
}

func test__ok() async throws {
try await withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
let (network, expectedResponse) = configureNetwork(for: .ok)
try await network.data(expectedResponse.request)
}
let (network, expectedResponse) = configureNetwork(for: .ok)
try await network.data(expectedResponse.request)
}

func test__internal_server_error() async throws {
try await withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
let (network, expectedResponse) = configureNetwork(for: .internalServerError)
await XCTAssertThrowsError(
try await network.data(expectedResponse.request),
matches: StackError.statusCode(expectedResponse)
)
}
let (network, expectedResponse) = configureNetwork(for: .internalServerError)
await XCTAssertThrowsError(
try await network.data(expectedResponse.request),
matches: StackError.statusCode(expectedResponse)
)
}

func test__unauthorized() async throws {
try await withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
let (network, expectedResponse) = configureNetwork(for: .unauthorized)
await XCTAssertThrowsError(
try await network.data(expectedResponse.request),
matches: StackError.unauthorized(expectedResponse)
)
}
let (network, expectedResponse) = configureNetwork(for: .unauthorized)
await XCTAssertThrowsError(
try await network.data(expectedResponse.request),
matches: StackError.unauthorized(expectedResponse)
)
}

}
86 changes: 45 additions & 41 deletions Tests/NetworkingTests/Components/DuplicatesRemovedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,61 @@ import XCTest

final class DuplicatesRemovedTests: XCTestCase {

override func invokeTest() {
withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
withMainSerialExecutor {
super.invokeTest()
}
}
}

func test__duplicates_removed() async throws {
let data1 = try XCTUnwrap("Hello".data(using: .utf8))
let data2 = try XCTUnwrap("World".data(using: .utf8))
let data3 = try XCTUnwrap("Whoops".data(using: .utf8))

let reporter = TestReporter()

try await withDependencies {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
try await withMainSerialExecutor {
let request1 = HTTPRequestData(authority: "example.com")
let request2 = HTTPRequestData(authority: "example.co.uk")
let request3 = HTTPRequestData(authority: "example.com", path: "/error")
let request4 = HTTPRequestData(authority: "example.com") // actually the same endpoint as request 1

let network = TerminalNetworkingComponent()
.mocked(request1, stub: .ok(data: data1))
.mocked(request2, stub: .ok(data: data2))
.mocked(request3, stub: .ok(data: data3))
.mocked(request4, stub: .ok(data: data1))
.reported(by: reporter)
.duplicatesRemoved()

try await withThrowingTaskGroup(of: HTTPResponseData.self) { group in
for _ in 0 ..< 4 {
group.addTask {
try await network.data(request1)
}
group.addTask {
try await network.data(request2)
}
group.addTask {
try await network.data(request3)
}
group.addTask {
try await network.data(request4)
}
}

var responses: [HTTPResponseData] = []
for try await response in group {
responses.append(response)
}
XCTAssertEqual(responses.count, 16)
let request1 = HTTPRequestData(authority: "example.com")
let request2 = HTTPRequestData(authority: "example.co.uk")
let request3 = HTTPRequestData(authority: "example.com", path: "/error")
let request4 = HTTPRequestData(authority: "example.com") // actually the same endpoint as request 1

let network = TerminalNetworkingComponent()
.mocked(request1, stub: .ok(data: data1))
.mocked(request2, stub: .ok(data: data2))
.mocked(request3, stub: .ok(data: data3))
.mocked(request4, stub: .ok(data: data1))
.reported(by: reporter)
.duplicatesRemoved()

try await withThrowingTaskGroup(of: HTTPResponseData.self) { group in
for _ in 0 ..< 4 {
group.addTask {
try await network.data(request1)
}
group.addTask {
try await network.data(request2)
}
group.addTask {
try await network.data(request3)
}
group.addTask {
try await network.data(request4)
}
}

let reportedRequests = await reporter.requests
XCTAssertEqual(reportedRequests.count, 3)
var responses: [HTTPResponseData] = []
for try await response in group {
responses.append(response)
}
XCTAssertEqual(responses.count, 16)
}

let reportedRequests = await reporter.requests
XCTAssertEqual(reportedRequests.count, 3)
}
}
4 changes: 2 additions & 2 deletions Tests/NetworkingTests/Components/RetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ final class RetryTests: XCTestCase {
$0.shortID = .incrementing
$0.continuousClock = clock
} operation: {
let request = HTTPRequestData(authority: "example.com")
let request = HTTPRequestData()

let network = TerminalNetworkingComponent()
.mocked { upstream, request in
Expand All @@ -68,7 +68,7 @@ final class RetryTests: XCTestCase {
$0.shortID = .incrementing
$0.continuousClock = TestClock()
} operation: {
var request = HTTPRequestData(authority: "example.com")
var request = HTTPRequestData()
request.retryingStrategy = nil
}
}
Expand Down
Loading

0 comments on commit d795c1d

Please sign in to comment.