From 71601d1cc3d2869bd561096f0159328a5dc16218 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:28:06 +0200 Subject: [PATCH 01/12] Lint with oida, and take the reformat its --fix produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oida (github.com/kolonialno/oida-swift) is our own linter: fifteen rules decide the shape of every argument list, and the ones this repository opts into are in .oida.yml. bin/lint fetches the pinned release, checksums it and runs it, then hands the files to swift-format, which owns every indent. There is no baseline. A standing violation would be silent under one, and this repository adopted oida to find what it says rather than to record that it once said something — so the tree goes to zero and stays there. This commit is the mechanical half: what --fix produces, 438 violations down to 278. The suite is 180/180 green against go-httpbin, so the reformat changed shape and nothing else. The remainder is hand work, and the gate goes on once it is at zero. --- .oida-checksum-linux-x86_64 | 1 + .oida-checksum-macos-arm64 | 1 + .oida-version | 1 + .oida.yml | 131 ++++++ Package.swift | 11 +- Sources/Networking/CacheStore.swift | 41 +- Sources/Networking/DownloadResponse.swift | 49 ++- Sources/Networking/FakeRequest.swift | 14 +- Sources/Networking/FormDataPart.swift | 7 +- Sources/Networking/HTTPInterceptor.swift | 27 +- Sources/Networking/Helpers.swift | 22 +- Sources/Networking/Image.swift | 6 +- .../Networking/Networking+HTTPRequests.swift | 401 ++++++++++++++---- Sources/Networking/Networking+New.swift | 134 ++++-- Sources/Networking/Networking+Private.swift | 116 ++++- Sources/Networking/Networking.swift | 18 +- Sources/Networking/NetworkingError.swift | 12 +- Sources/Networking/NetworkingEvent.swift | 20 +- ...thRefreshInterceptorIntegrationTests.swift | 6 +- ...hRefreshSingleFlightIntegrationTests.swift | 6 +- Tests/NetworkingTests/CacheStoreTests.swift | 107 ++++- ...CacheWriteAndDataBodyRegressionTests.swift | 21 +- .../CancellationIntegrationTests.swift | 2 +- .../DELETEIntegrationTests.swift | 2 +- .../Dictionary+FormURLEncodedTests.swift | 2 +- .../DownloadEventsIntegrationTests.swift | 12 +- .../DownloadIntegrationTests.swift | 26 +- .../DownloadInterceptorIntegrationTests.swift | 6 +- .../EncodableFormQueryIntegrationTests.swift | 21 +- Tests/NetworkingTests/FakeRequestTests.swift | 162 +++++-- .../NetworkingTests/GETIntegrationTests.swift | 2 +- Tests/NetworkingTests/GETTests.swift | 20 +- .../NetworkingIntegrationTests.swift | 2 +- Tests/NetworkingTests/NetworkingTests.swift | 2 +- .../NewNetworkingIntegrationTests.swift | 8 +- .../NetworkingTests/NewNetworkingTests.swift | 8 +- .../ObservabilityIntegrationTests.swift | 30 +- .../PATCHIntegrationTests.swift | 2 +- .../POSTIntegrationTests.swift | 34 +- .../NetworkingTests/PUTIntegrationTests.swift | 2 +- .../ResponseIntegrationTests.swift | 2 +- .../ResponseValidatorIntegrationTests.swift | 6 +- .../RetryInterceptorTests.swift | 79 +++- Tests/NetworkingTests/String+UTF8Tests.swift | 2 +- Tests/NetworkingTests/TestHelpers.swift | 6 +- .../TypedRequestBodyIntegrationTests.swift | 24 +- bin/lint | 112 +++++ bin/staged-swift-files | 20 + 48 files changed, 1441 insertions(+), 305 deletions(-) create mode 100644 .oida-checksum-linux-x86_64 create mode 100644 .oida-checksum-macos-arm64 create mode 100644 .oida-version create mode 100644 .oida.yml create mode 100755 bin/lint create mode 100755 bin/staged-swift-files diff --git a/.oida-checksum-linux-x86_64 b/.oida-checksum-linux-x86_64 new file mode 100644 index 0000000..51b0659 --- /dev/null +++ b/.oida-checksum-linux-x86_64 @@ -0,0 +1 @@ +b75a5e3843c3e4c47ae635691dbead66422c5727debcd5386d74952227930754 diff --git a/.oida-checksum-macos-arm64 b/.oida-checksum-macos-arm64 new file mode 100644 index 0000000..6375f19 --- /dev/null +++ b/.oida-checksum-macos-arm64 @@ -0,0 +1 @@ +cb984504dfda6efdd67771d08f13c041f52b26a9f148eeeae497b013910b3514 diff --git a/.oida-version b/.oida-version new file mode 100644 index 0000000..a803cc2 --- /dev/null +++ b/.oida-version @@ -0,0 +1 @@ +0.14.0 diff --git a/.oida.yml b/.oida.yml new file mode 100644 index 0000000..ae92ee6 --- /dev/null +++ b/.oida.yml @@ -0,0 +1,131 @@ +# The linter is oida (github.com/kolonialno/oida-swift), a SwiftLint fork carrying the rules we +# actually want. bin/lint fetches the pinned release and runs it; there is no baseline, so the tree +# is kept at zero and any violation fails. + +opt_in_rules: + # The shape of every argument list, parameter list and condition: three or more elements go one per + # line, two or fewer go on one line, and both directions hold. + - multiline_call_arguments + - multiline_parameters + - multiline_conditions + # Imports in three groups — Apple, ours, third-party — alphabetical inside each. Correctable. + - grouped_imports + - key_path_only_where_the_api_takes_one + - multiline_string_opens_on_its_own_line + # What gets written too much of: a helper with one caller that returns nothing, a comment whose every + # word is already in the code below it, a banner naming what the declarations already name. + - no_single_use_void_functions + - comment_adds_no_word + - no_mark_comments + # Auto-fixable and mechanical — `bin/lint --fix` applies these, no judgement needed. + - modifier_order + - self_binding + - toggle_bool + - optional_enum_case_matching + - period_spacing + - prefer_zero_over_explicit_init + - redundant_type_annotation + - explicit_init + - comma_inheritance + - final_test_case + - unneeded_override + - closure_spacing + - contains_over_first_not_nil + - fatal_error_message + - joined_default_parameter + - let_var_whitespace + - operator_usage_whitespace + - redundant_nil_coalescing + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - yoda_condition + - unhandled_throwing_task + - file_header + - force_cast + - force_try + - force_unwrapping + # VOICE.md's own contract, enforced on every root document. + - document_says_what_is + - document_avoids_retired_words + - document_links_resolve + +disabled_rules: + # `.swift-format` owns whitespace, and it is the formatter oida hands the files to, so these three + # would demand the alignment it removes. + - closure_end_indentation + - literal_expression_end_indentation + - vertical_parameter_alignment_on_call + # grouped_imports owns import order; this rule would flatten its groups into one alphabetical run. + - sorted_imports + - todo # work is tracked in PRs and LEARNINGS, not in-code markers + - unused_closure_parameter + # Length is not a defect. A long function is refactored for a reason, never for a count. + - file_length + - type_body_length + - line_length + - function_body_length + - large_tuple + - function_parameter_count + +excluded: + # Never lint vendored or generated code, wherever the build puts it. Matched by location, not path, + # so an in-repo `-derivedDataPath` does not drag SPM checkouts into scope. + - DerivedData + - build + - "**/.build" + - "**/SourcePackages" + - "**/GeneratedSources" + - "**/Intermediates.noindex" + +# Errors, not warnings: what these find is written by whoever is not reading the code around it, and a +# warning is what a reader scrolls past. +comment_adds_no_word: + severity: error +no_mark_comments: + severity: error +no_single_use_void_functions: + severity: error + +cyclomatic_complexity: + ignores_case_statements: true + +opening_brace: + # A condition spanning several lines gets its brace on its own line, so the body is not mistaken + # for another condition. That is what the formatter emits. + ignore_multiline_statement_conditions: true + +identifier_name: + min_length: 1 + max_length: 70 + +type_name: + max_length: 45 + +nesting: + type_level: + warning: 2 + +file_header: + severity: error + forbidden_pattern: 'Copyright © \d|Created by .+ on ' + +force_unwrapping: + severity: error + +multiline_call_arguments: + max_number_of_single_line_parameters: 2 + requires_single_line: true + +multiline_parameters: + max_number_of_single_line_parameters: 2 + requires_single_line: true + +multiline_conditions: + max_number_of_single_line_parameters: 2 + requires_single_line: true + +grouped_imports: + # This package ships one product and takes no dependencies, so "ours" is the module itself and its + # test target, which is only ever @testable-imported. + our_modules: + - Networking diff --git a/Package.swift b/Package.swift index c12bc43..04f725e 100644 --- a/Package.swift +++ b/Package.swift @@ -6,15 +6,12 @@ import PackageDescription let package = Package( name: "Networking", platforms: [ - .iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11) + .iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11), ], products: [ - .library( - name: "Networking", - targets: ["Networking"]), - ], - dependencies: [ + .library(name: "Networking", targets: ["Networking"]) ], + dependencies: [], targets: [ .target( name: "Networking", @@ -25,7 +22,7 @@ let package = Package( dependencies: ["Networking"], path: "Tests", resources: [.process("NetworkingTests/Resources")] - ) + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index ed22674..7c03f42 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -12,7 +12,11 @@ final class CacheStore: @unchecked Sendable { let expiry: CacheExpiry let folderName: String - init(memory: NSCache, ttl: Duration, folderName: String) { + init( + memory: NSCache, + ttl: Duration, + folderName: String + ) { self.memory = memory self.expiry = CacheExpiry(ttl: ttl) self.folderName = folderName @@ -42,7 +46,11 @@ final class CacheStore: @unchecked Sendable { let folderURL = cachesURL.appendingPathComponent(URL(string: folderPath)!.absoluteString) if FileManager.default.exists(at: folderURL) == false { - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory( + at: folderURL, + withIntermediateDirectories: true, + attributes: nil + ) } return cachesURL.appendingPathComponent(url.absoluteString) } @@ -78,7 +86,11 @@ final class CacheStore: @unchecked Sendable { /// A pure read: serve from the warm tier, falling back to a non-expired disk entry (which re-warms both /// tiers). Never mutates a tier except to drop an entry it finds expired. `.memory`/`.none` never touch /// disk, so a read can't destroy a durable copy written at `.memoryAndFile`. - func object(forResource resource: String, level: Networking.CachingLevel, asImage: Bool) throws -> Any? { + func object( + forResource resource: String, + level: Networking.CachingLevel, + asImage: Bool + ) throws -> Any? { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString switch level { @@ -121,7 +133,11 @@ final class CacheStore: @unchecked Sendable { // MARK: - Write - func storeData(_ data: Data?, forResource resource: String, level: Networking.CachingLevel) throws { + func storeData( + _ data: Data?, + forResource resource: String, + level: Networking.CachingLevel + ) throws { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString @@ -142,12 +158,19 @@ final class CacheStore: @unchecked Sendable { } @discardableResult - func storeImage(data: Data?, forResource resource: String, level: Networking.CachingLevel) throws -> Image? { + func storeImage( + data: Data?, + forResource resource: String, + level: Networking.CachingLevel + ) throws -> Image? { let destinationURL = try destinationURL(forResource: resource) let key = destinationURL.absoluteString var image: Image? - if let data = data, let nonOptionalImage = Image(data: data), data.count > 0 { + if let data = data, + let nonOptionalImage = Image(data: data), + data.count > 0 + { switch level { case .memory: memory.setObject(nonOptionalImage, forKey: key as AnyObject) @@ -235,6 +258,10 @@ final class CacheStore: @unchecked Sendable { } try? FileManager.default.createDirectory(at: domainURL, withIntermediateDirectories: true) - try? String(cursor &+ 1).write(to: cursorURL, atomically: true, encoding: .utf8) + try? String(cursor &+ 1).write( + to: cursorURL, + atomically: true, + encoding: .utf8 + ) } } diff --git a/Sources/Networking/DownloadResponse.swift b/Sources/Networking/DownloadResponse.swift index 790fc68..d94ec42 100644 --- a/Sources/Networking/DownloadResponse.swift +++ b/Sources/Networking/DownloadResponse.swift @@ -10,23 +10,39 @@ import Foundation /// envelope that also carries the status code and headers. The factory is how `downloadImage` /// builds the requested type — not meant to be called directly. public protocol ImageDownloadable { - static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> Self + static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self } /// The payload types a `downloadData` call can produce: the bare `Data`, or a `DataResponse` /// envelope that also carries the status code and headers. public protocol DataDownloadable { - static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> Self + static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self } extension Image: ImageDownloadable { - public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> Self { + public static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self { return image as! Self } } extension Data: DataDownloadable { - public static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> Self { + public static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> Self { return data } } @@ -39,9 +55,16 @@ public struct ImageResponse: ImageDownloadable, @unchecked Sendable { public let headers: [String: AnyCodable] public let image: Image - public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> ImageResponse - { - ImageResponse(statusCode: statusCode, headers: headers, image: image) + public static func makeDownloadResult( + image: Image, + statusCode: Int, + headers: [String: AnyCodable] + ) -> ImageResponse { + ImageResponse( + statusCode: statusCode, + headers: headers, + image: image + ) } } @@ -51,7 +74,15 @@ public struct DataResponse: DataDownloadable, Sendable { public let headers: [String: AnyCodable] public let data: Data - public static func makeDownloadResult(data: Data, statusCode: Int, headers: [String: AnyCodable]) -> DataResponse { - DataResponse(statusCode: statusCode, headers: headers, data: data) + public static func makeDownloadResult( + data: Data, + statusCode: Int, + headers: [String: AnyCodable] + ) -> DataResponse { + DataResponse( + statusCode: statusCode, + headers: headers, + data: data + ) } } diff --git a/Sources/Networking/FakeRequest.swift b/Sources/Networking/FakeRequest.swift index a5b2924..97f87e7 100644 --- a/Sources/Networking/FakeRequest.swift +++ b/Sources/Networking/FakeRequest.swift @@ -15,19 +15,9 @@ struct FakeRequest { let statusCode: Int let delay: Double - init( - payload: Payload, responseType: Networking.ResponseType, headerFields: [String: String]?, statusCode: Int, - delay: Double - ) { - self.payload = payload - self.responseType = responseType - self.headerFields = headerFields - self.statusCode = statusCode - self.delay = delay - } - static func find( - ofType type: Networking.RequestType, forPath path: String, + ofType type: Networking.RequestType, + forPath path: String, in collection: [Networking.RequestType: [String: FakeRequest]] ) throws -> FakeRequest? { guard let requests = collection[type] else { return nil } diff --git a/Sources/Networking/FormDataPart.swift b/Sources/Networking/FormDataPart.swift index b77b127..43d53bd 100644 --- a/Sources/Networking/FormDataPart.swift +++ b/Sources/Networking/FormDataPart.swift @@ -38,7 +38,12 @@ public struct FormDataPart { return bodyData as Data } - public init(type: FormDataPartType = .octetStream, data: Data, parameterName: String, filename: String? = nil) { + public init( + type: FormDataPartType = .octetStream, + data: Data, + parameterName: String, + filename: String? = nil + ) { self.type = type self.data = data self.parameterName = parameterName diff --git a/Sources/Networking/HTTPInterceptor.swift b/Sources/Networking/HTTPInterceptor.swift index 141712b..7a751be 100644 --- a/Sources/Networking/HTTPInterceptor.swift +++ b/Sources/Networking/HTTPInterceptor.swift @@ -15,10 +15,8 @@ public struct HTTPExchange: Sendable { /// A composable hook wrapping every verb request. `next` runs the rest of the chain (innermost is the real /// network call); calling it again replays the request — the basis for retry and auth-refresh. public protocol HTTPInterceptor: Sendable { - func intercept( - _ request: URLRequest, - next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange + func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + -> HTTPExchange } /// On an unauthorized response, refresh the credential and replay the request once. @@ -39,10 +37,9 @@ public struct AuthRefreshInterceptor: HTTPInterceptor { self.coordinator = RefreshCoordinator(refresh) } - public func intercept( - _ request: URLRequest, - next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + -> HTTPExchange + { let exchange = try await next(request) guard triggeringStatusCodes.contains(exchange.response.statusCode) else { return exchange } guard let refreshedValue = try await coordinator.refreshOnce() else { return exchange } @@ -80,10 +77,9 @@ public struct RetryInterceptor: HTTPInterceptor { self.retryableMethods = Set(retryableMethods.map { $0.uppercased() }) } - public func intercept( - _ request: URLRequest, - next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + -> HTTPExchange + { let methodAllowsRetry = retryableMethods.contains((request.httpMethod ?? "GET").uppercased()) var attempt = 1 while true { @@ -144,10 +140,9 @@ public struct ResponseValidatorInterceptor: HTTPInterceptor { self.validate = validate } - public func intercept( - _ request: URLRequest, - next: @Sendable (URLRequest) async throws -> HTTPExchange - ) async throws -> HTTPExchange { + public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + -> HTTPExchange + { let exchange = try await next(request) guard (200..<300).contains(exchange.response.statusCode) else { return exchange } switch validate(exchange) { diff --git a/Sources/Networking/Helpers.swift b/Sources/Networking/Helpers.swift index a6294ba..f2aa71a 100644 --- a/Sources/Networking/Helpers.swift +++ b/Sources/Networking/Helpers.swift @@ -73,8 +73,13 @@ extension FileManager { extension URLRequest { init( - url: URL, requestType: Networking.RequestType, contentType: String?, responseType: Networking.ResponseType, - authorizationHeaderValue: String?, token: String?, authorizationHeaderKey: String, + url: URL, + requestType: Networking.RequestType, + contentType: String?, + responseType: Networking.ResponseType, + authorizationHeaderValue: String?, + token: String?, + authorizationHeaderKey: String, headerFields: [String: String]? ) { self = URLRequest(url: url) @@ -103,8 +108,17 @@ extension URLRequest { } extension HTTPURLResponse { - convenience init(url: URL, headerFields: [String: String]? = nil, statusCode: Int) { - self.init(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)! + convenience init( + url: URL, + headerFields: [String: String]? = nil, + statusCode: Int + ) { + self.init( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )! } } diff --git a/Sources/Networking/Image.swift b/Sources/Networking/Image.swift index c747a40..86fb2c1 100644 --- a/Sources/Networking/Image.swift +++ b/Sources/Networking/Image.swift @@ -13,7 +13,11 @@ extension Image { #elseif os(watchOS) return UIImage(named: name)! #else - return UIImage(named: name, in: bundle, compatibleWith: nil)! + return UIImage( + named: name, + in: bundle, + compatibleWith: nil + )! #endif } diff --git a/Sources/Networking/Networking+HTTPRequests.swift b/Sources/Networking/Networking+HTTPRequests.swift index 8e1fec6..c6ca776 100644 --- a/Sources/Networking/Networking+HTTPRequests.swift +++ b/Sources/Networking/Networking+HTTPRequests.swift @@ -4,16 +4,34 @@ extension Networking { // MARK: GET - public func get(_ path: String, query: [URLQueryItem]? = nil, cachingLevel: CachingLevel = .none) + public func get( + _ path: String, + query: [URLQueryItem]? = nil, + cachingLevel: CachingLevel = .none + ) async -> Result { - return await handle(.get, path: path, query: query ?? [], cachingLevel: cachingLevel) - } - - public func get(_ path: String, query: Q, cachingLevel: CachingLevel = .none) async + return await handle( + .get, + path: path, + query: query ?? [], + cachingLevel: cachingLevel + ) + } + + public func get( + _ path: String, + query: Q, + cachingLevel: CachingLevel = .none + ) async -> Result { - return await queryEncodeAndHandle(.get, path: path, query: query, cachingLevel: cachingLevel) + return await queryEncodeAndHandle( + .get, + path: path, + query: query, + cachingLevel: cachingLevel + ) } // MARK: POST @@ -29,31 +47,59 @@ extension Networking { /// JSON body, ISO-8601 dates. public func post(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.post, path: path, body: body) + return await encodeAndHandle( + .post, + path: path, + body: body + ) } public func post(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.post, path: path, body: body) + let result: Result = await encodeAndHandle( + .post, + path: path, + body: body + ) return result.map { _ in () } } /// `application/x-www-form-urlencoded` body, built from a flat `Encodable`. public func post(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.post, path: path, form: form) + return await formEncodeAndHandle( + .post, + path: path, + form: form + ) } public func post(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.post, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .post, + path: path, + form: form + ) return result.map { _ in () } } - public func post(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func post( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.post, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func post(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .post, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func post( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( @@ -61,12 +107,23 @@ extension Networking { return result.map { _ in () } } - public func post(_ path: String, data: Data, contentType: String) async -> Result - { - return await handle(.post, path: path, body: .raw(data, contentType: contentType)) - } - - public func post(_ path: String, data: Data, contentType: String) async -> Result { + public func post( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .post, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func post( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( .post, path: path, body: .raw(data, contentType: contentType)) return result.map { _ in () } @@ -84,30 +141,58 @@ extension Networking { } public func put(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.put, path: path, body: body) + return await encodeAndHandle( + .put, + path: path, + body: body + ) } public func put(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.put, path: path, body: body) + let result: Result = await encodeAndHandle( + .put, + path: path, + body: body + ) return result.map { _ in () } } public func put(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.put, path: path, form: form) + return await formEncodeAndHandle( + .put, + path: path, + form: form + ) } public func put(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.put, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .put, + path: path, + form: form + ) return result.map { _ in () } } - public func put(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func put( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.put, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func put(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .put, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func put( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( @@ -115,11 +200,23 @@ extension Networking { return result.map { _ in () } } - public func put(_ path: String, data: Data, contentType: String) async -> Result { - return await handle(.put, path: path, body: .raw(data, contentType: contentType)) - } - - public func put(_ path: String, data: Data, contentType: String) async -> Result { + public func put( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .put, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func put( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( .put, path: path, body: .raw(data, contentType: contentType)) return result.map { _ in () } @@ -137,30 +234,58 @@ extension Networking { } public func patch(_ path: String, body: B) async -> Result { - return await encodeAndHandle(.patch, path: path, body: body) + return await encodeAndHandle( + .patch, + path: path, + body: body + ) } public func patch(_ path: String, body: B) async -> Result { - let result: Result = await encodeAndHandle(.patch, path: path, body: body) + let result: Result = await encodeAndHandle( + .patch, + path: path, + body: body + ) return result.map { _ in () } } public func patch(_ path: String, form: F) async -> Result { - return await formEncodeAndHandle(.patch, path: path, form: form) + return await formEncodeAndHandle( + .patch, + path: path, + form: form + ) } public func patch(_ path: String, form: F) async -> Result { - let result: Result = await formEncodeAndHandle(.patch, path: path, form: form) + let result: Result = await formEncodeAndHandle( + .patch, + path: path, + form: form + ) return result.map { _ in () } } - public func patch(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async + public func patch( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result { - return await handle(.patch, path: path, body: .multipart(fields: fields, parts: parts)) - } - - public func patch(_ path: String, parts: [FormDataPart], fields: [String: String] = [:]) async -> Result< + return await handle( + .patch, + path: path, + body: .multipart(fields: fields, parts: parts) + ) + } + + public func patch( + _ path: String, + parts: [FormDataPart], + fields: [String: String] = [:] + ) async -> Result< Void, NetworkingError > { let result: Result = await handle( @@ -168,12 +293,23 @@ extension Networking { return result.map { _ in () } } - public func patch(_ path: String, data: Data, contentType: String) async -> Result - { - return await handle(.patch, path: path, body: .raw(data, contentType: contentType)) - } - - public func patch(_ path: String, data: Data, contentType: String) async -> Result { + public func patch( + _ path: String, + data: Data, + contentType: String + ) async -> Result { + return await handle( + .patch, + path: path, + body: .raw(data, contentType: contentType) + ) + } + + public func patch( + _ path: String, + data: Data, + contentType: String + ) async -> Result { let result: Result = await handle( .patch, path: path, body: .raw(data, contentType: contentType)) return result.map { _ in () } @@ -182,16 +318,29 @@ extension Networking { // MARK: DELETE public func delete(_ path: String, query: [URLQueryItem]? = nil) async -> Result { - return await handle(.delete, path: path, query: query ?? []) + return await handle( + .delete, + path: path, + query: query ?? [] + ) } public func delete(_ path: String, query: [URLQueryItem]? = nil) async -> Result { - let result: Result = await handle(.delete, path: path, query: query ?? []) + let result: Result = await handle( + .delete, + path: path, + query: query ?? [] + ) return result.map { _ in () } } public func delete(_ path: String, query: Q) async -> Result { - return await queryEncodeAndHandle(.delete, path: path, query: query, cachingLevel: .none) + return await queryEncodeAndHandle( + .delete, + path: path, + query: query, + cachingLevel: .none + ) } public func delete(_ path: String, query: Q) async -> Result { @@ -209,7 +358,11 @@ extension Networking { return encoder }() - func encodeAndHandle(_ requestType: RequestType, path: String, body: B) async -> Result< + func encodeAndHandle( + _ requestType: RequestType, + path: String, + body: B + ) async -> Result< T, NetworkingError > { let data: Data @@ -220,7 +373,11 @@ extension Networking { requestType, path: path, error: .invalidRequest(.bodyEncodingFailed(message: error.localizedDescription))) } - return await handle(requestType, path: path, body: .json(data)) + return await handle( + requestType, + path: path, + body: .json(data) + ) } // A fake whose response can't encode is a test setup bug — fail loudly here rather than silently @@ -235,7 +392,11 @@ extension Networking { } extension Networking { - func formEncodeAndHandle(_ requestType: RequestType, path: String, form: some Encodable) async + func formEncodeAndHandle( + _ requestType: RequestType, + path: String, + form: some Encodable + ) async -> Result { let fields: [String: String] @@ -246,11 +407,18 @@ extension Networking { requestType, path: path, error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) } - return await handle(requestType, path: path, body: .formURLEncoded(fields)) + return await handle( + requestType, + path: path, + body: .formURLEncoded(fields) + ) } func queryEncodeAndHandle( - _ requestType: RequestType, path: String, query: some Encodable, cachingLevel: CachingLevel + _ requestType: RequestType, + path: String, + query: some Encodable, + cachingLevel: CachingLevel ) async -> Result { let items: [URLQueryItem] do { @@ -260,7 +428,12 @@ extension Networking { requestType, path: path, error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) } - return await handle(requestType, path: path, query: items, cachingLevel: cachingLevel) + return await handle( + requestType, + path: path, + query: items, + cachingLevel: cachingLevel + ) } } @@ -268,7 +441,10 @@ extension Networking { extension Networking { public func fakeGET( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( @@ -276,22 +452,33 @@ extension Networking { responseType: .json, statusCode: statusCode, delay: delay) } - public func fakeGET(_ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0) - { + public func fakeGET( + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 + ) { registerFake( requestType: .get, path: path, headerFields: headerFields, payload: .none, responseType: .json, statusCode: statusCode, delay: delay) } public func fakeGET( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .get, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) } public func fakePOST( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( @@ -300,7 +487,10 @@ extension Networking { } public func fakePOST( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .post, path: path, headerFields: headerFields, payload: .none, responseType: .json, @@ -308,14 +498,21 @@ extension Networking { } public func fakePOST( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .post, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) } public func fakePUT( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( @@ -323,22 +520,33 @@ extension Networking { responseType: .json, statusCode: statusCode, delay: delay) } - public func fakePUT(_ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0) - { + public func fakePUT( + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 + ) { registerFake( requestType: .put, path: path, headerFields: headerFields, payload: .none, responseType: .json, statusCode: statusCode, delay: delay) } public func fakePUT( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .put, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) } public func fakePATCH( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( @@ -347,7 +555,10 @@ extension Networking { } public func fakePATCH( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .patch, path: path, headerFields: headerFields, payload: .none, responseType: .json, @@ -355,14 +566,21 @@ extension Networking { } public func fakePATCH( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .patch, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) } public func fakeDELETE( - _ path: String, response: some Encodable, headerFields: [String: String]? = nil, statusCode: Int = 200, + _ path: String, + response: some Encodable, + headerFields: [String: String]? = nil, + statusCode: Int = 200, delay: Double = 0 ) { registerFake( @@ -371,7 +589,10 @@ extension Networking { } public func fakeDELETE( - _ path: String, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .delete, path: path, headerFields: headerFields, payload: .none, responseType: .json, @@ -379,7 +600,11 @@ extension Networking { } public func fakeDELETE( - _ path: String, fileName: String, bundle: Bundle = Bundle.main, statusCode: Int = 200, delay: Double = 0 + _ path: String, + fileName: String, + bundle: Bundle = Bundle.main, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .delete, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) @@ -390,7 +615,7 @@ extension Networking { extension Networking { - public nonisolated func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { + nonisolated public func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { let object = try objectFromCache( for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .image) @@ -398,7 +623,9 @@ extension Networking { } public func downloadImage( - _ path: String, cacheName: String? = nil, cachingLevel: CachingLevel = .memoryAndFile + _ path: String, + cacheName: String? = nil, + cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleImageRequest( .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .image) @@ -407,11 +634,19 @@ extension Networking { /// Completes the in-flight download for `path` with `URLError.cancelled`. public func cancelImageDownload(_ path: String) async throws { let url = try composedURL(with: path) - await cancelRequest(.data, requestType: .get, url: url) + await cancelRequest( + .data, + requestType: .get, + url: url + ) } public func fakeImageDownload( - _ path: String, image: Image, headerFields: [String: String]? = nil, statusCode: Int = 200, delay: Double = 0 + _ path: String, + image: Image, + headerFields: [String: String]? = nil, + statusCode: Int = 200, + delay: Double = 0 ) { registerFake( requestType: .get, path: path, headerFields: headerFields, payload: .image(image), responseType: .image, @@ -419,13 +654,15 @@ extension Networking { } public func downloadData( - _ path: String, cacheName: String? = nil, cachingLevel: CachingLevel = .memoryAndFile + _ path: String, + cacheName: String? = nil, + cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleDataRequest( .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .data) } - public nonisolated func dataFromCache(_ path: String, cacheName: String? = nil) throws -> Data? { + nonisolated public func dataFromCache(_ path: String, cacheName: String? = nil) throws -> Data? { let object = try objectFromCache( for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .data) diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index e19b736..8425997 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -22,7 +22,10 @@ extension Networking { } func handle( - _ requestType: RequestType, path: String, body: RequestBody = .none, query: [URLQueryItem] = [], + _ requestType: RequestType, + path: String, + body: RequestBody = .none, + query: [URLQueryItem] = [], cachingLevel: CachingLevel = .none ) async -> Result { let requestID = UUID() @@ -37,18 +40,31 @@ extension Networking { var responseMetadata: ResponseMetadata? do { - if let fakeRequest = try FakeRequest.find(ofType: requestType, forPath: path, in: fakeRequests) { + if let fakeRequest = try FakeRequest.find( + ofType: requestType, + forPath: path, + in: fakeRequests + ) { let fakeContext = RequestContext( id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) context = fakeContext emit(.started(fakeContext)) let (fakeResult, fakeStatus, fakeBytes): (Result, Int, Int) = - try await handleFakeRequest(fakeRequest, path: path, requestType: requestType) + try await handleFakeRequest( + fakeRequest, + path: path, + requestType: requestType + ) result = fakeResult statusCode = fakeStatus byteCount = fakeBytes } else { - let request = try createRequest(path: path, requestType: requestType, body: body, query: query) + let request = try createRequest( + path: path, + requestType: requestType, + body: body, + query: query + ) let requestContext = RequestContext( id: requestID, requestType: requestType, url: request.url, headers: request.allHTTPHeaderFields) context = requestContext @@ -68,7 +84,11 @@ extension Networking { // Run the cache hit back out through the interceptor chain so validators apply to it too. let exchange = try await perform( request, cached: HTTPExchange(data: cached.body, response: cachedResponse)) - result = handleResponse(responseData: exchange.data, response: exchange.response, path: path) + result = handleResponse( + responseData: exchange.data, + response: exchange.response, + path: path + ) statusCode = exchange.response.statusCode byteCount = exchange.data.count responseMetadata = ResponseMetadata(response: exchange.response, body: exchange.data) @@ -79,7 +99,8 @@ extension Networking { let response: URLResponse = exchange.response let networkResult: Result = handleResponse( responseData: responseData, response: response, path: path) - if cachingLevel != .none, case .success = networkResult, + if cachingLevel != .none, + case .success = networkResult, let httpResponse = response as? HTTPURLResponse { let headers = Dictionary( @@ -124,7 +145,11 @@ extension Networking { // Runs the interceptor chain. On a cache hit, `cached` is the base result the chain folds around (no // network call), so validators still see it. session/collector are read into locals first so the // @Sendable chain captures no actor-isolated state (Swift 6 region isolation). - func perform(_ request: URLRequest, collector: MetricsCollector? = nil, cached: HTTPExchange? = nil) async throws + func perform( + _ request: URLRequest, + collector: MetricsCollector? = nil, + cached: HTTPExchange? = nil + ) async throws -> HTTPExchange { let session = self.session @@ -150,8 +175,13 @@ extension Networking { // The single completion path, shared by verbs, pre-flight failures, and downloads. `requestBody` and // `responseMetadata` aren't on the `.completed` event, so they're threaded here for logging only. func complete( - _ result: Result, context: RequestContext, statusCode: Int?, byteCount: Int, - metrics: TransactionMetrics?, duration: Duration, requestBody: RequestBody? = nil, + _ result: Result, + context: RequestContext, + statusCode: Int?, + byteCount: Int, + metrics: TransactionMetrics?, + duration: Duration, + requestBody: RequestBody? = nil, responseMetadata: ResponseMetadata? = nil ) -> Result { let outcome: Outcome @@ -164,13 +194,24 @@ extension Networking { logCompletion( context: context, result: result, statusCode: statusCode, byteCount: byteCount, duration: duration, requestBody: requestBody, responseMetadata: responseMetadata) - emit(.completed(context, outcome: outcome, duration: duration, metrics: metrics)) + emit( + .completed( + context, + outcome: outcome, + duration: duration, + metrics: metrics + )) return result } private func logCompletion( - context: RequestContext, result: Result, statusCode: Int?, byteCount: Int, - duration: Duration, requestBody: RequestBody?, responseMetadata: ResponseMetadata? + context: RequestContext, + result: Result, + statusCode: Int?, + byteCount: Int, + duration: Duration, + requestBody: RequestBody?, + responseMetadata: ResponseMetadata? ) { guard logLevel != .none else { return } @@ -229,14 +270,25 @@ extension Networking { // Emits the `.started`/`.completed` pair for a failure that happens before the network call (e.g. // body encoding), so observers don't miss it. - func emitPreflightFailure(_ requestType: RequestType, path: String, error: NetworkingError) -> Result< + func emitPreflightFailure( + _ requestType: RequestType, + path: String, + error: NetworkingError + ) -> Result< T, NetworkingError > { let requestID = UUID() let context = RequestContext( id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) emit(.started(context)) - return complete(.failure(error), context: context, statusCode: nil, byteCount: 0, metrics: nil, duration: .zero) + return complete( + .failure(error), + context: context, + statusCode: nil, + byteCount: 0, + metrics: nil, + duration: .zero + ) } // Persists status code and headers alongside the body so a cache hit reproduces real metadata. @@ -247,9 +299,7 @@ extension Networking { } private func cacheKey(for request: URLRequest, fallbackPath: String) -> String { - guard let url = request.url, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - else { + guard let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return fallbackPath } // Sort the *encoded* query components so parameter order doesn't change the key (a cache @@ -260,10 +310,19 @@ extension Networking { return components.string ?? url.absoluteString } - private func handleFakeRequest(_ fakeRequest: FakeRequest, path: String, requestType: RequestType) + private func handleFakeRequest( + _ fakeRequest: FakeRequest, + path: String, + requestType: RequestType + ) async throws -> (Result, statusCode: Int, byteCount: Int) { - let (response, _) = try handleFakeRequest(fakeRequest, path: path, cacheName: nil, cachingLevel: .none) + let (response, _) = try handleFakeRequest( + fakeRequest, + path: path, + cacheName: nil, + cachingLevel: .none + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) @@ -281,12 +340,21 @@ extension Networking { return (result, response.statusCode, responseData.count) } - private func createRequest(path: String, requestType: RequestType, body: RequestBody, query: [URLQueryItem]) throws + private func createRequest( + path: String, + requestType: RequestType, + body: RequestBody, + query: [URLQueryItem] + ) throws -> URLRequest { // Split a query embedded in the path so it survives URL building instead of being // percent-encoded into the path (encodeUTF8 uses .urlPathAllowed, which escapes "?"). - let pathParts = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + let pathParts = path.split( + separator: "?", + maxSplits: 1, + omittingEmptySubsequences: false + ) let rawPath = String(pathParts[0]) let pathQuery = pathParts.count > 1 ? String(pathParts[1]) : nil @@ -352,7 +420,11 @@ extension Networking { } } - private func handleResponse(responseData: Data, response: URLResponse, path: String) -> Result< + private func handleResponse( + responseData: Data, + response: URLResponse, + path: String + ) -> Result< T, NetworkingError > { guard let httpResponse = response as? HTTPURLResponse else { @@ -363,7 +435,11 @@ extension Networking { switch StatusCodeType(statusCode: statusCode) { case .informational, .successful: - return handleSuccessfulResponse(responseData: responseData, path: path, httpResponse: httpResponse) + return handleSuccessfulResponse( + responseData: responseData, + path: path, + httpResponse: httpResponse + ) case .cancelled: return .failure(.cancelled) case .redirection, .clientError, .serverError, .unknown: @@ -373,7 +449,11 @@ extension Networking { } } - private func handleSuccessfulResponse(responseData: Data, path: String, httpResponse: HTTPURLResponse) + private func handleSuccessfulResponse( + responseData: Data, + path: String, + httpResponse: HTTPURLResponse + ) -> Result { if T.self == Data.self { @@ -387,7 +467,11 @@ extension Networking { // An empty body (e.g. 204 No Content) is a success — decoding empty data would fail, so use an empty body. let body = responseData.isEmpty ? [:] : try JSONDecoder().decode([String: AnyCodable].self, from: responseData) - let networkingJSON = JSONResponse(statusCode: httpResponse.statusCode, headers: headers, body: body) + let networkingJSON = JSONResponse( + statusCode: httpResponse.statusCode, + headers: headers, + body: body + ) return .success(networkingJSON as! T) } catch let error as DecodingError { return .failure(.decoding(error, ResponseMetadata(response: httpResponse, body: responseData))) diff --git a/Sources/Networking/Networking+Private.swift b/Sources/Networking/Networking+Private.swift index b2994cc..c414ffe 100644 --- a/Sources/Networking/Networking+Private.swift +++ b/Sources/Networking/Networking+Private.swift @@ -2,7 +2,10 @@ import Foundation extension Networking { nonisolated func objectFromCache( - for path: String, cacheName: String?, cachingLevel: CachingLevel, responseType: ResponseType + for path: String, + cacheName: String?, + cachingLevel: CachingLevel, + responseType: ResponseType ) throws -> Any? { try cacheStore.object( forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel, @@ -10,7 +13,12 @@ extension Networking { } func registerFake( - requestType: RequestType, path: String, fileName: String, bundle: Bundle, statusCode: Int, delay: Double + requestType: RequestType, + path: String, + fileName: String, + bundle: Bundle, + statusCode: Int, + delay: Double ) { let url = URL(string: fileName) guard let resource = url?.deletingPathExtension().absoluteString, @@ -25,8 +33,13 @@ extension Networking { } func registerFake( - requestType: RequestType, path: String, headerFields: [String: String]?, payload: FakeRequest.Payload, - responseType: ResponseType, statusCode: Int, delay: Double + requestType: RequestType, + path: String, + headerFields: [String: String]?, + payload: FakeRequest.Payload, + responseType: ResponseType, + statusCode: Int, + delay: Double ) { var requests = fakeRequests[requestType] ?? [String: FakeRequest]() requests[path] = FakeRequest( @@ -35,7 +48,12 @@ extension Networking { fakeRequests[requestType] = requests } - func handleFakeRequest(_ fakeRequest: FakeRequest, path: String, cacheName: String?, cachingLevel: CachingLevel) + func handleFakeRequest( + _ fakeRequest: FakeRequest, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws -> (HTTPURLResponse, NSError?) { var error: NSError? @@ -51,15 +69,28 @@ extension Networking { let data: Data? = { if case .data(let data) = fakeRequest.payload { return data } else { return nil } }() switch fakeRequest.responseType { case .image: - try cacheOrPurgeImage(data: nil, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + try cacheOrPurgeImage( + data: nil, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) case .data, .json: - try cacheOrPurgeData(data: data, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + try cacheOrPurgeData( + data: data, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) } return (response, error) } func handleDataRequest( - _ requestType: RequestType, path: String, cacheName: String?, cachingLevel: CachingLevel, + _ requestType: RequestType, + path: String, + cacheName: String?, + cachingLevel: CachingLevel, responseType: ResponseType ) async -> Result { let requestID = UUID() @@ -97,11 +128,20 @@ extension Networking { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( - T.makeDownloadResult(data: cached, statusCode: 200, headers: headerFields(from: response))) + T.makeDownloadResult( + data: cached, + statusCode: 200, + headers: headerFields(from: response) + )) } else { let (downloaded, networkResponse) = try await requestData( requestType, path: path, responseType: responseType) - try cacheOrPurgeData(data: downloaded, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + try cacheOrPurgeData( + data: downloaded, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) statusCode = networkResponse.statusCode if StatusCodeType(statusCode: networkResponse.statusCode) != .successful { result = .failure(downloadError(forStatusCode: networkResponse.statusCode)) @@ -122,7 +162,10 @@ extension Networking { } func handleImageRequest( - _ requestType: RequestType, path: String, cacheName: String?, cachingLevel: CachingLevel, + _ requestType: RequestType, + path: String, + cacheName: String?, + cachingLevel: CachingLevel, responseType: ResponseType ) async -> Result { let requestID = UUID() @@ -159,9 +202,17 @@ extension Networking { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( - T.makeDownloadResult(image: cached, statusCode: 200, headers: headerFields(from: response))) + T.makeDownloadResult( + image: cached, + statusCode: 200, + headers: headerFields(from: response) + )) } else { - let (data, networkResponse) = try await requestData(requestType, path: path, responseType: responseType) + let (data, networkResponse) = try await requestData( + requestType, + path: path, + responseType: responseType + ) statusCode = networkResponse.statusCode if StatusCodeType(statusCode: networkResponse.statusCode) != .successful { result = .failure(downloadError(forStatusCode: networkResponse.statusCode)) @@ -194,7 +245,11 @@ extension Networking { private func downloadError(forStatusCode statusCode: Int) -> NetworkingError { // Downloads don't retain the response body/headers at this point, so the metadata is status-only. - let metadata = ResponseMetadata(statusCode: statusCode, headers: [:], body: Data()) + let metadata = ResponseMetadata( + statusCode: statusCode, + headers: [:], + body: Data() + ) return .http(HTTPError(statusCode: statusCode, metadata: metadata)) } @@ -208,7 +263,11 @@ extension Networking { return .transport(URLError(.unknown)) } - func requestData(_ requestType: RequestType, path: String, responseType: ResponseType) async throws -> ( + func requestData( + _ requestType: RequestType, + path: String, + responseType: ResponseType + ) async throws -> ( Data, HTTPURLResponse ) { let request = URLRequest( @@ -223,7 +282,11 @@ extension Networking { return (exchange.data, exchange.response) } - func cancelRequest(_ sessionTaskType: SessionTaskType, requestType: RequestType, url: URL) async { + func cancelRequest( + _ sessionTaskType: SessionTaskType, + requestType: RequestType, + url: URL + ) async { let (dataTasks, uploadTasks, downloadTasks) = await session.tasks var sessionTasks = [URLSessionTask]() switch sessionTaskType { @@ -245,13 +308,26 @@ extension Networking { } } - nonisolated func cacheOrPurgeData(data: Data?, path: String, cacheName: String?, cachingLevel: CachingLevel) throws - { - try cacheStore.storeData(data, forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel) + nonisolated func cacheOrPurgeData( + data: Data?, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws { + try cacheStore.storeData( + data, + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel + ) } @discardableResult - nonisolated func cacheOrPurgeImage(data: Data?, path: String, cacheName: String?, cachingLevel: CachingLevel) throws + nonisolated func cacheOrPurgeImage( + data: Data?, + path: String, + cacheName: String?, + cachingLevel: CachingLevel + ) throws -> Image? { try cacheStore.storeImage( diff --git a/Sources/Networking/Networking.swift b/Sources/Networking/Networking.swift index 3b6abb8..d95c882 100644 --- a/Sources/Networking/Networking.swift +++ b/Sources/Networking/Networking.swift @@ -162,7 +162,11 @@ public actor Networking { } } - nonisolated let boundary = String(format: "com.elvisnunez.networking.%08x%08x", arc4random(), arc4random()) + nonisolated let boundary = String( + format: "com.elvisnunez.networking.%08x%08x", + arc4random(), + arc4random() + ) lazy var session: URLSession = { URLSession(configuration: self.configuration) @@ -188,15 +192,21 @@ public actor Networking { } public init( - baseURL: String = "", configuration: URLSessionConfiguration = .default, - cache: NSCache? = nil, logger: Logger? = nil, + baseURL: String = "", + configuration: URLSessionConfiguration = .default, + cache: NSCache? = nil, + logger: Logger? = nil, cacheTTL: Duration = .seconds(7 * 24 * 60 * 60) ) { self.baseURL = baseURL self.configuration = configuration let memoryCache = cache ?? NSCache() self.cache = memoryCache - self.cacheStore = CacheStore(memory: memoryCache, ttl: cacheTTL, folderName: Networking.domain) + self.cacheStore = CacheStore( + memory: memoryCache, + ttl: cacheTTL, + folderName: Networking.domain + ) self.logger = logger ?? Networking.defaultLogger self.logFileURL = ProcessInfo.processInfo.environment["NETWORKING_LOG_FILE"].flatMap( Networking.resolveLogFileURL) diff --git a/Sources/Networking/NetworkingError.swift b/Sources/Networking/NetworkingError.swift index ddf1db7..119b127 100644 --- a/Sources/Networking/NetworkingError.swift +++ b/Sources/Networking/NetworkingError.swift @@ -99,7 +99,11 @@ public struct ResponseMetadata: Sendable { public let headers: [String: String] public let body: Data - public init(statusCode: Int, headers: [String: String], body: Data) { + public init( + statusCode: Int, + headers: [String: String], + body: Data + ) { self.statusCode = statusCode self.headers = headers self.body = body @@ -110,7 +114,11 @@ public struct ResponseMetadata: Sendable { uniqueKeysWithValues: response.allHeaderFields.compactMap { key, value in (key as? String).map { ($0, "\(value)") } }) - self.init(statusCode: response.statusCode, headers: headers, body: body) + self.init( + statusCode: response.statusCode, + headers: headers, + body: body + ) } public func decode(_ type: T.Type, using decoder: JSONDecoder = JSONDecoder()) throws -> T { diff --git a/Sources/Networking/NetworkingEvent.swift b/Sources/Networking/NetworkingEvent.swift index e53f619..776fc77 100644 --- a/Sources/Networking/NetworkingEvent.swift +++ b/Sources/Networking/NetworkingEvent.swift @@ -25,8 +25,18 @@ extension RequestContext { // Owns the two normalizations every call site repeated: the HTTP method string and a nil header // set. The URL stays a caller concern — it's resolved differently per site (best-effort // `composedURL` from a path, a built request's URL, or nil on a pre-flight failure). - init(id: UUID, requestType: Networking.RequestType, url: URL?, headers: [String: String]?) { - self.init(id: id, method: requestType.rawValue, url: url, headers: headers ?? [:]) + init( + id: UUID, + requestType: Networking.RequestType, + url: URL?, + headers: [String: String]? + ) { + self.init( + id: id, + method: requestType.rawValue, + url: url, + headers: headers ?? [:] + ) } } @@ -89,7 +99,11 @@ final class MetricsCollector: NSObject, URLSessionTaskDelegate, @unchecked Senda return collected } - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { lock.lock() defer { lock.unlock() } collected = metrics diff --git a/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift b/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift index 1d630ec..390d7c2 100644 --- a/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/AuthRefreshInterceptorIntegrationTests.swift @@ -20,7 +20,11 @@ final class AuthRefreshInterceptorIntegrationTests: XCTestCase { switch result { case .success(let response): - XCTAssertEqual(response.statusCode, 200, "the replay with a fresh credential should succeed") + XCTAssertEqual( + response.statusCode, + 200, + "the replay with a fresh credential should succeed" + ) case .failure(let error): XCTFail("expected the refreshed replay to succeed, got \(error)") } diff --git a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift index 8d4149c..b8e0058 100644 --- a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift +++ b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift @@ -34,6 +34,10 @@ final class AuthRefreshSingleFlightIntegrationTests: XCTestCase { } let count = await counter.count - XCTAssertEqual(count, 1, "concurrent 401s should share a single refresh, but it ran \(count) times") + XCTAssertEqual( + count, + 1, + "concurrent 401s should share a single refresh, but it ran \(count) times" + ) } } diff --git a/Tests/NetworkingTests/CacheStoreTests.swift b/Tests/NetworkingTests/CacheStoreTests.swift index a021ca4..3f36f25 100644 --- a/Tests/NetworkingTests/CacheStoreTests.swift +++ b/Tests/NetworkingTests/CacheStoreTests.swift @@ -12,13 +12,21 @@ final class CacheStoreTests: XCTestCase { store: CacheStore, memory: NSCache ) { let memory = NSCache() - let store = CacheStore(memory: memory, ttl: ttl, folderName: folderName) + let store = CacheStore( + memory: memory, + ttl: ttl, + folderName: folderName + ) try store.clear() return (store, memory) } override func tearDown() { - try? CacheStore(memory: NSCache(), ttl: .seconds(1), folderName: folderName).clear() + try? CacheStore( + memory: NSCache(), + ttl: .seconds(1), + folderName: folderName + ).clear() super.tearDown() } @@ -29,8 +37,17 @@ final class CacheStoreTests: XCTestCase { let resource = "http://example.com/" + String(repeating: "a", count: 300) let payload = Data("payload".utf8) - try store.storeData(payload, forResource: resource, level: .memoryAndFile) - let cached = try store.object(forResource: resource, level: .memoryAndFile, asImage: false) as? Data + try store.storeData( + payload, + forResource: resource, + level: .memoryAndFile + ) + let cached = + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ) as? Data XCTAssertEqual(cached, payload) } @@ -38,7 +55,11 @@ final class CacheStoreTests: XCTestCase { func testColdDiskEntryExpiresOnRead() throws { let (store, memory) = try makeStore(ttl: .seconds(60)) let resource = "http://example.com/cold-entry" - try store.storeData(Data("stale".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("stale".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeObject(forKey: url.absoluteString as AnyObject) // force the disk path @@ -46,7 +67,11 @@ final class CacheStoreTests: XCTestCase { [.modificationDate: Date(timeIntervalSinceNow: -120)], ofItemAtPath: url.path) XCTAssertNil( - try store.object(forResource: resource, level: .memoryAndFile, asImage: false), + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ), "an entry idle beyond the TTL is expired on read" ) } @@ -55,16 +80,29 @@ final class CacheStoreTests: XCTestCase { func testDiskHitReWarmsFileDate() throws { let (store, memory) = try makeStore(ttl: .seconds(60)) let resource = "http://example.com/warm-entry" - try store.storeData(Data("fresh".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("fresh".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeObject(forKey: url.absoluteString as AnyObject) // force the disk path try FileManager.default.setAttributes( [.modificationDate: Date(timeIntervalSinceNow: -30)], ofItemAtPath: url.path) - XCTAssertNotNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) + XCTAssertNotNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) let mtime = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate! - XCTAssertLessThan(Date().timeIntervalSince(mtime), 5, "the disk hit should have re-warmed the mtime to ~now") + XCTAssertLessThan( + Date().timeIntervalSince(mtime), + 5, + "the disk hit should have re-warmed the mtime to ~now" + ) } // (No memory-hit-rewarm test: NSCache can evict at any time, so a memory hit can't be forced @@ -75,15 +113,27 @@ final class CacheStoreTests: XCTestCase { func testMemoryReadNeverDeletesTheDiskTier() throws { let (store, memory) = try makeStore() let resource = "http://example.com/durable-memory" - try store.storeData(Data("durable".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("durable".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) memory.removeAllObjects() // simulate iOS evicting the warm tier - _ = try store.object(forResource: resource, level: .memory, asImage: false) + _ = try store.object( + forResource: resource, + level: .memory, + asImage: false + ) XCTAssertTrue(FileManager.default.exists(at: url), "a .memory read deleted the durable disk copy") XCTAssertEqual( - try store.object(forResource: resource, level: .memoryAndFile, asImage: false) as? Data, + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + ) as? Data, Data("durable".utf8), "the disk copy should still be readable after the .memory read" ) @@ -93,10 +143,19 @@ final class CacheStoreTests: XCTestCase { func testNoneReadNeverDeletesTheDiskTier() throws { let (store, _) = try makeStore() let resource = "http://example.com/durable-none" - try store.storeData(Data("durable".utf8), forResource: resource, level: .memoryAndFile) + try store.storeData( + Data("durable".utf8), + forResource: resource, + level: .memoryAndFile + ) let url = try store.destinationURL(forResource: resource) - XCTAssertNil(try store.object(forResource: resource, level: .none, asImage: false)) + XCTAssertNil( + try store.object( + forResource: resource, + level: .none, + asImage: false + )) XCTAssertTrue(FileManager.default.exists(at: url), "a .none read deleted the durable disk copy") } @@ -105,11 +164,25 @@ final class CacheStoreTests: XCTestCase { func testClearEmptiesBothTiers() throws { let (store, _) = try makeStore() let resource = "http://example.com/cached" - try store.storeData(Data("hi".utf8), forResource: resource, level: .memoryAndFile) - XCTAssertNotNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) + try store.storeData( + Data("hi".utf8), + forResource: resource, + level: .memoryAndFile + ) + XCTAssertNotNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) try store.clear() - XCTAssertNil(try store.object(forResource: resource, level: .memoryAndFile, asImage: false)) + XCTAssertNil( + try store.object( + forResource: resource, + level: .memoryAndFile, + asImage: false + )) } } diff --git a/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift b/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift index 07b8ba0..e9baf14 100644 --- a/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift +++ b/Tests/NetworkingTests/CacheWriteAndDataBodyRegressionTests.swift @@ -14,8 +14,16 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { let cacheName = "orphan-regression-key" try await networking.clearCache() - try Helper.removeFileIfNeeded(networking, path: path, cacheName: nil) - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: nil + ) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadData(path, cacheName: cacheName) guard case .success = result else { return XCTFail("expected a successful download, got \(result)") } @@ -23,8 +31,7 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { let orphanURL = try networking.destinationURL(for: path, cacheName: nil) XCTAssertFalse( FileManager.default.exists(at: orphanURL), - "downloadData wrote a stray cache file under the path key that no read path uses" - ) + "downloadData wrote a stray cache file under the path key that no read path uses") } // The `.memory`/`.none` pure-read contracts (a read must not destroy a durable `.memoryAndFile` copy) @@ -38,6 +45,10 @@ final class CacheWriteAndDataBodyRegressionTests: XCTestCase { guard case .success(let data) = result else { return XCTFail("expected success, got \(result)") } let decoded = try? JSONDecoder().decode([String: String].self, from: data) - XCTAssertEqual(decoded?["message"], "hello", "a verb with T == Data must return the response body") + XCTAssertEqual( + decoded?["message"], + "hello", + "a verb with T == Data must return the response body" + ) } } diff --git a/Tests/NetworkingTests/CancellationIntegrationTests.swift b/Tests/NetworkingTests/CancellationIntegrationTests.swift index bf8f954..1cce804 100644 --- a/Tests/NetworkingTests/CancellationIntegrationTests.swift +++ b/Tests/NetworkingTests/CancellationIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class CancellationIntegrationTests: XCTestCase { +final class CancellationIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL // Cancelling the Task running a request surfaces `.cancelled`. go-httpbin's `/delay/{n}` keeps the diff --git a/Tests/NetworkingTests/DELETEIntegrationTests.swift b/Tests/NetworkingTests/DELETEIntegrationTests.swift index 7e3dccc..174cb50 100644 --- a/Tests/NetworkingTests/DELETEIntegrationTests.swift +++ b/Tests/NetworkingTests/DELETEIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class DELETEIntegrationTests: XCTestCase { +final class DELETEIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testDELETE() async throws { diff --git a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift index e10435d..2d3dddf 100644 --- a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift +++ b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class Dictionary_FormURLEncodedTests: XCTestCase { +final class Dictionary_FormURLEncodedTests: XCTestCase { func testFormatting() throws { let parameters = ["username": "elvis", "password": "secret"] diff --git a/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift b/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift index fca3f52..0611ed4 100644 --- a/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadEventsIntegrationTests.swift @@ -16,7 +16,11 @@ final class DownloadEventsIntegrationTests: XCTestCase { let _: Result = await networking.downloadImage("/image/png") let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "downloadImage should emit .started and .completed; got \(events)") + XCTAssertEqual( + events.count, + 2, + "downloadImage should emit .started and .completed; got \(events)" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, _, _) = events.last else { @@ -31,7 +35,11 @@ final class DownloadEventsIntegrationTests: XCTestCase { func testDownloadImageEmitsFailureForBadStatus() async { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, statusCode: 404) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + statusCode: 404 + ) let stream = await networking.events() let _: Result = await networking.downloadImage("/image/png") diff --git a/Tests/NetworkingTests/DownloadIntegrationTests.swift b/Tests/NetworkingTests/DownloadIntegrationTests.swift index 4383d0e..46aad31 100644 --- a/Tests/NetworkingTests/DownloadIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadIntegrationTests.swift @@ -6,7 +6,7 @@ import XCTest // @MainActor so the local `NSCache` (non-Sendable) used across the `downloadImage` await stays on // one actor and doesn't trip strict-concurrency "sending" checks. @MainActor -class DownloadIntegrationTests: XCTestCase { +final class DownloadIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testImageDownload() async throws { @@ -61,7 +61,11 @@ class DownloadIntegrationTests: XCTestCase { let path = "/image/png" let cacheName = "png/png" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let _: Result = await networking.downloadImage(path, cacheName: cacheName) let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) @@ -97,7 +101,11 @@ class DownloadIntegrationTests: XCTestCase { let path = "/image/png" let cacheName = "png/png" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadImage(path, cacheName: cacheName) switch result { @@ -134,7 +142,11 @@ class DownloadIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let path = "/image/png" let cacheName = "hello" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let _: Result = await networking.downloadImage(path, cacheName: cacheName) let image = try networking.imageFromCache(path, cacheName: cacheName) let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -166,7 +178,11 @@ class DownloadIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let path = "/image/png" let cacheName = "hello" - try Helper.removeFileIfNeeded(networking, path: path, cacheName: cacheName) + try Helper.removeFileIfNeeded( + networking, + path: path, + cacheName: cacheName + ) let result: Result = await networking.downloadImage(path, cacheName: cacheName) switch result { case .success: diff --git a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift index 12d4867..7ec54a4 100644 --- a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift @@ -32,6 +32,10 @@ final class DownloadInterceptorIntegrationTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected the download to succeed, got \(error)") } let count = await counter.count - XCTAssertGreaterThanOrEqual(count, 1, "a download must pass through the interceptor chain") + XCTAssertGreaterThanOrEqual( + count, + 1, + "a download must pass through the interceptor chain" + ) } } diff --git a/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift b/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift index 6985dea..725018a 100644 --- a/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift +++ b/Tests/NetworkingTests/EncodableFormQueryIntegrationTests.swift @@ -15,7 +15,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testPOSTFormFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.post( - "/post", form: SearchParams(term: "swift & co", page: 2, exact: true)) + "/post", + form: SearchParams( + term: "swift & co", + page: 2, + exact: true + )) switch result { case .success(let response): let form = httpbinEchoedMap(response, "form") @@ -33,7 +38,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testGETQueryFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.get( - "/get", query: SearchParams(term: "swift", page: 2, exact: false)) + "/get", + query: SearchParams( + term: "swift", + page: 2, + exact: false + )) switch result { case .success(let response): let args = httpbinEchoedMap(response, "args") @@ -48,7 +58,12 @@ final class EncodableFormQueryIntegrationTests: XCTestCase { func testDELETEQueryFromEncodableModel() async throws { let networking = Networking(baseURL: baseURL) let result: Result = await networking.delete( - "/delete", query: SearchParams(term: "swift", page: 5, exact: true)) + "/delete", + query: SearchParams( + term: "swift", + page: 5, + exact: true + )) switch result { case .success(let response): let args = httpbinEchoedMap(response, "args") diff --git a/Tests/NetworkingTests/FakeRequestTests.swift b/Tests/NetworkingTests/FakeRequestTests.swift index a83c6bc..1671de5 100644 --- a/Tests/NetworkingTests/FakeRequestTests.swift +++ b/Tests/NetworkingTests/FakeRequestTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class FakeRequestTests: XCTestCase { +final class FakeRequestTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL // Decodes a fake request's JSON payload back into a dictionary so `find`'s template @@ -42,11 +42,27 @@ class FakeRequestTests: XCTestCase { } func testFind() throws { - let request = FakeRequest(payload: .none, responseType: .json, headerFields: nil, statusCode: 200, delay: 0) + let request = FakeRequest( + payload: .none, + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/companies": request]] - XCTAssertNil(try FakeRequest.find(ofType: .get, forPath: "/users", in: existingRequests)) - XCTAssertNil(try FakeRequest.find(ofType: .get, forPath: "/users", in: [:])) + XCTAssertNil( + try FakeRequest.find( + ofType: .get, + forPath: "/users", + in: existingRequests + )) + XCTAssertNil( + try FakeRequest.find( + ofType: .get, + forPath: "/users", + in: [:] + )) } func testOneLevelFind() throws { @@ -57,7 +73,11 @@ class FakeRequestTests: XCTestCase { payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, statusCode: 200, delay: 0) let existingRequests = [Networking.RequestType.get: ["/users/{userID}": request]] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10", + in: existingRequests + ) let expected = [ "name": "Name 10" @@ -83,7 +103,11 @@ class FakeRequestTests: XCTestCase { "/users/{userID}": request, ] ] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10", + in: existingRequests + ) let expected = [ "name": "Name 10" @@ -101,7 +125,11 @@ class FakeRequestTests: XCTestCase { payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, statusCode: 200, delay: 0) let existingRequests = [Networking.RequestType.get: ["/users/{userID}/companies/{companyID}": request]] - let result = try FakeRequest.find(ofType: .get, forPath: "/users/10/companies/20", in: existingRequests) + let result = try FakeRequest.find( + ofType: .get, + forPath: "/users/10/companies/20", + in: existingRequests + ) let expected = [ "user": "User 10", @@ -209,12 +237,20 @@ extension FakeRequestTests { let delay: Double = 2.0 let startTime1 = Date() - await networking.fakeGET("/stories", response: ["name": "Elvis"], delay: delay) + await networking.fakeGET( + "/stories", + response: ["name": "Elvis"], + delay: delay + ) let firstResult: Result = await networking.get("/stories") let endTime1 = Date() let elapsedTime1 = endTime1.timeIntervalSince(startTime1) - XCTAssertGreaterThanOrEqual(elapsedTime1, delay, "The delay was not correctly applied") + XCTAssertGreaterThanOrEqual( + elapsedTime1, + delay, + "The delay was not correctly applied" + ) switch firstResult { case .success(let response): @@ -246,7 +282,11 @@ extension FakeRequestTests { func testFakeGETWithInvalidPathAndJSONError() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/stories", response: ["error": "Shit went down"], statusCode: 401) + await networking.fakeGET( + "/stories", + response: ["error": "Shit went down"], + statusCode: 401 + ) let result: Result = await networking.get("/stories") switch result { @@ -265,7 +305,11 @@ extension FakeRequestTests { func testErrorBodyRetainedInFullNotTruncated() async throws { let networking = Networking(baseURL: baseURL) let longMessage = String(repeating: "x", count: 600) - await networking.fakeGET("/big-error", response: ["error": longMessage], statusCode: 400) + await networking.fakeGET( + "/big-error", + response: ["error": longMessage], + statusCode: 400 + ) let result: Result = await networking.get("/big-error") guard case .failure(.http(let httpError)) = result else { @@ -280,7 +324,11 @@ extension FakeRequestTests { func testFakeGETUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/entries", fileName: "entries.json", bundle: .module) + await networking.fakeGET( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.get("/entries") switch result { @@ -298,12 +346,36 @@ extension FakeRequestTests { "name": "Name {userID}" ] - await networking.fakeGET("/users/ados", response: json, statusCode: 200) - await networking.fakeGET("/users/bedos", response: json, statusCode: 200) - await networking.fakeGET("/users/cedos", response: json, statusCode: 200) - await networking.fakeGET("/users/tedos", response: json, statusCode: 200) - await networking.fakeGET("/users/melos", response: json, statusCode: 200) - await networking.fakeGET("/users/{userID}", response: json, statusCode: 200) + await networking.fakeGET( + "/users/ados", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/bedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/cedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/tedos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/melos", + response: json, + statusCode: 200 + ) + await networking.fakeGET( + "/users/{userID}", + response: json, + statusCode: 200 + ) let result: Result = await networking.get("/users/10") switch result { @@ -325,7 +397,11 @@ extension FakeRequestTests { func testFakeGETUsingHeader() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeGET("/story", response: ["ok": true], headerFields: ["uid": "12345678"]) + await networking.fakeGET( + "/story", + response: ["ok": true], + headerFields: ["uid": "12345678"] + ) let result: Result = await networking.get("/story") switch result { @@ -374,7 +450,11 @@ extension FakeRequestTests { func testFakePOSTUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePOST("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePOST( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.post("/entries") switch result { @@ -460,7 +540,11 @@ extension FakeRequestTests { func testFakePUTUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePUT("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePUT( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.put("/entries") switch result { @@ -523,7 +607,11 @@ extension FakeRequestTests { func testFakePATCHUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakePATCH("/entries", fileName: "entries.json", bundle: .module) + await networking.fakePATCH( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.patch("/entries") switch result { @@ -609,7 +697,11 @@ extension FakeRequestTests { func testFakeDELETEUsingFile() async throws { let networking = Networking(baseURL: baseURL) - await networking.fakeDELETE("/entries", fileName: "entries.json", bundle: .module) + await networking.fakeDELETE( + "/entries", + fileName: "entries.json", + bundle: .module + ) let result: Result<[[String: AnyCodable]], NetworkingError> = await networking.delete("/entries") switch result { @@ -656,12 +748,20 @@ extension FakeRequestTests { let delay: Double = 2.0 let startTime = Date() - await networking.fakeImageDownload("/image/png", image: pigImage, delay: delay) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + delay: delay + ) let result: Result = await networking.downloadImage("/image/png") let endTime = Date() let elapsedTime = endTime.timeIntervalSince(startTime) - XCTAssertGreaterThanOrEqual(elapsedTime, delay, "The delay was not correctly applied") + XCTAssertGreaterThanOrEqual( + elapsedTime, + delay, + "The delay was not correctly applied" + ) switch result { case .success(let image): @@ -674,7 +774,11 @@ extension FakeRequestTests { func testFakeImageDownloadWithInvalidStatusCode() async throws { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, statusCode: 401) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + statusCode: 401 + ) let result: Result = await networking.downloadImage("/image/png") switch result { case .success: @@ -691,7 +795,11 @@ extension FakeRequestTests { func testFakeImageDownloadUsingHeader() async throws { let networking = Networking(baseURL: baseURL) let pigImage = Image.find(named: "pig.png", inBundle: .module) - await networking.fakeImageDownload("/image/png", image: pigImage, headerFields: ["uid": "12345678"]) + await networking.fakeImageDownload( + "/image/png", + image: pigImage, + headerFields: ["uid": "12345678"] + ) let result: Result = await networking.downloadImage("/image/png") switch result { case .success(let response): diff --git a/Tests/NetworkingTests/GETIntegrationTests.swift b/Tests/NetworkingTests/GETIntegrationTests.swift index 2ded889..5f67ca5 100644 --- a/Tests/NetworkingTests/GETIntegrationTests.swift +++ b/Tests/NetworkingTests/GETIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class GETIntegrationTests: XCTestCase { +final class GETIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testGET() async throws { diff --git a/Tests/NetworkingTests/GETTests.swift b/Tests/NetworkingTests/GETTests.swift index e77770e..cc26629 100644 --- a/Tests/NetworkingTests/GETTests.swift +++ b/Tests/NetworkingTests/GETTests.swift @@ -8,12 +8,16 @@ struct Friend: Decodable { let title: String } -class GETTests: XCTestCase { +final class GETTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testGETCachedFromMemory() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get("/get", cachingLevel: .memory) switch firstResult { @@ -36,7 +40,11 @@ class GETTests: XCTestCase { func testGETCachedFromFile() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get( "/get", cachingLevel: .memoryAndFile) @@ -60,7 +68,11 @@ class GETTests: XCTestCase { func testGETCachedNone() async throws { let cache = NSCache() - let networking = Networking(baseURL: baseURL, configuration: .default, cache: cache) + let networking = Networking( + baseURL: baseURL, + configuration: .default, + cache: cache + ) await networking.fakeGET("/get", response: ["key": "value1"]) let firstResult: Result = await networking.get("/get", cachingLevel: .none) switch firstResult { diff --git a/Tests/NetworkingTests/NetworkingIntegrationTests.swift b/Tests/NetworkingTests/NetworkingIntegrationTests.swift index be80246..8c12e61 100644 --- a/Tests/NetworkingTests/NetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NetworkingIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class NetworkingIntegrationTests: XCTestCase { +final class NetworkingIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testSetAuthorizationHeaderWithUsernameAndPassword() async throws { diff --git a/Tests/NetworkingTests/NetworkingTests.swift b/Tests/NetworkingTests/NetworkingTests.swift index 875ffb2..a39060c 100644 --- a/Tests/NetworkingTests/NetworkingTests.swift +++ b/Tests/NetworkingTests/NetworkingTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class NetworkingTests: XCTestCase { +final class NetworkingTests: XCTestCase { let baseURL = "http://example.com" func setAuthorizationHeaderCustomValue() async throws { diff --git a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift index f86200d..d988c60 100644 --- a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift @@ -4,7 +4,7 @@ import XCTest @testable import Networking -class NewNetworkingIntegrationTests: XCTestCase { +final class NewNetworkingIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testNewGET() async throws { @@ -13,7 +13,7 @@ class NewNetworkingIntegrationTests: XCTestCase { let result: Result = await networking.get("/get") switch result { - case .success(_): + case .success: print("worked") case .failure(let failure): print(failure.localizedDescription) @@ -36,7 +36,7 @@ class NewNetworkingIntegrationTests: XCTestCase { let result: Result = await networking.get("/get", query: query) switch result { - case .success(_): + case .success: print("Test passed") case .failure(let error): print("error \(error)") @@ -49,7 +49,7 @@ class NewNetworkingIntegrationTests: XCTestCase { let result: Result = await networking.post("/get", body: ["String": "String"]) switch result { - case .success(_): + case .success: print("worked") case .failure(let failure): print(failure.localizedDescription) diff --git a/Tests/NetworkingTests/NewNetworkingTests.swift b/Tests/NetworkingTests/NewNetworkingTests.swift index db6af71..12cd47d 100644 --- a/Tests/NetworkingTests/NewNetworkingTests.swift +++ b/Tests/NetworkingTests/NewNetworkingTests.swift @@ -4,7 +4,7 @@ import XCTest @testable import Networking -class NewNetworkingTests: XCTestCase { +final class NewNetworkingTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL struct ValidationErrors: Decodable { let errors: [String: [String]] } @@ -17,7 +17,11 @@ class NewNetworkingTests: XCTestCase { "phone_number": ["has already been taken"] ] ] - await networking.fakePOST("/auth", response: response, statusCode: 422) + await networking.fakePOST( + "/auth", + response: response, + statusCode: 422 + ) let result: Result = await networking.post("/auth") switch result { diff --git a/Tests/NetworkingTests/ObservabilityIntegrationTests.swift b/Tests/NetworkingTests/ObservabilityIntegrationTests.swift index 87b97fb..29c9c5a 100644 --- a/Tests/NetworkingTests/ObservabilityIntegrationTests.swift +++ b/Tests/NetworkingTests/ObservabilityIntegrationTests.swift @@ -13,13 +13,21 @@ final class ObservabilityIntegrationTests: XCTestCase { let _: Result = await networking.get("/get") let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "expected a .started and a .completed event") + XCTAssertEqual( + events.count, + 2, + "expected a .started and a .completed event" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, let duration, let metrics) = events.last else { return XCTFail("expected .started then .completed, got \(events)") } - XCTAssertEqual(startContext.id, endContext.id, "the two events should share a request id") + XCTAssertEqual( + startContext.id, + endContext.id, + "the two events should share a request id" + ) XCTAssertEqual(startContext.method, "GET") XCTAssertGreaterThan(duration, .zero) guard case .success(let statusCode, let byteCount) = outcome else { @@ -60,7 +68,11 @@ final class ObservabilityIntegrationTests: XCTestCase { guard case .started(let context) = events.first else { return XCTFail("expected a .started event, got \(events)") } - XCTAssertEqual(context.headers["Authorization"], "Bearer supersecret", "events() carries the real header value") + XCTAssertEqual( + context.headers["Authorization"], + "Bearer supersecret", + "events() carries the real header value" + ) XCTAssertEqual(context.headers["Cookie"], "session=abc") XCTAssertEqual(context.headers["X-Trace"], "trace-123") } @@ -75,13 +87,21 @@ final class ObservabilityIntegrationTests: XCTestCase { let _: Result = await networking.post("/post", body: Unencodable()) let events = await stream.collect(2) - XCTAssertEqual(events.count, 2, "an encode failure must still emit .started and .completed; got \(events)") + XCTAssertEqual( + events.count, + 2, + "an encode failure must still emit .started and .completed; got \(events)" + ) guard case .started(let startContext) = events.first, case .completed(let endContext, let outcome, _, _) = events.last else { return XCTFail("expected .started then .completed, got \(events)") } - XCTAssertEqual(startContext.id, endContext.id, "both events should share a request id") + XCTAssertEqual( + startContext.id, + endContext.id, + "both events should share a request id" + ) guard case .failure(let error) = outcome, case .invalidRequest = error else { return XCTFail("expected an .invalidRequest failure outcome, got \(outcome)") } diff --git a/Tests/NetworkingTests/PATCHIntegrationTests.swift b/Tests/NetworkingTests/PATCHIntegrationTests.swift index 0545654..8768004 100644 --- a/Tests/NetworkingTests/PATCHIntegrationTests.swift +++ b/Tests/NetworkingTests/PATCHIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class PATCHIntegrationTests: XCTestCase { +final class PATCHIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPATCH() async throws { diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index 4a3b8c4..51f2d9b 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class POSTIntegrationTests: XCTestCase { +final class POSTIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPOSTWithoutBody() async throws { @@ -28,7 +28,13 @@ class POSTIntegrationTests: XCTestCase { } // Distinct values so an Int/Double/Bool encoding mix-up would actually fail the assertions. let result: Result = await networking.post( - "/post", body: Payload(string: "valueA", int: 20, double: 20.5, bool: false)) + "/post", + body: Payload( + string: "valueA", + int: 20, + double: 20.5, + bool: false + )) switch result { case .success(let response): XCTAssertEqual(response.json.string, "valueA") @@ -91,8 +97,16 @@ class POSTIntegrationTests: XCTestCase { let item1 = "FIRSTDATA" let item2 = "SECONDDATA" - let part1 = FormDataPart(data: item1.data(using: .utf8)!, parameterName: item1, filename: "\(item1).png") - let part2 = FormDataPart(data: item2.data(using: .utf8)!, parameterName: item2, filename: "\(item2).png") + let part1 = FormDataPart( + data: item1.data(using: .utf8)!, + parameterName: item1, + filename: "\(item1).png" + ) + let part2 = FormDataPart( + data: item2.data(using: .utf8)!, + parameterName: item2, + filename: "\(item2).png" + ) let fields = [ "string": "valueA", "int": "20", @@ -126,7 +140,11 @@ class POSTIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL) let item1 = "FIRSTDATA" - let part1 = FormDataPart(data: item1.data(using: .utf8)!, parameterName: item1, filename: "\(item1).png") + let part1 = FormDataPart( + data: item1.data(using: .utf8)!, + parameterName: item1, + filename: "\(item1).png" + ) let result: Result = await networking.post("/post", parts: [part1]) switch result { case .success(let response): @@ -147,7 +165,11 @@ class POSTIntegrationTests: XCTestCase { let imageURL = try XCTUnwrap(Bundle.module.url(forResource: "pig", withExtension: "png")) let imageData = try Data(contentsOf: imageURL) - let imagePart = FormDataPart(data: imageData, parameterName: "file", filename: "pig.png") + let imagePart = FormDataPart( + data: imageData, + parameterName: "file", + filename: "pig.png" + ) let result: Result = await networking.post( "/post", parts: [imagePart], fields: ["public_id": "pig"]) diff --git a/Tests/NetworkingTests/PUTIntegrationTests.swift b/Tests/NetworkingTests/PUTIntegrationTests.swift index ba4d0d1..b524a4c 100644 --- a/Tests/NetworkingTests/PUTIntegrationTests.swift +++ b/Tests/NetworkingTests/PUTIntegrationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class PUTIntegrationTests: XCTestCase { +final class PUTIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testPUT() async throws { diff --git a/Tests/NetworkingTests/ResponseIntegrationTests.swift b/Tests/NetworkingTests/ResponseIntegrationTests.swift index 8e813d5..45feb1d 100644 --- a/Tests/NetworkingTests/ResponseIntegrationTests.swift +++ b/Tests/NetworkingTests/ResponseIntegrationTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import Networking -class ResponseIntegrationTests: XCTestCase { +final class ResponseIntegrationTests: XCTestCase { let baseURL = TestConfig.httpbinBaseURL func testReflectsRequestHeaderInBody() async throws { diff --git a/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift b/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift index e40e377..275707c 100644 --- a/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift +++ b/Tests/NetworkingTests/ResponseValidatorIntegrationTests.swift @@ -29,7 +29,11 @@ final class ResponseValidatorIntegrationTests: XCTestCase { return XCTFail("expected a .validation failure, got \(result)") } XCTAssertTrue(reason.contains("application/json"), "the reason should explain the expectation; got \(reason)") - XCTAssertEqual(metadata.statusCode, 200, "validation carries the offending response's metadata") + XCTAssertEqual( + metadata.statusCode, + 200, + "validation carries the offending response's metadata" + ) } // A validator scrutinizes successes, not errors: a non-2xx response must still surface as its .http diff --git a/Tests/NetworkingTests/RetryInterceptorTests.swift b/Tests/NetworkingTests/RetryInterceptorTests.swift index ff7f1fa..f5675fc 100644 --- a/Tests/NetworkingTests/RetryInterceptorTests.swift +++ b/Tests/NetworkingTests/RetryInterceptorTests.swift @@ -33,7 +33,12 @@ final class RetryInterceptorTests: XCTestCase { switch outcomeForAttempt(attempt) { case .status(let code, let headers): let url = request.url ?? URL(string: "https://example.com")! - let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: headers)! + let response = HTTPURLResponse( + url: url, + statusCode: code, + httpVersion: nil, + headerFields: headers + )! return HTTPExchange(data: Data(), response: response) case .throwTransport(let code): throw URLError(code) @@ -50,7 +55,11 @@ final class RetryInterceptorTests: XCTestCase { func testRetriesTransientStatusThenSucceeds() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 5, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 5, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { $0 <= 2 ? .status(503) : .status(200) } ) @@ -58,13 +67,21 @@ final class RetryInterceptorTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected success after retries, got \(error)") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "two 503s should be retried, succeeding on the third attempt") + XCTAssertEqual( + attempts, + 3, + "two 503s should be retried, succeeding on the third attempt" + ) } func testGivesUpAfterMaxAttempts() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) @@ -75,13 +92,21 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 503) let attempts = await counter.count - XCTAssertEqual(attempts, 3, "a persistently failing request should stop at maxAttempts") + XCTAssertEqual( + attempts, + 3, + "a persistently failing request should stop at maxAttempts" + ) } func testHonorsRetryAfterHeader() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .seconds(5)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .seconds(5) + ), ScriptedInterceptor(counter: counter) { $0 == 1 ? .status(429, headers: ["Retry-After": "1"]) : .status(200) } @@ -103,7 +128,11 @@ final class RetryInterceptorTests: XCTestCase { func testDoesNotRetryNonIdempotentMethodByDefault() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) @@ -114,7 +143,11 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 503) let attempts = await counter.count - XCTAssertEqual(attempts, 1, "POST is not idempotent and must not be retried by default") + XCTAssertEqual( + attempts, + 1, + "POST is not idempotent and must not be retried by default" + ) } func testRetriesNonIdempotentMethodWhenExplicitlyConfigured() async { @@ -130,13 +163,21 @@ final class RetryInterceptorTests: XCTestCase { if case .success = result { XCTFail("expected the persistent 503 to fail") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "an explicit retryableMethods opt-in should retry POST") + XCTAssertEqual( + attempts, + 3, + "an explicit retryableMethods opt-in should retry POST" + ) } func testDoesNotRetryNonRetryableStatus() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { _ in .status(404) } ) @@ -147,13 +188,21 @@ final class RetryInterceptorTests: XCTestCase { } XCTAssertEqual(error.statusCode, 404) let attempts = await counter.count - XCTAssertEqual(attempts, 1, "a 404 is not retryable and must not be retried") + XCTAssertEqual( + attempts, + 1, + "a 404 is not retryable and must not be retried" + ) } func testRetriesTransientTransportError() async { let counter = CallCounter() let networking = await networking( - RetryInterceptor(maxAttempts: 4, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2)), + RetryInterceptor( + maxAttempts: 4, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2) + ), ScriptedInterceptor(counter: counter) { $0 <= 2 ? .throwTransport(.timedOut) : .status(200) } ) @@ -163,6 +212,10 @@ final class RetryInterceptorTests: XCTestCase { XCTFail("expected success after transient transport retries, got \(error)") } let attempts = await counter.count - XCTAssertEqual(attempts, 3, "two timeouts should be retried, succeeding on the third attempt") + XCTAssertEqual( + attempts, + 3, + "two timeouts should be retried, succeeding on the third attempt" + ) } } diff --git a/Tests/NetworkingTests/String+UTF8Tests.swift b/Tests/NetworkingTests/String+UTF8Tests.swift index e306f38..491aaaa 100644 --- a/Tests/NetworkingTests/String+UTF8Tests.swift +++ b/Tests/NetworkingTests/String+UTF8Tests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -class String_UTF8Tests: XCTestCase { +final class String_UTF8Tests: XCTestCase { func testEncodeUTF8WithNorwegianCharacters() { let encodedString = "døgnvillburgere.jpg".encodeUTF8() diff --git a/Tests/NetworkingTests/TestHelpers.swift b/Tests/NetworkingTests/TestHelpers.swift index 6507ab0..0017e67 100644 --- a/Tests/NetworkingTests/TestHelpers.swift +++ b/Tests/NetworkingTests/TestHelpers.swift @@ -53,7 +53,11 @@ func httpbinEchoedMap(_ response: JSONResponse, _ key: String) -> [String: Strin struct Helper { - static func removeFileIfNeeded(_ networking: Networking, path: String, cacheName: String? = nil) throws { + static func removeFileIfNeeded( + _ networking: Networking, + path: String, + cacheName: String? = nil + ) throws { let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) if FileManager.default.exists(at: destinationURL) { try FileManager.default.remove(at: destinationURL) diff --git a/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift b/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift index a09851b..bcef909 100644 --- a/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift +++ b/Tests/NetworkingTests/TypedRequestBodyIntegrationTests.swift @@ -21,7 +21,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPOSTWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "jameson", age: 20, active: true) + let body = Credentials( + username: "jameson", + age: 20, + active: true + ) let result: Result = await networking.post("/post", body: body) switch result { case .success(let echo): @@ -34,7 +38,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPOSTWithTypedBodyVoidReturn() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "jameson", age: 20, active: true) + let body = Credentials( + username: "jameson", + age: 20, + active: true + ) let result: Result = await networking.post("/post", body: body) if case .failure(let error) = result { XCTFail(error.localizedDescription) @@ -43,7 +51,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPUTWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "ada", age: 36, active: false) + let body = Credentials( + username: "ada", + age: 36, + active: false + ) let result: Result = await networking.put("/put", body: body) switch result { case .success(let echo): @@ -55,7 +67,11 @@ final class TypedRequestBodyIntegrationTests: XCTestCase { func testPATCHWithTypedBodyRoundTrips() async throws { let networking = Networking(baseURL: baseURL) - let body = Credentials(username: "grace", age: 45, active: true) + let body = Credentials( + username: "grace", + age: 45, + active: true + ) let result: Result = await networking.patch("/patch", body: body) switch result { case .success(let echo): diff --git a/bin/lint b/bin/lint new file mode 100755 index 0000000..fb8ca29 --- /dev/null +++ b/bin/lint @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Lints with oida, our own linter, whose rules are in .oida.yml. +# +# bin/lint report what it finds +# bin/lint --fix fix what can be fixed, and format +# bin/lint --check fail on any violation or unformatted file (what CI runs) +# bin/lint --staged the same check, over staged files only (what the commit hook runs) +# +# One tool does both jobs: the linter decides which lists split and which join, then hands the files to +# swift-format — Xcode's on a Mac, the toolchain's on Linux CI, either way the binary Format File runs — +# which owns every indent. +# +# There is no baseline. A standing violation would be silent under one, and this repository adopted oida +# to find what it says rather than to record that it once said something, so the tree is kept at zero and +# any violation fails. +set -euo pipefail +cd "$(dirname "$0")/.." + +# oida ships two builds, and this is the only place that chooses between them. Anything else fails +# loudly rather than guessing. +case "$(uname -s)" in + Darwin) + platform="macos-arm64" + default_cache_root="$HOME/Library/Caches" + ;; + Linux) + platform="linux-x86_64" + default_cache_root="$HOME/.cache" + ;; + *) + echo "oida has no build for $(uname -s). It ships macOS arm64 and Linux x86_64 only." >&2 + exit 1 + ;; +esac + +# The linter is ours (github.com/kolonialno/oida-swift). It arrives as a download rather than a committed +# binary, because every landing of a 7 MB blob stays in the history forever. +# +# Pinned and checksummed rather than "latest": .oida-version names the release and .oida-checksum-$platform +# names its bytes, so a clone cannot lint with a build this repository did not ask for, and a tampered +# download cannot pass for one. The version is asserted after unzipping too, which is what catches a +# half-finished download rather than running whatever is in the cache. +version=$(cat .oida-version) +expected=$(cat ".oida-checksum-$platform") +cache="${XDG_CACHE_HOME:-$default_cache_root}/oida/$version" +oida="$cache/oida" + +if [ ! -x "$oida" ]; then + archive=$(mktemp -d)/oida-$platform.zip + url="https://github.com/kolonialno/oida-swift/releases/download/$version/oida-$platform.zip" + echo "Fetching the linter, $version — once per version." >&2 + if ! curl -fsSL "$url" -o "$archive"; then + echo "Could not download $url" >&2 + echo "Check that .oida-version names a published release." >&2 + exit 1 + fi + found=$(shasum -a 256 "$archive" | cut -d' ' -f1) + if [ "$found" != "$expected" ]; then + echo "The download does not match .oida-checksum-$platform." >&2 + echo " expected $expected" >&2 + echo " found $found" >&2 + echo "Refusing to run it. Either the release was rebuilt or the download is not what it claims." >&2 + exit 1 + fi + mkdir -p "$cache" + unzip -qo "$archive" -d "$cache" + chmod +x "$oida" + rm -rf "$(dirname "$archive")" +fi + +# The pin is asserted rather than trusted: a cached binary from an interrupted download, or one someone +# put there by hand, would otherwise lint by rules this repository never chose. +have=$("$oida" version) +if [ "$version" != "$have" ]; then + echo "The linter at $oida reports $have but .oida-version pins $version." >&2 + echo "Remove it and let this script fetch the pinned one:" >&2 + echo " rm -rf \"$cache\"" >&2 + exit 1 +fi + +case "${1:-}" in + --staged) + staged=() + while IFS= read -r -d '' file; do staged+=("$file"); done < <(./bin/staged-swift-files) + + if [ ${#staged[@]} -eq 0 ]; then + echo "No staged Swift files." + exit 0 + fi + + # The whole repository, not just the staged files. A rule that counts callers across the run + # reads a partial run as a function with fewer callers than it has and invents violations, so + # the staged list decides only whether to run at all. + if ! "$oida" lint --strict --format --quiet; then + echo "Fix the violations above, or justify one with a scoped oida:disable." >&2 + exit 1 + fi + echo "Lint clean, with ${#staged[@]} staged Swift file(s)." + ;; + --check) + "$oida" lint --strict --format --quiet + echo "Lint and formatting are clean." + ;; + --fix) + "$oida" --fix --format --quiet >/dev/null + echo "Fixed and formatted." + ;; + *) + "$oida" lint --strict + ;; +esac diff --git a/bin/staged-swift-files b/bin/staged-swift-files new file mode 100755 index 0000000..572c445 --- /dev/null +++ b/bin/staged-swift-files @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# Prints the staged Swift files worth checking, null-delimited, from the repo root. +# +# bin/lint decides from this list whether a commit is worth linting at all — it lints the whole +# repository when it runs, since a rule that counts callers across the run cannot read a partial one. +# So a file this list wrongly keeps costs a couple of seconds, and one it wrongly skips costs a commit +# that was never checked. The list mirrors `excluded:` in .oida.yml. +# +# Null-delimited, so a Swift file under a folder with a space in its name stays one path. +set -euo pipefail +cd "$(dirname "$0")/.." + +# Paths arrive relative to the repo root. +excluded='/\.build/|^\.build/|^build/|/DerivedData/|/SourcePackages/|/GeneratedSources/|/Intermediates\.noindex/' + +# --diff-filter drops deletions and renames-away: there is nothing left to read. +while IFS= read -r -d '' file; do + printf '%s' "$file" | grep -qE "$excluded" || printf '%s\0' "$file" +done < <(git diff --cached --name-only --diff-filter=ACMR -z -- '*.swift') From c31da44c9731b2a445ee63c17fef665fbe49a7fa Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:30:06 +0200 Subject: [PATCH 02/12] One tool formats, and it is the one that lints bin/lint already hands the corrected files to swift-format, so the standalone swift-format CI job and the swift-format pre-commit hook were running it a second time. The hook now calls bin/lint --staged and CI calls bin/lint --check. .swift-format stays: swift-format still owns every indent, it is just reached through oida now, which is what keeps the tree and Xcode's Format File agreeing. --- .githooks/pre-commit | 25 ++----------------------- .github/workflows/ci.yml | 15 ++++++--------- scripts/setup.sh | 2 +- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a49efcd..b2e1b8a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,26 +1,5 @@ #!/bin/sh -# Formats staged Swift files with swift-format before each commit. +# Lints and formats before each commit, so what CI checks is what you push. # Enable once per clone: ./scripts/setup.sh (or git config core.hooksPath .githooks) set -e - -# NUL-delimited so paths with spaces survive. ACM excludes deletions. -files=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.swift' | tr '\0' '\n') -[ -z "$files" ] && exit 0 - -if ! command -v swift >/dev/null 2>&1; then - echo "pre-commit: swift toolchain not found; skipping swift-format." >&2 - exit 0 -fi - -echo "$files" | while IFS= read -r f; do - [ -n "$f" ] || continue - # A file with unstaged changes is partially staged; formatting in place and - # re-adding would pull the unstaged hunks into the commit. Skip it — CI's - # format check is the backstop for anything the hook can't safely touch. - if git diff --quiet -- "$f"; then - swift format --in-place "$f" - git add -- "$f" - else - echo "pre-commit: '$f' is partially staged; skipping format (run swift format manually)." >&2 - fi -done +exec "$(git rev-parse --show-toplevel)/bin/lint" --staged diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a31fbdb..e7aabe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,18 +13,15 @@ env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer jobs: - swift-format-check: - name: swift-format check + lint: + name: Lint runs-on: xcode-27 steps: - uses: actions/checkout@v5 - - name: Check formatting - run: | - swift format --in-place --recursive Sources Tests - if ! git diff --exit-code; then - echo "::error::Code is not formatted. Run: swift format --in-place --recursive Sources Tests (and commit)." - exit 1 - fi + # oida lints and then hands the files to swift-format, so one job covers both. There is no + # baseline: any violation fails. + - name: bin/lint --check + run: ./bin/lint --check build-and-test: name: macOS Swift Build & Test diff --git a/scripts/setup.sh b/scripts/setup.sh index 47491e8..5b70088 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -4,4 +4,4 @@ set -e repo_root=$(git rev-parse --show-toplevel) git -C "$repo_root" config core.hooksPath .githooks -echo "setup: core.hooksPath -> .githooks (swift-format pre-commit hook enabled)" +echo "setup: core.hooksPath -> .githooks (oida pre-commit hook enabled)" From b3026e48ad6a5e14f5c52c41bfc64cf1c82c47b3 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:31:29 +0200 Subject: [PATCH 03/12] Every XCTFail says what was expected, and the mechanical violations go XCTFail() with no message tells whoever reads the failure nothing the test name did not already say, so all seventeen now name the expectation: "a GET to a path that does not exist must fail", "the image should come back from the file cache". The rest is mechanical. Trailing commas dropped from collection literals. Two force-unwrapped `"...".data(using: .utf8)!` become `Data("...".utf8)`, which cannot fail and so needs no unwrap. `arc4random()` becomes `UInt32.random(in: .min ... .max)`. Two test classes lose the underscore their filename put in their name. 438 violations down to 238, suite 180/180 green. --- Package.swift | 4 +-- Sources/Networking/FormDataPart.swift | 2 +- Sources/Networking/Networking+New.swift | 2 +- Sources/Networking/Networking.swift | 4 +-- .../DELETEIntegrationTests.swift | 2 +- .../Dictionary+FormURLEncodedTests.swift | 2 +- .../DownloadIntegrationTests.swift | 10 +++--- Tests/NetworkingTests/FakeRequestTests.swift | 34 +++++++++---------- .../NetworkingTests/GETIntegrationTests.swift | 2 +- .../NewNetworkingIntegrationTests.swift | 2 +- .../PATCHIntegrationTests.swift | 2 +- .../POSTIntegrationTests.swift | 6 ++-- .../NetworkingTests/PUTIntegrationTests.swift | 2 +- Tests/NetworkingTests/String+UTF8Tests.swift | 2 +- 14 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Package.swift b/Package.swift index 04f725e..985b34e 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Networking", platforms: [ - .iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11), + .iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11) ], products: [ .library(name: "Networking", targets: ["Networking"]) @@ -22,7 +22,7 @@ let package = Package( dependencies: ["Networking"], path: "Tests", resources: [.process("NetworkingTests/Resources")] - ), + ) ], swiftLanguageModes: [.v6] ) diff --git a/Sources/Networking/FormDataPart.swift b/Sources/Networking/FormDataPart.swift index 43d53bd..01cd4b4 100644 --- a/Sources/Networking/FormDataPart.swift +++ b/Sources/Networking/FormDataPart.swift @@ -33,7 +33,7 @@ public struct FormDataPart { var bodyData = Data() bodyData.append(body.data(using: .utf8)!) bodyData.append(data) - bodyData.append("\r\n".data(using: .utf8)!) + bodyData.append(Data("\r\n".utf8)) return bodyData as Data } diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index 8425997..a0c1b7b 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -413,7 +413,7 @@ extension Networking { part.boundary = boundary bodyData.append(part.formData as Data) } - bodyData.append("--\(boundary)--\r\n".data(using: .utf8)!) + bodyData.append(Data("--\(boundary)--\r\n".utf8)) return bodyData case .raw(let data, _): return data diff --git a/Sources/Networking/Networking.swift b/Sources/Networking/Networking.swift index d95c882..ff376cc 100644 --- a/Sources/Networking/Networking.swift +++ b/Sources/Networking/Networking.swift @@ -164,8 +164,8 @@ public actor Networking { nonisolated let boundary = String( format: "com.elvisnunez.networking.%08x%08x", - arc4random(), - arc4random() + UInt32.random(in: .min ... .max), + UInt32.random(in: .min ... .max) ) lazy var session: URLSession = { diff --git a/Tests/NetworkingTests/DELETEIntegrationTests.swift b/Tests/NetworkingTests/DELETEIntegrationTests.swift index 174cb50..742210f 100644 --- a/Tests/NetworkingTests/DELETEIntegrationTests.swift +++ b/Tests/NetworkingTests/DELETEIntegrationTests.swift @@ -35,7 +35,7 @@ final class DELETEIntegrationTests: XCTestCase { let result: Result = await networking.delete("/invalidpath") switch result { case .success: - XCTFail() + XCTFail("a DELETE to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift index 2d3dddf..4fd9789 100644 --- a/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift +++ b/Tests/NetworkingTests/Dictionary+FormURLEncodedTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -final class Dictionary_FormURLEncodedTests: XCTestCase { +final class DictionaryFormURLEncodedTests: XCTestCase { func testFormatting() throws { let parameters = ["username": "elvis", "password": "secret"] diff --git a/Tests/NetworkingTests/DownloadIntegrationTests.swift b/Tests/NetworkingTests/DownloadIntegrationTests.swift index 46aad31..c73a584 100644 --- a/Tests/NetworkingTests/DownloadIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadIntegrationTests.swift @@ -86,7 +86,7 @@ final class DownloadIntegrationTests: XCTestCase { let destinationURL = try networking.destinationURL(for: path) let absoluteString = destinationURL.absoluteString guard let image = networking.cache.object(forKey: absoluteString as AnyObject) as? Image else { - XCTFail() + XCTFail("the downloaded image should be in the memory cache") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -113,7 +113,7 @@ final class DownloadIntegrationTests: XCTestCase { let destinationURL = try networking.destinationURL(for: path, cacheName: cacheName) let absoluteString = destinationURL.absoluteString guard let image = networking.cache.object(forKey: absoluteString as AnyObject) as? Image else { - XCTFail() + XCTFail("the downloaded image should be in the memory cache under its custom name") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -164,7 +164,7 @@ final class DownloadIntegrationTests: XCTestCase { let absoluteString = destinationURL.absoluteString networking.cache.removeObject(forKey: absoluteString as AnyObject) guard let image = try networking.imageFromCache(path) else { - XCTFail() + XCTFail("the image should come back from the file cache") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -190,7 +190,7 @@ final class DownloadIntegrationTests: XCTestCase { let absoluteString = destinationURL.absoluteString networking.cache.removeObject(forKey: absoluteString as AnyObject) guard let image = try networking.imageFromCache(path, cacheName: cacheName) else { - XCTFail() + XCTFail("the image should come back from the file cache under its custom name") return } let pigImage = Image.find(named: "pig.png", inBundle: .module) @@ -257,7 +257,7 @@ final class DownloadIntegrationTests: XCTestCase { if let cacheData = try networking.dataFromCache(path) { XCTAssert(data == cacheData) } else { - XCTFail() + XCTFail("the data should come back from the cache") } case .failure(let error): XCTFail(error.localizedDescription) diff --git a/Tests/NetworkingTests/FakeRequestTests.swift b/Tests/NetworkingTests/FakeRequestTests.swift index 1671de5..840025c 100644 --- a/Tests/NetworkingTests/FakeRequestTests.swift +++ b/Tests/NetworkingTests/FakeRequestTests.swift @@ -100,7 +100,7 @@ final class FakeRequestTests: XCTestCase { "/users/cedos": request, "/users/tedos": request, "/users/melos": request, - "/users/{userID}": request, + "/users/{userID}": request ] ] let result = try FakeRequest.find( @@ -119,7 +119,7 @@ final class FakeRequestTests: XCTestCase { func testTwoLevelFind() throws { let json = [ "user": "User {userID}", - "company": "Company {companyID}", + "company": "Company {companyID}" ] let request = FakeRequest( payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, @@ -133,7 +133,7 @@ final class FakeRequestTests: XCTestCase { let expected = [ "user": "User 10", - "company": "Company 20", + "company": "Company 20" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) @@ -143,7 +143,7 @@ final class FakeRequestTests: XCTestCase { let json = [ "user": "User {userID}", "company": "Company {companyID}", - "product": "Product {productID}", + "product": "Product {productID}" ] let request = FakeRequest( payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, @@ -157,7 +157,7 @@ final class FakeRequestTests: XCTestCase { "/users/{userID}/companies/{companyID}/products/{productID}": request, "/users/{userID}/companies/{companyID}/products/e": request, "/users/{userID}/companies/{companyID}/products/f": request, - "/users/{userID}/companies/{companyID}/products/g": request, + "/users/{userID}/companies/{companyID}/products/g": request ] ] let result = try FakeRequest.find( @@ -166,7 +166,7 @@ final class FakeRequestTests: XCTestCase { let expected = [ "user": "User 10", "company": "Company 20", - "product": "Product 30", + "product": "Product 30" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) @@ -183,7 +183,7 @@ final class FakeRequestTests: XCTestCase { "resource7": "Resource {resourceID7}", "resource8": "Resource {resourceID8}", "resource9": "Resource {resourceID9}", - "resource10": "Resource {resourceID10}", + "resource10": "Resource {resourceID10}" ] let request = FakeRequest( @@ -210,7 +210,7 @@ final class FakeRequestTests: XCTestCase { "resource7": "Resource 7", "resource8": "Resource 8", "resource9": "Resource 9", - "resource10": "Resource 10", + "resource10": "Resource 10" ] XCTAssertEqual(decodedDictionary(result), expected as NSDictionary) } @@ -268,7 +268,7 @@ extension FakeRequestTests { let result: Result = await networking.get("/stories") switch result { case .success: - XCTFail() + XCTFail("a faked GET carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -291,7 +291,7 @@ extension FakeRequestTests { let result: Result = await networking.get("/stories") switch result { case .success: - XCTFail() + XCTFail("a faked GET on an unregistered path must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -438,7 +438,7 @@ extension FakeRequestTests { let result: Result = await networking.post("/story") switch result { case .success: - XCTFail() + XCTFail("a faked POST carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -528,7 +528,7 @@ extension FakeRequestTests { let result: Result = await networking.put("/story") switch result { case .success: - XCTFail() + XCTFail("a faked PUT carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -595,7 +595,7 @@ extension FakeRequestTests { let result: Result = await networking.patch("/story") switch result { case .success: - XCTFail() + XCTFail("a faked PATCH carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -685,7 +685,7 @@ extension FakeRequestTests { let result: Result = await networking.delete("/story") switch result { case .success: - XCTFail() + XCTFail("a faked DELETE carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -782,7 +782,7 @@ extension FakeRequestTests { let result: Result = await networking.downloadImage("/image/png") switch result { case .success: - XCTFail() + XCTFail("a faked image download carrying a failing status code must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") @@ -822,12 +822,12 @@ extension FakeRequestTests { "client": "aClient", "access-token": "anAccessToken", "uid": "aUID", - "Authorization": "authorization", + "Authorization": "authorization" ]) let parameters = [ "phone_number": "phoneNumber", - "confirmation_code": "confirmationCode", + "confirmation_code": "confirmationCode" ] let result: Result = await networking.post( diff --git a/Tests/NetworkingTests/GETIntegrationTests.swift b/Tests/NetworkingTests/GETIntegrationTests.swift index 5f67ca5..454768a 100644 --- a/Tests/NetworkingTests/GETIntegrationTests.swift +++ b/Tests/NetworkingTests/GETIntegrationTests.swift @@ -47,7 +47,7 @@ final class GETIntegrationTests: XCTestCase { let result: Result = await networking.get("/invalidpath") switch result { case .success: - XCTFail() + XCTFail("a GET to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift index d988c60..a6c22b4 100644 --- a/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NewNetworkingIntegrationTests.swift @@ -30,7 +30,7 @@ final class NewNetworkingIntegrationTests: XCTestCase { URLQueryItem(name: "pickup_latitude", value: "\(pickupCoordinate.latitude)"), URLQueryItem(name: "pickup_longitude", value: "\(pickupCoordinate.longitude)"), URLQueryItem(name: "delivery_latitude", value: "\(deliveryCoordinate.latitude)"), - URLQueryItem(name: "delivery_longitude", value: "\(deliveryCoordinate.longitude)"), + URLQueryItem(name: "delivery_longitude", value: "\(deliveryCoordinate.longitude)") ] let result: Result = await networking.get("/get", query: query) diff --git a/Tests/NetworkingTests/PATCHIntegrationTests.swift b/Tests/NetworkingTests/PATCHIntegrationTests.swift index 8768004..5f84839 100644 --- a/Tests/NetworkingTests/PATCHIntegrationTests.swift +++ b/Tests/NetworkingTests/PATCHIntegrationTests.swift @@ -41,7 +41,7 @@ final class PATCHIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a PATCH to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index 51f2d9b..3357ccd 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -76,7 +76,7 @@ final class POSTIntegrationTests: XCTestCase { "int": "20", "double": "20.0", "bool": "true", - "date": "2016-11-02T13:55:28+01:00", + "date": "2016-11-02T13:55:28+01:00" ] let result: Result = await networking.post("/post", form: parameters) switch result { @@ -111,7 +111,7 @@ final class POSTIntegrationTests: XCTestCase { "string": "valueA", "int": "20", "double": "20.0", - "bool": "true", + "bool": "true" ] let result: Result = await networking.post( "/post", parts: [part1, part2], fields: fields) @@ -211,7 +211,7 @@ final class POSTIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a POST to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/PUTIntegrationTests.swift b/Tests/NetworkingTests/PUTIntegrationTests.swift index b524a4c..8110f7a 100644 --- a/Tests/NetworkingTests/PUTIntegrationTests.swift +++ b/Tests/NetworkingTests/PUTIntegrationTests.swift @@ -41,7 +41,7 @@ final class PUTIntegrationTests: XCTestCase { "/posdddddt", body: ["username": "jameson", "password": "secret"]) switch result { case .success: - XCTFail() + XCTFail("a PUT to a path that does not exist must fail") case .failure(let error): guard case .http(let httpError) = error else { return XCTFail("expected an HTTP error, got \(error)") diff --git a/Tests/NetworkingTests/String+UTF8Tests.swift b/Tests/NetworkingTests/String+UTF8Tests.swift index 491aaaa..3ab4fc7 100644 --- a/Tests/NetworkingTests/String+UTF8Tests.swift +++ b/Tests/NetworkingTests/String+UTF8Tests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Networking -final class String_UTF8Tests: XCTestCase { +final class StringUTF8Tests: XCTestCase { func testEncodeUTF8WithNorwegianCharacters() { let encodedString = "døgnvillburgere.jpg".encodeUTF8() From d38e6cbc2fe4718ec1fbbf1bf635a9bc6932c2b5 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:40:53 +0200 Subject: [PATCH 04/12] The MARK banners go, and a declaration stands off what follows it Eleven `// MARK: -` banners named what the declarations under them already named, so they are gone. A doc comment that sat above two ordinary comments moves down onto the declaration it documents, where it is read as one. The rest is spacing: a stored property now stands off the function under it. 238 violations down to 220, suite 180/180 green. --- Sources/Networking/CacheStore.swift | 9 +-------- Sources/Networking/DownloadResponse.swift | 2 +- Sources/Networking/Networking+HTTPRequests.swift | 14 -------------- .../AuthRefreshSingleFlightIntegrationTests.swift | 1 + .../DownloadInterceptorIntegrationTests.swift | 2 ++ Tests/NetworkingTests/POSTIntegrationTests.swift | 1 + Tests/NetworkingTests/RetryInterceptorTests.swift | 1 + 7 files changed, 7 insertions(+), 23 deletions(-) diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index 7c03f42..1f4f39f 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -23,9 +23,8 @@ final class CacheStore: @unchecked Sendable { } var ttl: Duration { expiry.ttl } - func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) } - // MARK: - Layout + func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) } /// The on-disk URL for a resolved resource key, laid out under `folderName//`. Creates the /// shard directory if needed. @@ -81,8 +80,6 @@ final class CacheStore: @unchecked Sendable { return "\(prefix)-\(hash)" } - // MARK: - Read - /// A pure read: serve from the warm tier, falling back to a non-expired disk entry (which re-warms both /// tiers). Never mutates a tier except to drop an entry it finds expired. `.memory`/`.none` never touch /// disk, so a read can't destroy a durable copy written at `.memoryAndFile`. @@ -131,8 +128,6 @@ final class CacheStore: @unchecked Sendable { } } - // MARK: - Write - func storeData( _ data: Data?, forResource resource: String, @@ -188,8 +183,6 @@ final class CacheStore: @unchecked Sendable { return image } - // MARK: - Clear & sweep - // Serializes whole-folder mutations of the shared cache directory so the background sweep (which // creates the folder + writes its cursor) can't race a clear. static let mutationLock = NSLock() diff --git a/Sources/Networking/DownloadResponse.swift b/Sources/Networking/DownloadResponse.swift index d94ec42..90abc0a 100644 --- a/Sources/Networking/DownloadResponse.swift +++ b/Sources/Networking/DownloadResponse.swift @@ -47,9 +47,9 @@ extension Data: DataDownloadable { } } -/// A downloaded image plus the response metadata, for callers that need the status/headers. // @unchecked: immutable struct; the only non-Sendable-provable field is the platform `Image`, // which is effectively read-only once downloaded. +/// A downloaded image plus the response metadata, for callers that need the status/headers. public struct ImageResponse: ImageDownloadable, @unchecked Sendable { public let statusCode: Int public let headers: [String: AnyCodable] diff --git a/Sources/Networking/Networking+HTTPRequests.swift b/Sources/Networking/Networking+HTTPRequests.swift index c6ca776..1917ff9 100644 --- a/Sources/Networking/Networking+HTTPRequests.swift +++ b/Sources/Networking/Networking+HTTPRequests.swift @@ -2,8 +2,6 @@ import Foundation extension Networking { - // MARK: GET - public func get( _ path: String, query: [URLQueryItem]? = nil, @@ -34,8 +32,6 @@ extension Networking { ) } - // MARK: POST - public func post(_ path: String) async -> Result { return await handle(.post, path: path) } @@ -129,8 +125,6 @@ extension Networking { return result.map { _ in () } } - // MARK: PUT - public func put(_ path: String) async -> Result { return await handle(.put, path: path) } @@ -222,8 +216,6 @@ extension Networking { return result.map { _ in () } } - // MARK: PATCH - public func patch(_ path: String) async -> Result { return await handle(.patch, path: path) } @@ -315,8 +307,6 @@ extension Networking { return result.map { _ in () } } - // MARK: DELETE - public func delete(_ path: String, query: [URLQueryItem]? = nil) async -> Result { return await handle( .delete, @@ -437,8 +427,6 @@ extension Networking { } } -// MARK: - Faking requests - extension Networking { public func fakeGET( _ path: String, @@ -611,8 +599,6 @@ extension Networking { } } -// MARK: - Downloads - extension Networking { nonisolated public func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { diff --git a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift index b8e0058..1a196a0 100644 --- a/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift +++ b/Tests/NetworkingTests/AuthRefreshSingleFlightIntegrationTests.swift @@ -11,6 +11,7 @@ final class AuthRefreshSingleFlightIntegrationTests: XCTestCase { private actor RefreshCounter { private(set) var count = 0 + func increment() { count += 1 } } diff --git a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift index 7ec54a4..f641b03 100644 --- a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift @@ -10,11 +10,13 @@ final class DownloadInterceptorIntegrationTests: XCTestCase { private actor Counter { private(set) var count = 0 + func tick() { count += 1 } } private struct CountingPassthroughInterceptor: HTTPInterceptor { let counter: Counter + func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws -> HTTPExchange { diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index 3357ccd..9003495 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -229,5 +229,6 @@ private struct PostJSONEcho: Decodable { let double: Double let bool: Bool } + let json: Payload } diff --git a/Tests/NetworkingTests/RetryInterceptorTests.swift b/Tests/NetworkingTests/RetryInterceptorTests.swift index f5675fc..7c8698f 100644 --- a/Tests/NetworkingTests/RetryInterceptorTests.swift +++ b/Tests/NetworkingTests/RetryInterceptorTests.swift @@ -11,6 +11,7 @@ final class RetryInterceptorTests: XCTestCase { actor CallCounter { private(set) var count = 0 + func tick() -> Int { count += 1 return count From ad6d565705c1838d2795a1e5ac7e2c837ec6e76f Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:43:24 +0200 Subject: [PATCH 05/12] A force unwrap is either an invariant worth stating or a nil worth handling Seventeen force unwraps and three force casts, each taken on its own terms. Handled, because the value can genuinely be absent: a macOS image with no TIFF representation now returns nil, which the signature already allowed; a folder name that will not parse as a URL throws or returns nil rather than trapping; five `"...".data(using: .utf8)!` become `Data("...".utf8)`, which cannot fail. Stated, because the absence would be our fault rather than a caller's: a bundled image that is missing is a packaging fault, so Image.find says so and traps with the name it looked for. Two justified with a scoped disable: the URL reaching the HTTPURLResponse convenience initializer has already been parsed by composedURL, and the three generic casts are each guarded by the type test above them. In tests a nil is a failed test, so those take XCTUnwrap. 220 violations down to 206, suite 180/180 green. --- Sources/Networking/CacheStore.swift | 12 +++++++-- Sources/Networking/DownloadResponse.swift | 2 ++ Sources/Networking/FormDataPart.swift | 2 +- Sources/Networking/Helpers.swift | 10 +++----- Sources/Networking/Image.swift | 25 +++++++++++-------- Sources/Networking/Networking+New.swift | 6 ++++- Tests/NetworkingTests/CacheStoreTests.swift | 3 ++- .../NetworkingIntegrationTests.swift | 5 ++-- .../POSTIntegrationTests.swift | 6 ++--- .../RetryInterceptorTests.swift | 7 +----- 10 files changed, 45 insertions(+), 33 deletions(-) diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index 1f4f39f..05a8df7 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -43,7 +43,12 @@ final class CacheStore: @unchecked Sendable { userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"]) } - let folderURL = cachesURL.appendingPathComponent(URL(string: folderPath)!.absoluteString) + guard let folderComponent = URL(string: folderPath)?.absoluteString else { + throw NSError( + domain: folderName, code: 9999, + userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(folderPath)"]) + } + let folderURL = cachesURL.appendingPathComponent(folderComponent) if FileManager.default.exists(at: folderURL) == false { try FileManager.default.createDirectory( at: folderURL, @@ -204,7 +209,10 @@ final class CacheStore: @unchecked Sendable { guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } - return cachesURL.appendingPathComponent(URL(string: folderName)!.absoluteString) + guard let folderComponent = URL(string: folderName)?.absoluteString else { + return nil + } + return cachesURL.appendingPathComponent(folderComponent) } // Deletes expired files from **one** shard per call (rotated via a tiny cursor file), so each launch's diff --git a/Sources/Networking/DownloadResponse.swift b/Sources/Networking/DownloadResponse.swift index 90abc0a..d97b0d2 100644 --- a/Sources/Networking/DownloadResponse.swift +++ b/Sources/Networking/DownloadResponse.swift @@ -33,6 +33,8 @@ extension Image: ImageDownloadable { statusCode: Int, headers: [String: AnyCodable] ) -> Self { + // Self is the concrete type the caller asked for, and Image is the only case that reaches here. + // oida:disable:next force_cast return image as! Self } } diff --git a/Sources/Networking/FormDataPart.swift b/Sources/Networking/FormDataPart.swift index 01cd4b4..73e2ec0 100644 --- a/Sources/Networking/FormDataPart.swift +++ b/Sources/Networking/FormDataPart.swift @@ -31,7 +31,7 @@ public struct FormDataPart { body += "Content-Type: \(type.contentType)\r\n\r\n" var bodyData = Data() - bodyData.append(body.data(using: .utf8)!) + bodyData.append(Data(body.utf8)) bodyData.append(data) bodyData.append(Data("\r\n".utf8)) diff --git a/Sources/Networking/Helpers.swift b/Sources/Networking/Helpers.swift index f2aa71a..6fdbedc 100644 --- a/Sources/Networking/Helpers.swift +++ b/Sources/Networking/Helpers.swift @@ -113,12 +113,10 @@ extension HTTPURLResponse { headerFields: [String: String]? = nil, statusCode: Int ) { - self.init( - url: url, - statusCode: statusCode, - httpVersion: nil, - headerFields: headerFields - )! + // composedURL has already parsed this URL and thrown on anything that would not, so the only + // input Foundation refuses cannot reach here. + // oida:disable:next force_unwrapping + self.init(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)! } } diff --git a/Sources/Networking/Image.swift b/Sources/Networking/Image.swift index 86fb2c1..18dad81 100644 --- a/Sources/Networking/Image.swift +++ b/Sources/Networking/Image.swift @@ -9,24 +9,27 @@ extension Image { static func find(named name: String, inBundle bundle: Bundle) -> Image { #if os(macOS) - return bundle.image(forResource: name)! + let found = bundle.image(forResource: name) #elseif os(watchOS) - return UIImage(named: name)! + let found = UIImage(named: name) #else - return UIImage( - named: name, - in: bundle, - compatibleWith: nil - )! + let found = UIImage(named: name, in: bundle, compatibleWith: nil) #endif + guard let found else { + // The asset ships inside the bundle, so its absence is a packaging fault, not a caller's. + fatalError("\(bundle.bundleURL.lastPathComponent) carries no image named \(name)") + } + return found } #if os(macOS) func data(_ type: NSBitmapImageRep.FileType) -> Data? { - let imageData = tiffRepresentation! - let bitmapImageRep = NSBitmapImageRep(data: imageData)! - let data = bitmapImageRep.representation(using: type, properties: [NSBitmapImageRep.PropertyKey: Any]()) - return data + guard let imageData = tiffRepresentation, + let bitmapImageRep = NSBitmapImageRep(data: imageData) + else { + return nil + } + return bitmapImageRep.representation(using: type, properties: [NSBitmapImageRep.PropertyKey: Any]()) } #endif diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index a0c1b7b..6134d35 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -407,7 +407,7 @@ extension Networking { body += "--\(boundary)\r\n" body += "Content-Disposition: form-data; name=\"\(key)\"" body += "\r\n\r\n\(value)\r\n" - bodyData.append(body.data(using: .utf8)!) + bodyData.append(Data(body.utf8)) } for var part in parts { part.boundary = boundary @@ -457,6 +457,8 @@ extension Networking { -> Result { if T.self == Data.self { + // Guarded by the test on the line above: T is Data here. + // oida:disable:next force_cast return .success(responseData as! T) } else if T.self == JSONResponse.self { let headers = Dictionary( @@ -472,6 +474,8 @@ extension Networking { headers: headers, body: body ) + // Guarded by the `T.self == JSONResponse.self` test this branch is under. + // oida:disable:next force_cast return .success(networkingJSON as! T) } catch let error as DecodingError { return .failure(.decoding(error, ResponseMetadata(response: httpResponse, body: responseData))) diff --git a/Tests/NetworkingTests/CacheStoreTests.swift b/Tests/NetworkingTests/CacheStoreTests.swift index 3f36f25..07d23c1 100644 --- a/Tests/NetworkingTests/CacheStoreTests.swift +++ b/Tests/NetworkingTests/CacheStoreTests.swift @@ -97,7 +97,8 @@ final class CacheStoreTests: XCTestCase { level: .memoryAndFile, asImage: false )) - let mtime = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate! + let mtime = try XCTUnwrap( + url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) XCTAssertLessThan( Date().timeIntervalSince(mtime), 5, diff --git a/Tests/NetworkingTests/NetworkingIntegrationTests.swift b/Tests/NetworkingTests/NetworkingIntegrationTests.swift index 8c12e61..bc518a3 100644 --- a/Tests/NetworkingTests/NetworkingIntegrationTests.swift +++ b/Tests/NetworkingTests/NetworkingIntegrationTests.swift @@ -47,8 +47,9 @@ final class NetworkingIntegrationTests: XCTestCase { } func testClearCacheOnlyRemovesTheNetworkingFolder() async throws { - let cachesRoot = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! - let networkingFolder = cachesRoot.appendingPathComponent(URL(string: Networking.domain)!.absoluteString) + let cachesRoot = try XCTUnwrap(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) + let domainComponent = try XCTUnwrap(URL(string: Networking.domain)?.absoluteString) + let networkingFolder = cachesRoot.appendingPathComponent(domainComponent) let networking = Networking(baseURL: baseURL) let _: Result = await networking.downloadImage("/image/png") diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index 9003495..d1b0852 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -98,12 +98,12 @@ final class POSTIntegrationTests: XCTestCase { let item1 = "FIRSTDATA" let item2 = "SECONDDATA" let part1 = FormDataPart( - data: item1.data(using: .utf8)!, + data: Data(item1.utf8), parameterName: item1, filename: "\(item1).png" ) let part2 = FormDataPart( - data: item2.data(using: .utf8)!, + data: Data(item2.utf8), parameterName: item2, filename: "\(item2).png" ) @@ -141,7 +141,7 @@ final class POSTIntegrationTests: XCTestCase { let item1 = "FIRSTDATA" let part1 = FormDataPart( - data: item1.data(using: .utf8)!, + data: Data(item1.utf8), parameterName: item1, filename: "\(item1).png" ) diff --git a/Tests/NetworkingTests/RetryInterceptorTests.swift b/Tests/NetworkingTests/RetryInterceptorTests.swift index 7c8698f..c398aac 100644 --- a/Tests/NetworkingTests/RetryInterceptorTests.swift +++ b/Tests/NetworkingTests/RetryInterceptorTests.swift @@ -34,12 +34,7 @@ final class RetryInterceptorTests: XCTestCase { switch outcomeForAttempt(attempt) { case .status(let code, let headers): let url = request.url ?? URL(string: "https://example.com")! - let response = HTTPURLResponse( - url: url, - statusCode: code, - httpVersion: nil, - headerFields: headers - )! + let response = HTTPURLResponse(url: url, headerFields: headers, statusCode: code) return HTTPExchange(data: Data(), response: response) case .throwTransport(let code): throw URLError(code) From 6bffcc1263e89b5339cbe0a4adbe85c86d185482 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Tue, 15 Sep 2026 09:46:43 +0200 Subject: [PATCH 06/12] A templated fake path answers what it captured, and matching reads as three steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FakeRequest.find carried an exact lookup and the templated-path walk in one body, at complexity 14. It is now three: find tries the exact path then hands over, templated walks the candidates, and captures answers what one placeholder path took from a lookup — or nil where the two describe different paths. The body substitution both halves needed is String.replacing(_:). A `for` whose body was one `if` takes a `where` clause. no_single_use_void_functions is left out of the rule set, with the reason in .oida.yml: nine of the ten it finds here are a library's own seams and one is documented public API, whose callers are in other people's projects (kolonialno/oida-swift#21). Inlining the tenth does not even compile — it is the hop that makes an actor-isolated write legal from a nonisolated closure. 206 violations down to 195, suite 180/180 green. --- .oida.yml | 3 - Sources/Networking/FakeRequest.swift | 100 +++++++++++++++------------ 2 files changed, 55 insertions(+), 48 deletions(-) diff --git a/.oida.yml b/.oida.yml index ae92ee6..0cf4c19 100644 --- a/.oida.yml +++ b/.oida.yml @@ -14,7 +14,6 @@ opt_in_rules: - multiline_string_opens_on_its_own_line # What gets written too much of: a helper with one caller that returns nothing, a comment whose every # word is already in the code below it, a banner naming what the declarations already name. - - no_single_use_void_functions - comment_adds_no_word - no_mark_comments # Auto-fixable and mechanical — `bin/lint --fix` applies these, no judgement needed. @@ -83,8 +82,6 @@ comment_adds_no_word: severity: error no_mark_comments: severity: error -no_single_use_void_functions: - severity: error cyclomatic_complexity: ignores_case_statements: true diff --git a/Sources/Networking/FakeRequest.swift b/Sources/Networking/FakeRequest.swift index 97f87e7..793e2cc 100644 --- a/Sources/Networking/FakeRequest.swift +++ b/Sources/Networking/FakeRequest.swift @@ -25,59 +25,69 @@ struct FakeRequest { if let result = requests[path] { return result - } else { - var evaluatedPath = path - evaluatedPath.removeFirstLetterIfDash() - evaluatedPath.removeLastLetterIfDash() - let lookupPathParts = evaluatedPath.components(separatedBy: "/") - - for (originalFakedPath, fakeRequest) in requests { - guard originalFakedPath.contains("{") else { continue } - - var fakedPath = originalFakedPath - fakedPath.removeFirstLetterIfDash() - fakedPath.removeLastLetterIfDash() - let fakePathParts = fakedPath.components(separatedBy: "/") - - guard lookupPathParts.count == fakePathParts.count else { continue } - guard lookupPathParts.first == fakePathParts.first else { continue } - guard lookupPathParts.count != 1 && fakePathParts.count != 1 else { continue } - - var replacedValues = [String: String]() - for (index, fakePathPart) in fakePathParts.enumerated() { - if fakePathPart.contains("{") { - replacedValues[fakePathPart] = lookupPathParts[index] - } - } - - var replacedPath = originalFakedPath - for (key, value) in replacedValues { - replacedPath = replacedPath.replacingOccurrences(of: key, with: value) - } - guard replacedPath == path else { continue } - // Substitute the captured path values into the JSON body string (e.g. "{userID}" -> "10"). - guard case .data(let data) = fakeRequest.payload, - var responseString = String(data: data, encoding: .utf8) - else { continue } - - for (key, value) in replacedValues { - responseString = responseString.replacingOccurrences(of: key, with: value) - } - - guard let stringData = responseString.data(using: .utf8) else { continue } - return FakeRequest( - payload: .data(stringData), responseType: fakeRequest.responseType, - headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay - ) - } + } + return templated(forPath: path, in: requests) + } + + // A faked path may carry placeholders — "/users/{userID}" answers "/users/10" — so a lookup that + // missed exactly walks the templated ones, and the values it captured are substituted into the body + // as well as the path. + private static func templated(forPath path: String, in requests: [String: FakeRequest]) -> FakeRequest? { + var evaluatedPath = path + evaluatedPath.removeFirstLetterIfDash() + evaluatedPath.removeLastLetterIfDash() + let lookupPathParts = evaluatedPath.components(separatedBy: "/") + + for (originalFakedPath, fakeRequest) in requests { + guard let replacedValues = captures(from: originalFakedPath, matching: lookupPathParts), + originalFakedPath.replacing(replacedValues) == path + else { continue } + + guard case .data(let data) = fakeRequest.payload, + let responseString = String(data: data, encoding: .utf8) + else { continue } + + let substituted = responseString.replacing(replacedValues) + guard let stringData = substituted.data(using: .utf8) else { continue } + return FakeRequest( + payload: .data(stringData), responseType: fakeRequest.responseType, + headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay + ) } return nil } + // The values a templated path captures from a lookup — "/users/{userID}" against "/users/10" gives + // ["{userID}": "10"] — or nil where the two do not describe the same path at all. + private static func captures(from fakedPath: String, matching lookupPathParts: [String]) -> [String: String]? { + guard fakedPath.contains("{") else { return nil } + + var trimmed = fakedPath + trimmed.removeFirstLetterIfDash() + trimmed.removeLastLetterIfDash() + let fakePathParts = trimmed.components(separatedBy: "/") + + guard lookupPathParts.count == fakePathParts.count, + lookupPathParts.first == fakePathParts.first, + lookupPathParts.count != 1 + else { return nil } + + var captured = [String: String]() + for (index, fakePathPart) in fakePathParts.enumerated() where fakePathPart.contains("{") { + captured[fakePathPart] = lookupPathParts[index] + } + return captured + } + } extension String { + // Placeholder -> captured value, applied to whichever string carries the placeholders. + func replacing(_ values: [String: String]) -> String { + values.reduce(self) { $0.replacingOccurrences(of: $1.key, with: $1.value) } + } + mutating func removeFirstLetterIfDash() { let initialCharacter = String(self[.. Date: Wed, 16 Sep 2026 07:02:40 +0200 Subject: [PATCH 07/12] oida 0.15.0, and linting moves to Linux 0.15.0 answers all three issues this adoption raised. The 149 violations that had no fix in 0.14.0 are gone: multiline_call_arguments and multiline_conditions correct now that width is the formatter's alone, and opening_brace takes ignore_multiline_function_signatures, which is the shape a wrapped signature could never satisfy before (#19, #20). no_single_use_void_functions no longer flags public clearCache, whose callers are in other people's projects (#21), so the rule comes back into the set. The configuration loses what 0.15.0 deleted: requires_single_line, the eight count rules, nesting and cyclomatic_complexity. Naming a rule that no longer exists is naming nothing. Linting needs no Xcode, so its job moves to ubuntu-latest in swift:6.3.2-noble, the shape tienda-ios already runs. The toolchain image is what makes Linux work at all: oida's SourceKit rules and its swift-format handoff both resolve through it. The macOS runner stays for build, test and the warnings gate. 195 violations down to 48, suite 180/180 green. --- .github/workflows/ci.yml | 17 +- .oida-checksum-linux-x86_64 | 2 +- .oida-checksum-macos-arm64 | 2 +- .oida-version | 2 +- .oida.yml | 23 +- Sources/Networking/CacheStore.swift | 24 +- Sources/Networking/FakeRequest.swift | 10 +- Sources/Networking/HTTPInterceptor.swift | 20 +- Sources/Networking/Helpers.swift | 22 +- Sources/Networking/Image.swift | 10 +- .../Networking/Networking+HTTPRequests.swift | 230 ++++++++++++++---- Sources/Networking/Networking+New.swift | 78 ++++-- Sources/Networking/Networking+Private.swift | 138 ++++++++--- Tests/NetworkingTests/CacheStoreTests.swift | 3 +- .../DownloadInterceptorIntegrationTests.swift | 5 +- Tests/NetworkingTests/FakeRequestTests.swift | 50 +++- .../NetworkingTests/GETIntegrationTests.swift | 21 +- .../POSTIntegrationTests.swift | 10 +- .../RetryInterceptorTests.swift | 23 +- 19 files changed, 518 insertions(+), 172 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7aabe2..4f76370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,20 @@ env: jobs: lint: - name: Lint - runs-on: xcode-27 + name: oida + # Linting needs no Xcode, so it runs on Linux where a runner minute is cheapest. bin/lint downloads + # oida, pinned by .oida-version and verified against .oida-checksum-, so CI and a laptop run + # the same bytes. The Swift toolchain container is what makes Linux work at all: oida's SourceKit-based + # rules and its swift-format handoff both resolve through the toolchain that ships in this image, the + # same way they resolve through an installed Xcode on a Mac. There is no baseline: any violation fails. + runs-on: ubuntu-latest + container: swift:6.3.2-noble steps: - uses: actions/checkout@v5 - # oida lints and then hands the files to swift-format, so one job covers both. There is no - # baseline: any violation fails. - - name: bin/lint --check + # curl isn't in the toolchain image; bin/lint needs it to fetch the pinned oida release. + - name: Install curl + run: apt-get update && apt-get install -y curl + - name: Lint run: ./bin/lint --check build-and-test: diff --git a/.oida-checksum-linux-x86_64 b/.oida-checksum-linux-x86_64 index 51b0659..b5cbfd5 100644 --- a/.oida-checksum-linux-x86_64 +++ b/.oida-checksum-linux-x86_64 @@ -1 +1 @@ -b75a5e3843c3e4c47ae635691dbead66422c5727debcd5386d74952227930754 +0bfe78da9491f6457bf81bd265230131a2b0a0005076b71414cfee265cf04d5c diff --git a/.oida-checksum-macos-arm64 b/.oida-checksum-macos-arm64 index 6375f19..76194ee 100644 --- a/.oida-checksum-macos-arm64 +++ b/.oida-checksum-macos-arm64 @@ -1 +1 @@ -cb984504dfda6efdd67771d08f13c041f52b26a9f148eeeae497b013910b3514 +29dc7d4234d4a9863651af651bcfcb8d7fa1efb3602fc29f73c373f7b08d9a61 diff --git a/.oida-version b/.oida-version index a803cc2..a551051 100644 --- a/.oida-version +++ b/.oida-version @@ -1 +1 @@ -0.14.0 +0.15.0 diff --git a/.oida.yml b/.oida.yml index 0cf4c19..b7c26fb 100644 --- a/.oida.yml +++ b/.oida.yml @@ -16,6 +16,7 @@ opt_in_rules: # word is already in the code below it, a banner naming what the declarations already name. - comment_adds_no_word - no_mark_comments + - no_single_use_void_functions # Auto-fixable and mechanical — `bin/lint --fix` applies these, no judgement needed. - modifier_order - self_binding @@ -58,13 +59,6 @@ disabled_rules: - sorted_imports - todo # work is tracked in PRs and LEARNINGS, not in-code markers - unused_closure_parameter - # Length is not a defect. A long function is refactored for a reason, never for a count. - - file_length - - type_body_length - - line_length - - function_body_length - - large_tuple - - function_parameter_count excluded: # Never lint vendored or generated code, wherever the build puts it. Matched by location, not path, @@ -83,13 +77,11 @@ comment_adds_no_word: no_mark_comments: severity: error -cyclomatic_complexity: - ignores_case_statements: true - opening_brace: - # A condition spanning several lines gets its brace on its own line, so the body is not mistaken - # for another condition. That is what the formatter emits. + # A condition or a signature that spans several lines gets its brace on its own line — that is what + # the formatter emits, and asking for anything else is asking for a shape it will undo. ignore_multiline_statement_conditions: true + ignore_multiline_function_signatures: true identifier_name: min_length: 1 @@ -98,10 +90,6 @@ identifier_name: type_name: max_length: 45 -nesting: - type_level: - warning: 2 - file_header: severity: error forbidden_pattern: 'Copyright © \d|Created by .+ on ' @@ -111,15 +99,12 @@ force_unwrapping: multiline_call_arguments: max_number_of_single_line_parameters: 2 - requires_single_line: true multiline_parameters: max_number_of_single_line_parameters: 2 - requires_single_line: true multiline_conditions: max_number_of_single_line_parameters: 2 - requires_single_line: true grouped_imports: # This package ships one product and takes no dependencies, so "ours" is the module itself and its diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index 05a8df7..33e8ca8 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -39,14 +39,18 @@ final class CacheStore: @unchecked Sendable { let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw NSError( - domain: folderName, code: 9999, - userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"]) + domain: folderName, + code: 9999, + userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"] + ) } guard let folderComponent = URL(string: folderPath)?.absoluteString else { throw NSError( - domain: folderName, code: 9999, - userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(folderPath)"]) + domain: folderName, + code: 9999, + userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(folderPath)"] + ) } let folderURL = cachesURL.appendingPathComponent(folderComponent) if FileManager.default.exists(at: folderURL) == false { @@ -233,8 +237,10 @@ final class CacheStore: @unchecked Sendable { let shardURL = domainURL.appendingPathComponent(String(cursor % Self.shardCount, radix: 16)) if let files = try? FileManager.default.contentsOfDirectory( - at: shardURL, includingPropertiesForKeys: [.contentModificationDateKey], options: []) - { + at: shardURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [] + ) { for file in files { guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]) @@ -249,8 +255,10 @@ final class CacheStore: @unchecked Sendable { // Pre-sharding versions wrote files directly under the domain root; clear those strays (the cursor // file and the shard subdirectories stay). if let rootEntries = try? FileManager.default.contentsOfDirectory( - at: domainURL, includingPropertiesForKeys: [.isRegularFileKey], options: []) - { + at: domainURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ) { for entry in rootEntries where entry.lastPathComponent != Self.sweepCursorFileName { if (try? entry.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true { try? FileManager.default.removeItem(at: entry) diff --git a/Sources/Networking/FakeRequest.swift b/Sources/Networking/FakeRequest.swift index 793e2cc..0b33d39 100644 --- a/Sources/Networking/FakeRequest.swift +++ b/Sources/Networking/FakeRequest.swift @@ -43,15 +43,17 @@ struct FakeRequest { originalFakedPath.replacing(replacedValues) == path else { continue } - guard case .data(let data) = fakeRequest.payload, - let responseString = String(data: data, encoding: .utf8) + guard case .data(let data) = fakeRequest.payload, let responseString = String(data: data, encoding: .utf8) else { continue } let substituted = responseString.replacing(replacedValues) guard let stringData = substituted.data(using: .utf8) else { continue } return FakeRequest( - payload: .data(stringData), responseType: fakeRequest.responseType, - headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay + payload: .data(stringData), + responseType: fakeRequest.responseType, + headerFields: fakeRequest.headerFields, + statusCode: fakeRequest.statusCode, + delay: fakeRequest.delay ) } diff --git a/Sources/Networking/HTTPInterceptor.swift b/Sources/Networking/HTTPInterceptor.swift index 7a751be..e8a1039 100644 --- a/Sources/Networking/HTTPInterceptor.swift +++ b/Sources/Networking/HTTPInterceptor.swift @@ -15,7 +15,10 @@ public struct HTTPExchange: Sendable { /// A composable hook wrapping every verb request. `next` runs the rest of the chain (innermost is the real /// network call); calling it again replays the request — the basis for retry and auth-refresh. public protocol HTTPInterceptor: Sendable { - func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange } @@ -37,7 +40,10 @@ public struct AuthRefreshInterceptor: HTTPInterceptor { self.coordinator = RefreshCoordinator(refresh) } - public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + public func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { let exchange = try await next(request) @@ -77,7 +83,10 @@ public struct RetryInterceptor: HTTPInterceptor { self.retryableMethods = Set(retryableMethods.map { $0.uppercased() }) } - public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + public func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { let methodAllowsRetry = retryableMethods.contains((request.httpMethod ?? "GET").uppercased()) @@ -140,7 +149,10 @@ public struct ResponseValidatorInterceptor: HTTPInterceptor { self.validate = validate } - public func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + public func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { let exchange = try await next(request) diff --git a/Sources/Networking/Helpers.swift b/Sources/Networking/Helpers.swift index 6fdbedc..8f8f841 100644 --- a/Sources/Networking/Helpers.swift +++ b/Sources/Networking/Helpers.swift @@ -31,8 +31,10 @@ extension Dictionary where Key: ExpressibleByStringLiteral { return current + ["\(keyValuePair.key)=\(encodedValue)"] } else { throw NSError( - domain: Networking.domain, code: 0, - userInfo: [NSLocalizedDescriptionKey: "Couldn't encode \(keyValuePair.value)"]) + domain: Networking.domain, + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Couldn't encode \(keyValuePair.value)"] + ) } } @@ -115,15 +117,23 @@ extension HTTPURLResponse { ) { // composedURL has already parsed this URL and thrown on anything that would not, so the only // input Foundation refuses cannot reach here. - // oida:disable:next force_unwrapping - self.init(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields)! + // oida:disable force_unwrapping + self.init( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields + )! + // oida:enable force_unwrapping } } extension NSError { convenience init(statusCode: Int) { self.init( - domain: Networking.domain, code: statusCode, - userInfo: [NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)]) + domain: Networking.domain, + code: statusCode, + userInfo: [NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)] + ) } } diff --git a/Sources/Networking/Image.swift b/Sources/Networking/Image.swift index 18dad81..d260d7b 100644 --- a/Sources/Networking/Image.swift +++ b/Sources/Networking/Image.swift @@ -13,7 +13,11 @@ extension Image { #elseif os(watchOS) let found = UIImage(named: name) #else - let found = UIImage(named: name, in: bundle, compatibleWith: nil) + let found = UIImage( + named: name, + in: bundle, + compatibleWith: nil + ) #endif guard let found else { // The asset ships inside the bundle, so its absence is a packaging fault, not a caller's. @@ -24,9 +28,7 @@ extension Image { #if os(macOS) func data(_ type: NSBitmapImageRep.FileType) -> Data? { - guard let imageData = tiffRepresentation, - let bitmapImageRep = NSBitmapImageRep(data: imageData) - else { + guard let imageData = tiffRepresentation, let bitmapImageRep = NSBitmapImageRep(data: imageData) else { return nil } return bitmapImageRep.representation(using: type, properties: [NSBitmapImageRep.PropertyKey: Any]()) diff --git a/Sources/Networking/Networking+HTTPRequests.swift b/Sources/Networking/Networking+HTTPRequests.swift index 1917ff9..27afd22 100644 --- a/Sources/Networking/Networking+HTTPRequests.swift +++ b/Sources/Networking/Networking+HTTPRequests.swift @@ -99,7 +99,10 @@ extension Networking { Void, NetworkingError > { let result: Result = await handle( - .post, path: path, body: .multipart(fields: fields, parts: parts)) + .post, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } @@ -121,7 +124,10 @@ extension Networking { contentType: String ) async -> Result { let result: Result = await handle( - .post, path: path, body: .raw(data, contentType: contentType)) + .post, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } @@ -190,7 +196,10 @@ extension Networking { Void, NetworkingError > { let result: Result = await handle( - .put, path: path, body: .multipart(fields: fields, parts: parts)) + .put, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } @@ -212,7 +221,10 @@ extension Networking { contentType: String ) async -> Result { let result: Result = await handle( - .put, path: path, body: .raw(data, contentType: contentType)) + .put, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } @@ -281,7 +293,10 @@ extension Networking { Void, NetworkingError > { let result: Result = await handle( - .patch, path: path, body: .multipart(fields: fields, parts: parts)) + .patch, + path: path, + body: .multipart(fields: fields, parts: parts) + ) return result.map { _ in () } } @@ -303,7 +318,10 @@ extension Networking { contentType: String ) async -> Result { let result: Result = await handle( - .patch, path: path, body: .raw(data, contentType: contentType)) + .patch, + path: path, + body: .raw(data, contentType: contentType) + ) return result.map { _ in () } } @@ -335,7 +353,11 @@ extension Networking { public func delete(_ path: String, query: Q) async -> Result { let result: Result = await queryEncodeAndHandle( - .delete, path: path, query: query, cachingLevel: .none) + .delete, + path: path, + query: query, + cachingLevel: .none + ) return result.map { _ in () } } } @@ -360,8 +382,10 @@ extension Networking { data = try Self.requestBodyEncoder.encode(body) } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.bodyEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.bodyEncodingFailed(message: error.localizedDescription)) + ) } return await handle( requestType, @@ -394,8 +418,10 @@ extension Networking { fields = try formFields(from: form) } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription)) + ) } return await handle( requestType, @@ -415,8 +441,10 @@ extension Networking { items = try formFields(from: query).map { URLQueryItem(name: $0.key, value: $0.value) } } catch { return emitPreflightFailure( - requestType, path: path, - error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription))) + requestType, + path: path, + error: .invalidRequest(.parameterEncodingFailed(message: error.localizedDescription)) + ) } return await handle( requestType, @@ -436,8 +464,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeGET( @@ -447,8 +481,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeGET( @@ -459,7 +499,13 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .get, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( @@ -470,8 +516,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .post, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( @@ -481,8 +533,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .post, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePOST( @@ -493,7 +551,13 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .post, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .post, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePUT( @@ -504,8 +568,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .put, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePUT( @@ -515,8 +585,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .put, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePUT( @@ -527,7 +603,13 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .put, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .put, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( @@ -538,8 +620,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( @@ -549,8 +637,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakePATCH( @@ -561,7 +655,13 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .patch, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .patch, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( @@ -572,8 +672,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, headerFields: headerFields, payload: fakePayload(response), - responseType: .json, statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + headerFields: headerFields, + payload: fakePayload(response), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( @@ -583,8 +689,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, headerFields: headerFields, payload: .none, responseType: .json, - statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + headerFields: headerFields, + payload: .none, + responseType: .json, + statusCode: statusCode, + delay: delay + ) } public func fakeDELETE( @@ -595,7 +707,13 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .delete, path: path, fileName: fileName, bundle: bundle, statusCode: statusCode, delay: delay) + requestType: .delete, + path: path, + fileName: fileName, + bundle: bundle, + statusCode: statusCode, + delay: delay + ) } } @@ -603,7 +721,11 @@ extension Networking { nonisolated public func imageFromCache(_ path: String, cacheName: String? = nil) throws -> Image? { let object = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .image) + for: path, + cacheName: cacheName, + cachingLevel: .memoryAndFile, + responseType: .image + ) return object as? Image } @@ -614,7 +736,12 @@ extension Networking { cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleImageRequest( - .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .image) + .get, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: .image + ) } /// Completes the in-flight download for `path` with `URLError.cancelled`. @@ -635,8 +762,14 @@ extension Networking { delay: Double = 0 ) { registerFake( - requestType: .get, path: path, headerFields: headerFields, payload: .image(image), responseType: .image, - statusCode: statusCode, delay: delay) + requestType: .get, + path: path, + headerFields: headerFields, + payload: .image(image), + responseType: .image, + statusCode: statusCode, + delay: delay + ) } public func downloadData( @@ -645,12 +778,21 @@ extension Networking { cachingLevel: CachingLevel = .memoryAndFile ) async -> Result { return await handleDataRequest( - .get, path: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: .data) + .get, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: .data + ) } nonisolated public func dataFromCache(_ path: String, cacheName: String? = nil) throws -> Data? { let object = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: .memoryAndFile, responseType: .data) + for: path, + cacheName: cacheName, + cachingLevel: .memoryAndFile, + responseType: .data + ) return object as? Data } diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index 6134d35..5f467ce 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -46,7 +46,11 @@ extension Networking { in: fakeRequests ) { let fakeContext = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) context = fakeContext emit(.started(fakeContext)) let (fakeResult, fakeStatus, fakeBytes): (Result, Int, Int) = @@ -66,7 +70,11 @@ extension Networking { query: query ) let requestContext = RequestContext( - id: requestID, requestType: requestType, url: request.url, headers: request.allHTTPHeaderFields) + id: requestID, + requestType: requestType, + url: request.url, + headers: request.allHTTPHeaderFields + ) context = requestContext emit(.started(requestContext)) @@ -75,11 +83,19 @@ extension Networking { let cacheKey = cacheKey(for: request, fallbackPath: path) if cachingLevel != .none, let cachedData = try objectFromCache( - for: path, cacheName: cacheKey, cachingLevel: cachingLevel, responseType: .json) as? Data, + for: path, + cacheName: cacheKey, + cachingLevel: cachingLevel, + responseType: .json + ) as? Data, let cached = try? JSONDecoder().decode(CachedResponse.self, from: cachedData), let url = request.url, let cachedResponse = HTTPURLResponse( - url: url, statusCode: cached.statusCode, httpVersion: nil, headerFields: cached.headers) + url: url, + statusCode: cached.statusCode, + httpVersion: nil, + headerFields: cached.headers + ) { // Run the cache hit back out through the interceptor chain so validators apply to it too. let exchange = try await perform( @@ -98,7 +114,10 @@ extension Networking { let responseData = exchange.data let response: URLResponse = exchange.response let networkResult: Result = handleResponse( - responseData: responseData, response: response, path: path) + responseData: responseData, + response: response, + path: path + ) if cachingLevel != .none, case .success = networkResult, let httpResponse = response as? HTTPURLResponse @@ -108,10 +127,17 @@ extension Networking { (key as? String).map { ($0, "\(value)") } }) let envelope = CachedResponse( - statusCode: httpResponse.statusCode, headers: headers, body: responseData) + statusCode: httpResponse.statusCode, + headers: headers, + body: responseData + ) if let encoded = try? JSONEncoder().encode(envelope) { try? cacheOrPurgeData( - data: encoded, path: path, cacheName: cacheKey, cachingLevel: cachingLevel) + data: encoded, + path: path, + cacheName: cacheKey, + cachingLevel: cachingLevel + ) } } result = networkResult @@ -128,7 +154,11 @@ extension Networking { // .started/.completed so observers see every request. if context == nil { let errorContext = RequestContext( - id: requestID, requestType: requestType, url: nil, headers: headerFields) + id: requestID, + requestType: requestType, + url: nil, + headers: headerFields + ) context = errorContext emit(.started(errorContext)) } @@ -138,8 +168,15 @@ extension Networking { let duration = clock.now - startInstant guard let context else { return result } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: metrics, - duration: duration, requestBody: body, responseMetadata: responseMetadata) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: metrics, + duration: duration, + requestBody: body, + responseMetadata: responseMetadata + ) } // Runs the interceptor chain. On a cache hit, `cached` is the base result the chain folds around (no @@ -192,8 +229,14 @@ extension Networking { outcome = .failure(error) } logCompletion( - context: context, result: result, statusCode: statusCode, byteCount: byteCount, duration: duration, - requestBody: requestBody, responseMetadata: responseMetadata) + context: context, + result: result, + statusCode: statusCode, + byteCount: byteCount, + duration: duration, + requestBody: requestBody, + responseMetadata: responseMetadata + ) emit( .completed( context, @@ -279,7 +322,11 @@ extension Networking { > { let requestID = UUID() let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) return complete( .failure(error), @@ -336,7 +383,10 @@ extension Networking { responseData = Data() } let result: Result = handleResponse( - responseData: responseData, response: response, path: path) + responseData: responseData, + response: response, + path: path + ) return (result, response.statusCode, responseData.count) } diff --git a/Sources/Networking/Networking+Private.swift b/Sources/Networking/Networking+Private.swift index c414ffe..98875f3 100644 --- a/Sources/Networking/Networking+Private.swift +++ b/Sources/Networking/Networking+Private.swift @@ -8,8 +8,10 @@ extension Networking { responseType: ResponseType ) throws -> Any? { try cacheStore.object( - forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel, - asImage: responseType == .image) + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel, + asImage: responseType == .image + ) } func registerFake( @@ -28,8 +30,14 @@ extension Networking { fatalError("We couldn't find \(fileName), are you sure is there?") } registerFake( - requestType: requestType, path: path, headerFields: nil, payload: .data(data), responseType: .json, - statusCode: statusCode, delay: delay) + requestType: requestType, + path: path, + headerFields: nil, + payload: .data(data), + responseType: .json, + statusCode: statusCode, + delay: delay + ) } func registerFake( @@ -43,8 +51,12 @@ extension Networking { ) { var requests = fakeRequests[requestType] ?? [String: FakeRequest]() requests[path] = FakeRequest( - payload: payload, responseType: responseType, headerFields: headerFields, statusCode: statusCode, - delay: delay) + payload: payload, + responseType: responseType, + headerFields: headerFields, + statusCode: statusCode, + delay: delay + ) fakeRequests[requestType] = requests } @@ -59,7 +71,10 @@ extension Networking { var error: NSError? let url = try composedURL(with: path) let response = HTTPURLResponse( - url: url, headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode) + url: url, + headerFields: fakeRequest.headerFields, + statusCode: fakeRequest.statusCode + ) if StatusCodeType(statusCode: fakeRequest.statusCode) != .successful { error = NSError(statusCode: fakeRequest.statusCode) @@ -97,7 +112,11 @@ extension Networking { let clock = ContinuousClock() let startInstant = clock.now let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) let result: Result @@ -106,7 +125,11 @@ extension Networking { do { if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { let (fakeResponse, _) = try handleFakeRequest( - fakeRequest, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + fakeRequest, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) } @@ -117,14 +140,19 @@ extension Networking { byteCount = fakeData.count result = .success( T.makeDownloadResult( - data: fakeData, statusCode: fakeResponse.statusCode, - headers: headerFields(from: fakeResponse))) + data: fakeData, + statusCode: fakeResponse.statusCode, + headers: headerFields(from: fakeResponse) + )) } else { result = .failure(.invalidResponse) } } else if let cached = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: responseType) as? Data - { + for: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: responseType + ) as? Data { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( @@ -135,7 +163,10 @@ extension Networking { )) } else { let (downloaded, networkResponse) = try await requestData( - requestType, path: path, responseType: responseType) + requestType, + path: path, + responseType: responseType + ) try cacheOrPurgeData( data: downloaded, path: path, @@ -149,16 +180,23 @@ extension Networking { byteCount = downloaded.count result = .success( T.makeDownloadResult( - data: downloaded, statusCode: networkResponse.statusCode, - headers: headerFields(from: networkResponse))) + data: downloaded, + statusCode: networkResponse.statusCode, + headers: headerFields(from: networkResponse) + )) } } } catch { result = .failure(downloadError(error)) } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: nil, - duration: clock.now - startInstant) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: nil, + duration: clock.now - startInstant + ) } func handleImageRequest( @@ -172,7 +210,11 @@ extension Networking { let clock = ContinuousClock() let startInstant = clock.now let context = RequestContext( - id: requestID, requestType: requestType, url: try? composedURL(with: path), headers: headerFields) + id: requestID, + requestType: requestType, + url: try? composedURL(with: path), + headers: headerFields + ) emit(.started(context)) let result: Result @@ -181,7 +223,11 @@ extension Networking { do { if let fakeRequests = fakeRequests[requestType], let fakeRequest = fakeRequests[path] { let (fakeResponse, _) = try handleFakeRequest( - fakeRequest, path: path, cacheName: cacheName, cachingLevel: cachingLevel) + fakeRequest, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) if fakeRequest.delay > 0 { try? await Task.sleep(nanoseconds: UInt64(fakeRequest.delay * 1_000_000_000)) } @@ -191,14 +237,19 @@ extension Networking { } else if case .image(let fakeImage) = fakeRequest.payload { result = .success( T.makeDownloadResult( - image: fakeImage, statusCode: fakeResponse.statusCode, - headers: headerFields(from: fakeResponse))) + image: fakeImage, + statusCode: fakeResponse.statusCode, + headers: headerFields(from: fakeResponse) + )) } else { result = .failure(.invalidResponse) } } else if let cached = try objectFromCache( - for: path, cacheName: cacheName, cachingLevel: cachingLevel, responseType: responseType) as? Image - { + for: path, + cacheName: cacheName, + cachingLevel: cachingLevel, + responseType: responseType + ) as? Image { let response = HTTPURLResponse(url: try composedURL(with: path), statusCode: 200) statusCode = 200 result = .success( @@ -217,13 +268,18 @@ extension Networking { if StatusCodeType(statusCode: networkResponse.statusCode) != .successful { result = .failure(downloadError(forStatusCode: networkResponse.statusCode)) } else if let downloaded = try cacheOrPurgeImage( - data: data, path: path, cacheName: cacheName, cachingLevel: cachingLevel) - { + data: data, + path: path, + cacheName: cacheName, + cachingLevel: cachingLevel + ) { byteCount = data.count result = .success( T.makeDownloadResult( - image: downloaded, statusCode: networkResponse.statusCode, - headers: headerFields(from: networkResponse))) + image: downloaded, + statusCode: networkResponse.statusCode, + headers: headerFields(from: networkResponse) + )) } else { result = .failure(.invalidResponse) } @@ -232,8 +288,13 @@ extension Networking { result = .failure(downloadError(error)) } return complete( - result, context: context, statusCode: statusCode, byteCount: byteCount, metrics: nil, - duration: clock.now - startInstant) + result, + context: context, + statusCode: statusCode, + byteCount: byteCount, + metrics: nil, + duration: clock.now - startInstant + ) } private func headerFields(from response: HTTPURLResponse) -> [String: AnyCodable] { @@ -271,9 +332,15 @@ extension Networking { Data, HTTPURLResponse ) { let request = URLRequest( - url: try composedURL(with: path), requestType: requestType, contentType: nil, responseType: responseType, - authorizationHeaderValue: authorizationHeaderValue, token: token, - authorizationHeaderKey: authorizationHeaderKey, headerFields: headerFields) + url: try composedURL(with: path), + requestType: requestType, + contentType: nil, + responseType: responseType, + authorizationHeaderValue: authorizationHeaderValue, + token: token, + authorizationHeaderKey: authorizationHeaderKey, + headerFields: headerFields + ) // Route through the interceptor chain so retry/auth-refresh apply to downloads too. Caching is the // caller's job (handleDataRequest/handleImageRequest write under the real cacheName) — writing here @@ -331,6 +398,9 @@ extension Networking { -> Image? { try cacheStore.storeImage( - data: data, forResource: cacheResource(for: path, cacheName: cacheName), level: cachingLevel) + data: data, + forResource: cacheResource(for: path, cacheName: cacheName), + level: cachingLevel + ) } } diff --git a/Tests/NetworkingTests/CacheStoreTests.swift b/Tests/NetworkingTests/CacheStoreTests.swift index 07d23c1..576049b 100644 --- a/Tests/NetworkingTests/CacheStoreTests.swift +++ b/Tests/NetworkingTests/CacheStoreTests.swift @@ -97,8 +97,7 @@ final class CacheStoreTests: XCTestCase { level: .memoryAndFile, asImage: false )) - let mtime = try XCTUnwrap( - url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) + let mtime = try XCTUnwrap(url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) XCTAssertLessThan( Date().timeIntervalSince(mtime), 5, diff --git a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift index f641b03..347ce4e 100644 --- a/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift +++ b/Tests/NetworkingTests/DownloadInterceptorIntegrationTests.swift @@ -17,7 +17,10 @@ final class DownloadInterceptorIntegrationTests: XCTestCase { private struct CountingPassthroughInterceptor: HTTPInterceptor { let counter: Counter - func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { await counter.tick() diff --git a/Tests/NetworkingTests/FakeRequestTests.swift b/Tests/NetworkingTests/FakeRequestTests.swift index 840025c..83a5087 100644 --- a/Tests/NetworkingTests/FakeRequestTests.swift +++ b/Tests/NetworkingTests/FakeRequestTests.swift @@ -70,8 +70,12 @@ final class FakeRequestTests: XCTestCase { "name": "Name {userID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/users/{userID}": request]] let result = try FakeRequest.find( ofType: .get, @@ -91,8 +95,12 @@ final class FakeRequestTests: XCTestCase { "name": "Name {userID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "/users/ados": request, @@ -122,8 +130,12 @@ final class FakeRequestTests: XCTestCase { "company": "Company {companyID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [Networking.RequestType.get: ["/users/{userID}/companies/{companyID}": request]] let result = try FakeRequest.find( ofType: .get, @@ -146,8 +158,12 @@ final class FakeRequestTests: XCTestCase { "product": "Product {productID}" ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "/users/{userID}/companies/{companyID}/products/a": request, @@ -161,7 +177,10 @@ final class FakeRequestTests: XCTestCase { ] ] let result = try FakeRequest.find( - ofType: .get, forPath: "/users/10/companies/20/products/30", in: existingRequests) + ofType: .get, + forPath: "/users/10/companies/20/products/30", + in: existingRequests + ) let expected = [ "user": "User 10", @@ -187,8 +206,12 @@ final class FakeRequestTests: XCTestCase { ] let request = FakeRequest( - payload: .data(try JSONSerialization.data(withJSONObject: json)), responseType: .json, headerFields: nil, - statusCode: 200, delay: 0) + payload: .data(try JSONSerialization.data(withJSONObject: json)), + responseType: .json, + headerFields: nil, + statusCode: 200, + delay: 0 + ) let existingRequests = [ Networking.RequestType.get: [ "resource1/{resourceID1}/resource2/{resourceID2}/resource3/{resourceID3}/resource4/{resourceID4}/resource5/{resourceID5}/resource6/{resourceID6}/resource7/{resourceID7}/resource8/{resourceID8}/resource9/{resourceID9}/resource10/{resourceID10}": @@ -318,7 +341,10 @@ extension FakeRequestTests { XCTAssertGreaterThan(httpError.metadata.body.count, 512) XCTAssertEqual(try httpError.metadata.decode(ServerError.self).error, longMessage) XCTAssertEqual( - httpError.metadata.bodySnippet?.hasSuffix("… (truncated)"), true, "the snippet stays a log excerpt") + httpError.metadata.bodySnippet?.hasSuffix("… (truncated)"), + true, + "the snippet stays a log excerpt" + ) } func testFakeGETUsingFile() async throws { diff --git a/Tests/NetworkingTests/GETIntegrationTests.swift b/Tests/NetworkingTests/GETIntegrationTests.swift index 454768a..bbc518c 100644 --- a/Tests/NetworkingTests/GETIntegrationTests.swift +++ b/Tests/NetworkingTests/GETIntegrationTests.swift @@ -166,9 +166,15 @@ final class GETIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL, cache: cache) let first: Result = await networking.get( - "/get", query: [URLQueryItem(name: "value", value: "1")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "value", value: "1")], + cachingLevel: .memory + ) let second: Result = await networking.get( - "/get", query: [URLQueryItem(name: "value", value: "2")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "value", value: "2")], + cachingLevel: .memory + ) guard case .success(let firstResponse) = first, case .success(let secondResponse) = second else { return XCTFail("expected both requests to succeed") @@ -185,10 +191,15 @@ final class GETIntegrationTests: XCTestCase { let networking = Networking(baseURL: baseURL, cache: cache) let _: Result = await networking.get( - "/get", query: [URLQueryItem(name: "a", value: "1&b=2")], cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "a", value: "1&b=2")], + cachingLevel: .memory + ) let second: Result = await networking.get( - "/get", query: [URLQueryItem(name: "a", value: "1"), URLQueryItem(name: "b", value: "2")], - cachingLevel: .memory) + "/get", + query: [URLQueryItem(name: "a", value: "1"), URLQueryItem(name: "b", value: "2")], + cachingLevel: .memory + ) guard case .success(let secondResponse) = second else { return XCTFail("expected the second request to succeed") diff --git a/Tests/NetworkingTests/POSTIntegrationTests.swift b/Tests/NetworkingTests/POSTIntegrationTests.swift index d1b0852..8053f2a 100644 --- a/Tests/NetworkingTests/POSTIntegrationTests.swift +++ b/Tests/NetworkingTests/POSTIntegrationTests.swift @@ -114,7 +114,10 @@ final class POSTIntegrationTests: XCTestCase { "bool": "true" ] let result: Result = await networking.post( - "/post", parts: [part1, part2], fields: fields) + "/post", + parts: [part1, part2], + fields: fields + ) switch result { case .success(let response): XCTAssertEqual(response.body.string(for: "url"), "\(TestConfig.httpbinBaseURL)/post") @@ -172,7 +175,10 @@ final class POSTIntegrationTests: XCTestCase { ) let result: Result = await networking.post( - "/post", parts: [imagePart], fields: ["public_id": "pig"]) + "/post", + parts: [imagePart], + fields: ["public_id": "pig"] + ) switch result { case .success(let response): XCTAssertEqual(response.body.string(for: "url"), "\(TestConfig.httpbinBaseURL)/post") diff --git a/Tests/NetworkingTests/RetryInterceptorTests.swift b/Tests/NetworkingTests/RetryInterceptorTests.swift index c398aac..8fa405d 100644 --- a/Tests/NetworkingTests/RetryInterceptorTests.swift +++ b/Tests/NetworkingTests/RetryInterceptorTests.swift @@ -27,14 +27,21 @@ final class RetryInterceptorTests: XCTestCase { let counter: CallCounter let outcomeForAttempt: @Sendable (Int) -> ScriptedOutcome - func intercept(_ request: URLRequest, next: @Sendable (URLRequest) async throws -> HTTPExchange) async throws + func intercept( + _ request: URLRequest, + next: @Sendable (URLRequest) async throws -> HTTPExchange + ) async throws -> HTTPExchange { let attempt = await counter.tick() switch outcomeForAttempt(attempt) { case .status(let code, let headers): let url = request.url ?? URL(string: "https://example.com")! - let response = HTTPURLResponse(url: url, headerFields: headers, statusCode: code) + let response = HTTPURLResponse( + url: url, + headerFields: headers, + statusCode: code + ) return HTTPExchange(data: Data(), response: response) case .throwTransport(let code): throw URLError(code) @@ -115,7 +122,10 @@ final class RetryInterceptorTests: XCTestCase { if case .failure(let error) = result { XCTFail("expected success after honoring Retry-After, got \(error)") } XCTAssertGreaterThanOrEqual( - elapsed, .milliseconds(900), "Retry-After: 1 should pace the retry ~1s, not the 1ms base backoff") + elapsed, + .milliseconds(900), + "Retry-After: 1 should pace the retry ~1s, not the 1ms base backoff" + ) } // Retrying a non-idempotent request after a timeout/5xx can duplicate a side effect (a second charge, @@ -150,8 +160,11 @@ final class RetryInterceptorTests: XCTestCase { let counter = CallCounter() let networking = await networking( RetryInterceptor( - maxAttempts: 3, baseDelay: .milliseconds(1), maxDelay: .milliseconds(2), - retryableMethods: ["POST"]), + maxAttempts: 3, + baseDelay: .milliseconds(1), + maxDelay: .milliseconds(2), + retryableMethods: ["POST"] + ), ScriptedInterceptor(counter: counter) { _ in .status(503) } ) From 1dd2942f697ca0d92a12c137658706cb9111b4fb Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Wed, 16 Sep 2026 07:08:30 +0200 Subject: [PATCH 08/12] The documents say what is Thirty-nine places where the prose said what something was rather than what it is. A few read better for it: "If your file is not located in the main bundle you have to specify using the bundle parameters" is now "A file outside the main bundle takes the bundle parameter"; "makes no assumption about the error body's shape" is "treats the error body as opaque"; the `.ephemeral` paragraph, carried in from Apple's own documentation, says what it keeps in RAM instead of what it declines to write. Two uses of "simply" go, which was the whole of document_avoids_retired_words. Every relative link still resolves. --- AGENTS.md | 14 +++++++------- LEARNINGS.md | 6 +++--- README.md | 48 ++++++++++++++++++++++++------------------------ 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3b178a..ee0935b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i - `make httpbin` / `make httpbin-stop` — run go-httpbin on `:8080` to iterate with plain `swift test`. - `swift build` — library only; CI also fails on any first-party `warning:`. - Integration tests need go-httpbin (`TestConfig.httpbinBaseURL`, default `http://127.0.0.1:8080`). Without it - they fail fast (connection refused) — intended, not a flake. The offline / `fake*` suites need no server. + they fail fast (connection refused) — intended behaviour rather than a flake. The offline / `fake*` suites run on their own. - Formatting: `.swift-format` (120 cols, 4-space). Run `./scripts/setup.sh` once per clone to enable the `.githooks/pre-commit` hook (formats staged Swift files); CI's format check is the backstop. - CI: the `xcode-27` runner image / Xcode 27 / Swift 6.4, the same toolchain as local — CI is the gate. @@ -47,23 +47,23 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i Cross-file contracts an agent must hold; the *why* lives in each file's comments. - **Actor isolation.** `Networking` is an actor — isolated members need `await`, config is async setters - (no external property mutation), no subclassing. -- **Typed bodies, no `Any`.** The method picks the encoding (`body:` JSON / `form:` url-encoded / + (mutation goes through them alone), and the type is final. +- **Typed bodies, chosen by the method.** The method picks the encoding (`body:` JSON / `form:` url-encoded / `parts:`+`fields:` multipart / `data:contentType:` raw; `query:` for get/delete), via the `RequestBody` enum. `T` ∈ any `Decodable` · `Data` · `Void` · `JSONResponse`. -- **Errors say *where* it failed**, never a stringified catch-all; the core never parses the error body — +- **Errors say *where* it failed**, each a typed case; the core hands the error body on untouched — `ResponseMetadata.body` is the full bytes, the caller decodes its own envelope. - **Interceptors are an onion *below* the decode layer**, over a raw `HTTPExchange`; registered outermost-first, `next` replays. Downloads route through too; a GET cache hit flows back out through the chain (the cache is the innermost layer). -- **Cache reads are pure.** `.memory`/`.none` never touch disk, so a read can't destroy a durable +- **Cache reads are pure.** `.memory`/`.none` stay in memory, so a read leaves a durable `.memoryAndFile` copy — purging belongs to the write path and `clearCache`. Sliding TTL keyed on file mtime; sharded layout; one-shard-per-launch background sweep. The `NSCache` warm tier can be evicted by iOS at any time — disk is the durable fallback. - **Observability is one hook:** `events()` (multi-consumer `AsyncStream`). Built-in logging is separate, synchronous, gated by `logLevel`; redaction is log-path only, so `events()` carries the real headers. - **Region-isolation trap (Swift 6.2):** before building the `@Sendable` interceptor chain, read actor - state (`session`/`collector`) into locals so the closure captures none. + state (`session`/`collector`) into locals, so the closure captures values alone. ## Boundaries @@ -71,5 +71,5 @@ Cross-file contracts an agent must hold; the *why* lives in each file's comments - **Always** update `README.md` and `CHANGELOG.md` in the same PR as any public-API change — the README snippets are copy-paste docs and must compile under the actor (`await`). - **Ask first** before adding a third-party dependency — the core is intentionally dependency-free. -- **Never** put cache purging back on the read path: `.memory`/`.none` reads must stay pure, or an +- **Keep cache purging off the read path**: `.memory`/`.none` reads must stay pure, or an evicted warm tier turns a recoverable miss into permanent loss. diff --git a/LEARNINGS.md b/LEARNINGS.md index c3ad8f4..578b12e 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -21,9 +21,9 @@ LEARNINGS/2026-09-15-1520-a-warm-build-hides-a-warning.md The filename is the whole of the discipline. As many branches write this repository at once as there are worktrees open, and a file apiece is what lets them: each branch adds a path the others lack, so a merge takes every side whole and the file you wrote arrives byte for byte. Sharing one file costs a conflict per -pair of open branches, and `merge=union` in `.gitattributes` does not save you — it is read off a **working -tree**, which a server-side merge lacks, so it clears the conflicts on your machine and none of the ones -that block a merge button. +pair of open branches, and `merge=union` in `.gitattributes` applies only where a **working +tree** exists, which a server-side merge lacks — so it clears the conflicts on your machine while every +one that blocks a merge button stands. Inside: diff --git a/README.md b/README.md index 9916a4e..dbb8cf2 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - Friendly API - Singleton free -- No external dependencies +- Dependency-free - Minimal implementation - Fully unit tested - Simple request cancellation @@ -47,7 +47,7 @@ Initializing an instance of **Networking** means you have to select a [URLSessio - `.default`: The default session configuration uses a persistent disk-based cache (except when the result is downloaded to a file) and stores credentials in the user’s keychain. -- `.ephemeral`: An ephemeral session configuration object is similar to a default session configuration object except that the corresponding session object does not store caches, credential stores, or any session-related data to disk. Instead, session-related data is stored in RAM. The only time an ephemeral session writes data to disk is when you tell it to write the contents of a URL to a file. The main advantage to using ephemeral sessions is privacy. By not writing potentially sensitive data to disk, you make it less likely that the data will be intercepted and used later. For this reason, ephemeral sessions are ideal for private browsing modes in web browsers and other similar situations. +- `.ephemeral`: Keeps everything in RAM — caches, credential stores and session data all live there, and the one thing it writes to disk is a URL you explicitly ask it to save to a file. Privacy is the point: data that stays out of the file system stays out of reach later, which is what makes this the configuration behind private browsing modes. - `.background`: This configuration type is suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated. @@ -65,7 +65,7 @@ let networking = Networking(baseURL: "http://example.com", configuration: .ephem You can set the `headerFields` in any networking object. -This will append (if not found) or overwrite (if found) what NSURLSession sends on each request. +This sets what `URLSession` sends on each request: a header it already carries is overwritten, and a new one is added. ```swift await networking.setHeaderFields(["User-Agent": "your new user agent"]) @@ -130,7 +130,7 @@ await networking.setInterceptors([ ]) ``` -Concurrent requests that all hit `401` at once share a **single** refresh — they wait on the one in flight instead of each firing their own (no token-refresh stampede). For pure *notification* of a `401` (no replay), you don't need an interceptor — it's already a `.completed(_, .failure)` with `error.statusCode == 401` on [`events()`](#observing-requests). +Concurrent requests that all hit `401` at once share a **single** refresh — they wait on the one in flight, so a single token refresh serves them all. For pure *notification* of a `401`, where a replay is beside the point, [`events()`](#observing-requests) already carries it as a `.completed(_, .failure)` with `error.statusCode == 401`, which is enough on its own. `AuthRefreshInterceptor` is one implementation of the general `HTTPInterceptor` seam — an async `intercept(_:next:)` hook that wraps every verb request. Calling `next` runs the rest of the chain (the innermost being the real network call); calling it again replays. @@ -163,7 +163,7 @@ Interceptors apply to **downloads** (`downloadImage`/`downloadData`) as well as ### Validating the response -A `2xx` isn't always a *correct* response — the server might return the wrong content-type or a malformed envelope. `ResponseValidatorInterceptor` runs your check on each successful response and turns a failure into a typed `NetworkingError.validation(reason:_:)` (carrying the response metadata). Non-2xx responses pass through untouched, so a real `500` still surfaces as `.http`, not a validation failure. +A `2xx` can still be the *wrong* response — the server might return the wrong content-type or a malformed envelope. `ResponseValidatorInterceptor` runs your check on each successful response and turns a failure into a typed `NetworkingError.validation(reason:_:)` (carrying the response metadata). Everything outside 2xx passes through untouched, so a real `500` still surfaces as `.http`. ```swift await networking.setInterceptors([ @@ -243,7 +243,7 @@ case .failure(let error): `get` returns Swift's [Result](https://developer.apple.com/documentation/swift/result) with two cases: `.success(let response)` and `.failure(let error)`. The success carries the decoded value, the failure a `NetworkingError`. -`get` is generic over any `Decodable`, so you can decode straight into your own model — no manual JSON digging: +`get` is generic over any `Decodable`, so you decode straight into your own model and the JSON digging stays here: ```swift struct Recipe: Decodable { let title: String } @@ -304,9 +304,9 @@ error.responseMetadata // ResponseMetadata? — status, headers, and the full bo error.isRetryable // Bool — conservative: transient transport failures + HTTP 408/429/5xx ``` -`isRetryable` is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). A 4xx (other than 408/429), a decoding failure, or an invalid request is never reported as retryable. +`isRetryable` is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). It reports a 4xx (other than 408/429), a decoding failure and an invalid request as final. -**The core makes no assumption about the error body's shape.** It hands you the complete body in `ResponseMetadata` so you can decode your API's own error envelope into a typed value — `decode(_:)` is a small convenience over `body`: +**The core treats the error body as opaque.** It hands you the complete body in `ResponseMetadata` so you can decode your API's own error envelope into a typed value — `decode(_:)` is a small convenience over `body`: ```swift struct APIError: Decodable { let errors: [String: [String]] } // e.g. a Rails/ActiveModel envelope @@ -320,7 +320,7 @@ if case .failure(.http(let httpError)) = result, ### Typed request bodies -Just as the response side is generic over any `Decodable`, the request side is generic over any `Encodable`. `post`, `put`, and `patch` take a `body:` that's JSON-encoded for you and sent with `Content-Type: application/json` — so the body you send is compile-checked, never an untyped `[String: Any]` dictionary: +Just as the response side is generic over any `Decodable`, the request side is generic over any `Encodable`. `post`, `put`, and `patch` take a `body:` that's JSON-encoded for you and sent with `Content-Type: application/json` — so the body you send is compile-checked, a typed value rather than a `[String: Any]` dictionary: ```swift struct Credentials: Encodable { @@ -342,7 +342,7 @@ let ack: Result = await networking.put("/account", body: ## Choosing how the body is encoded -Each encoding is a distinct, typed method — the method you call picks the `Content-Type`, so there's no untyped `parameters:`/`parameterType:` pair to get wrong. +Each encoding is a distinct, typed method, and the method you call picks the `Content-Type` — one decision, rather than a `parameters:`/`parameterType:` pair to keep in step. ### JSON @@ -356,7 +356,7 @@ let result: Result = await networking.post("/post ### URL-encoding -Pass `form:` to send `application/x-www-form-urlencoded`; **Networking** percent-encodes it for you ([`Percent-encoding` / `URL-encoding`](https://en.wikipedia.org/wiki/Percent-encoding#The_application.2Fx-www-form-urlencoded_type)). `form:` takes any flat `Encodable` — a `[String: String]` or your own model — and stringifies scalars for you (`Bool` → `"true"`, not `"1"`): +Pass `form:` to send `application/x-www-form-urlencoded`; **Networking** percent-encodes it for you ([`Percent-encoding` / `URL-encoding`](https://en.wikipedia.org/wiki/Percent-encoding#The_application.2Fx-www-form-urlencoded_type)). `form:` takes any flat `Encodable` — a `[String: String]` or your own model — and stringifies scalars for you, so a `Bool` becomes `"true"`: ```swift let networking = Networking(baseURL: "http://example.com") @@ -394,7 +394,7 @@ let result: Result = await networking.post("/imag **FormDataPart Content-Type**: -Each part's `Content-Type` comes from its `FormDataPartType`, which simply carries the MIME string. The default is `.octetStream` (`application/octet-stream`); `.png` and `.jpeg` are provided as conveniences. For anything else, construct one directly — `FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …)`. +Each part's `Content-Type` comes from its `FormDataPartType`, which carries the MIME string. The default is `.octetStream` (`application/octet-stream`); `.png` and `.jpeg` are provided as conveniences. For anything else, construct one directly — `FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …)`. ### Raw data @@ -454,7 +454,7 @@ let result: Result<[Story], NetworkingError> = await networking.get("/stories") **Faking with contents of a file**: -If your file is not located in the main bundle you have to specify using the bundle parameters, otherwise `NSBundle.mainBundle()` will be used. +A file outside the main bundle takes the bundle parameter; everything else uses `Bundle.main`. ```swift let networking = Networking(baseURL: baseURL) @@ -465,9 +465,9 @@ let result: Result = await networking.get("/entri **Faking with status code**: -If you do not provide a status code for this fake request, the default returned one will be 200 (SUCCESS), but if you do provide a status code that is not 2xx, then **Networking** returns `.failure(.http(HTTPError))` carrying that status code, with the fake's body in `metadata` for you to `decode` — see [Handling errors](#handling-errors). +A fake request defaults to 200 (SUCCESS). Give it a status code outside 2xx and **Networking** returns `.failure(.http(HTTPError))` carrying that status code, with the fake's body in `metadata` for you to `decode` — see [Handling errors](#handling-errors). -Use the no-body overload (omit `response:`) for a status-code-only fake: +Omit `response:` for a status-code-only fake: ```swift let networking = Networking(baseURL: "https://example.com") @@ -524,11 +524,11 @@ let _: Result = await networking.downloadImage("/image/p // Image from cache ``` -**Clearing and expiring the cache.** Call `await networking.clearCache()` to empty it — **both** the in-memory layer and the on-disk files. (`reset()` does the same, plus wiping credentials.) There's no per-path purge: `clearCache()` clears everything, and stale entries expire on their own (below). For a download, `downloadImage`/`downloadData` with `cachingLevel: .none` force a fresh fetch and drop that cached copy; for the verbs, `.none` simply bypasses the cache (it neither reads nor writes — and is the default, so it never disturbs an entry you cached deliberately). +**Clearing and expiring the cache.** Call `await networking.clearCache()` to empty it — **both** the in-memory layer and the on-disk files. (`reset()` does the same, plus wiping credentials.) `clearCache()` takes the whole cache at once: it clears everything, and stale entries expire on their own (below). For a download, `downloadImage`/`downloadData` with `cachingLevel: .none` force a fresh fetch and drop that cached copy; for the verbs, `.none` bypasses the cache and is the default, so an entry you cached deliberately stays where it is. -On-disk entries expire on their own, so a long-lived cache can't grow without bound. An entry whose **on-disk last use** is older than `cacheTTL` (default **7 days**; set it via `init(…, cacheTTL:)` or `setCacheTTL(_:)`) is swept. The persisted clock is the cache file's modification date, refreshed by **disk reads and writes** — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to `NSCache`'s own memory-pressure eviction) and does **not** re-stamp the disk file on a hit, so an entry kept warm *only* in memory for longer than `cacheTTL` may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm. +On-disk entries expire on their own, so a long-lived cache stays bounded. An entry whose **on-disk last use** is older than `cacheTTL` (default **7 days**; set it via `init(…, cacheTTL:)` or `setCacheTTL(_:)`) is swept. The persisted clock is the cache file's modification date, refreshed by **disk reads and writes** — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to `NSCache`'s own memory-pressure eviction) and leaves the disk file's date alone on a hit, so an entry kept warm *only* in memory for longer than `cacheTTL` may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm. -> **This is a key→blob store, not HTTP caching.** `cachingLevel` caches the downloaded bytes *unconditionally* by path (or `cacheName:`), ignoring `Cache-Control`/`ETag`/`Expires` — which is what you usually want for images and other assets whose hosts often send no cache headers at all. It deliberately does **not** implement HTTP cache semantics (freshness, conditional `304` revalidation, eviction). If you want those, configure a `URLCache` on the session and let `URLSession` handle it per the response headers — it composes with the verb requests: +> **This is a key→blob store rather than HTTP caching.** `cachingLevel` caches the downloaded bytes *unconditionally* by path (or `cacheName:`), ignoring `Cache-Control`/`ETag`/`Expires` — which is what you usually want for images and other assets whose hosts often omit cache headers entirely. HTTP cache semantics — freshness, conditional `304` revalidation, eviction — are deliberately out of scope. For those, configure a `URLCache` on the session and let `URLSession` handle it per the response headers — it composes with the verb requests: > > ```swift > let configuration = URLSessionConfiguration.default @@ -551,7 +551,7 @@ let result: Result = await networking.downloadImage("/im **Networking** doesn't print to the console. Two separate things give you visibility: a **failure log** that's on by default (next section), and an **event stream** you can hook for the full lifecycle. -`events()` returns an `AsyncStream` — one `.started` then one `.completed` for **every** request (verbs *and* downloads). Iterate it with `for await`, accumulating into plain local state (no callback-capture gymnastics): +`events()` returns an `AsyncStream` — one `.started` then one `.completed` for **every** request (verbs *and* downloads). Iterate it with `for await`, accumulating into plain local state: ```swift let stream = await networking.events() @@ -585,7 +585,7 @@ await networking.setRedactedHeaderFields(["Authorization", "X-Api-Key"]) ### Built-in logging -Out of the box — no setup — the library logs **failures** (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (`os.Logger`, subsystem `com.elvisnunez.networking`), tagged with the request id. This is the modern replacement for console `print`: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations. +Straight out of the box the library logs **failures** (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (`os.Logger`, subsystem `com.elvisnunez.networking`), tagged with the request id. This is the modern replacement for console `print`: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations. `setLogLevel` chooses **which requests** are logged — logged requests always get full detail (line, request + response headers, request + response bodies, truncated): @@ -597,7 +597,7 @@ await networking.setLogLevel(.all) // every request too, success or failur `.failures` is the default: failures are rare, so logging them in full is cheap and it's the case you debug — including the **request body**, the quickest way to catch a wrong-shaped payload. `.all` adds successful requests (with their response body, for "succeeded but returned the wrong thing"). The level gates *only* the built-in logging — `events()` always delivers full structured events regardless. (Downloads — `downloadImage`/`downloadData` — log the line + request headers; their response headers/body are omitted since the payload is binary.) -**Privacy — one rule: debug shows, release redacts.** Requests carry sensitive data (logins, payments, profiles, `Authorization`/`Cookie` headers). `redactsLogs` governs the built-in **logs**: in **debug** builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in **release** it replaces both the body lines *and* the `setRedactedHeaderFields` header values (`Authorization`/`Cookie`/`Set-Cookie` by default) with ``. Override either way with `setRedactsLogs(_:)`. One caveat on what redaction does **not** cover: `events()` always carries the **real** headers — it's your own request data, and redaction is a logging concern, not an observation one. Need more control? `setLogLevel(.none)` or filter `events()` yourself. +**Privacy — one rule: debug shows, release redacts.** Requests carry sensitive data (logins, payments, profiles, `Authorization`/`Cookie` headers). `redactsLogs` governs the built-in **logs**: in **debug** builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in **release** it replaces both the body lines *and* the `setRedactedHeaderFields` header values (`Authorization`/`Cookie`/`Set-Cookie` by default) with ``. Override either way with `setRedactsLogs(_:)`. One boundary worth knowing: `events()` always carries the **real** headers — it's your own request data, and redaction is a logging concern rather than an observation one. For more control, `setLogLevel(.none)` or filter `events()` yourself. **Reading logs from a CLI / test / headless run.** `os.Logger` isn't visible in `swift test` / `swift run` stdout. Point the library at a file and it mirrors the same diagnostics there as plain text: @@ -605,7 +605,7 @@ await networking.setLogLevel(.all) // every request too, success or failur await networking.setLogFileURL(URL(fileURLWithPath: "/tmp/networking.log")) ``` -Or set it with **no code change** via the `NETWORKING_LOG_FILE` environment variable — handy for CI or an automated agent: +Or set it from the environment with **`NETWORKING_LOG_FILE`**, leaving the code untouched — handy for CI or an automated agent: ```shell NETWORKING_LOG_FILE=/tmp/networking.log swift test @@ -624,7 +624,7 @@ dir=$(xcrun simctl get_app_container data) cat "$dir/Library/Caches/networking.log" ``` -(On a physical device there's no `simctl`; fall back to Console.app / `os.Logger`.) +(A physical device has Console.app / `os.Logger` in place of `simctl`.) ## Installing @@ -656,7 +656,7 @@ swift test make httpbin-stop ``` -Tests default to `http://127.0.0.1:8080`; set `HTTPBIN_BASE_URL` to point elsewhere. The offline (faked) suites need no server. +Tests default to `http://127.0.0.1:8080`; set `HTTPBIN_BASE_URL` to point elsewhere. The offline (faked) suites run on their own. ## Author From 5cb06ae2dd24cb53d69b674ce9902d31633a01e6 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Wed, 16 Sep 2026 07:12:39 +0200 Subject: [PATCH 09/12] The formatter and the linter agree about a trailing comma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift-format adds a trailing comma to a multi-line collection literal by default and oida's trailing_comma forbids one, so bin/lint --fix wrote fifteen commas that bin/lint --check then rejected — stably, since a second --fix changed nothing. tienda-ios has always set this key, which is why it reports none. multiElementCollectionTrailingCommas: false is the half that makes both directions agree; mandatory_comma: true alone leaves seven, because the formatter adds the comma in most multi-line literals rather than all of them. Filed as kolonialno/oida-swift#34. --fix is idempotent now. --- .swift-format | 1 + 1 file changed, 1 insertion(+) diff --git a/.swift-format b/.swift-format index 2a64983..051ba15 100644 --- a/.swift-format +++ b/.swift-format @@ -5,6 +5,7 @@ "spaces": 4 }, "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": false, "respectsExistingLineBreaks": true, "lineBreakBeforeEachArgument": false } From 624463c5fdee92cf07a9b94f227f0d6f033e31fe Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Wed, 16 Sep 2026 21:40:02 +0200 Subject: [PATCH 10/12] oida 0.16.0, and the configuration loses what went with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.16.0 takes 209 rules to 89, and 25 of the names this configuration carried are among them — the SwiftLint opt-in menu, the whitespace rules swift-format already reports, and the preferences that pick one of two correct spellings. trailing_comma goes with them, which closes #34: layout is the formatter's, so multiElementCollectionTrailingCommas decides alone and oida holds no opinion to disagree with. file_header and force_unwrapping left too. The scoped disable on the HTTPURLResponse initializer goes with force_unwrapping, and its comment stays: why that unwrap is safe is worth reading whether or not a rule asks. --fix is idempotent, suite 180/180 green, and the tree is at 9 — no_single_use_void_functions, all of it. --- .oida-checksum-linux-x86_64 | 2 +- .oida-checksum-macos-arm64 | 2 +- .oida-version | 2 +- .oida.yml | 39 +------------------------------- Sources/Networking/Helpers.swift | 2 -- 5 files changed, 4 insertions(+), 43 deletions(-) diff --git a/.oida-checksum-linux-x86_64 b/.oida-checksum-linux-x86_64 index b5cbfd5..0cd652c 100644 --- a/.oida-checksum-linux-x86_64 +++ b/.oida-checksum-linux-x86_64 @@ -1 +1 @@ -0bfe78da9491f6457bf81bd265230131a2b0a0005076b71414cfee265cf04d5c +1df36e1a360fc0c613bce32bfd81d95ddd8d9f888c4bd3da223c305dce03ec88 diff --git a/.oida-checksum-macos-arm64 b/.oida-checksum-macos-arm64 index 76194ee..4e5db0c 100644 --- a/.oida-checksum-macos-arm64 +++ b/.oida-checksum-macos-arm64 @@ -1 +1 @@ -29dc7d4234d4a9863651af651bcfcb8d7fa1efb3602fc29f73c373f7b08d9a61 +b8715e4c9a16a1c4e78d09ab3904cb6966bbce41d722f774b3846352f3f0c7f9 diff --git a/.oida-version b/.oida-version index a551051..04a373e 100644 --- a/.oida-version +++ b/.oida-version @@ -1 +1 @@ -0.15.0 +0.16.0 diff --git a/.oida.yml b/.oida.yml index b7c26fb..d92ad03 100644 --- a/.oida.yml +++ b/.oida.yml @@ -17,46 +17,16 @@ opt_in_rules: - comment_adds_no_word - no_mark_comments - no_single_use_void_functions - # Auto-fixable and mechanical — `bin/lint --fix` applies these, no judgement needed. - - modifier_order - - self_binding - - toggle_bool - - optional_enum_case_matching - - period_spacing - - prefer_zero_over_explicit_init - - redundant_type_annotation - - explicit_init - - comma_inheritance - - final_test_case + # A cast or a try that traps on anything the runtime hands it, and an override that overrides nothing. - unneeded_override - - closure_spacing - - contains_over_first_not_nil - - fatal_error_message - - joined_default_parameter - - let_var_whitespace - - operator_usage_whitespace - - redundant_nil_coalescing - - unneeded_parentheses_in_closure_argument - - unowned_variable_capture - - yoda_condition - - unhandled_throwing_task - - file_header - force_cast - force_try - - force_unwrapping # VOICE.md's own contract, enforced on every root document. - document_says_what_is - document_avoids_retired_words - document_links_resolve disabled_rules: - # `.swift-format` owns whitespace, and it is the formatter oida hands the files to, so these three - # would demand the alignment it removes. - - closure_end_indentation - - literal_expression_end_indentation - - vertical_parameter_alignment_on_call - # grouped_imports owns import order; this rule would flatten its groups into one alphabetical run. - - sorted_imports - todo # work is tracked in PRs and LEARNINGS, not in-code markers - unused_closure_parameter @@ -90,13 +60,6 @@ identifier_name: type_name: max_length: 45 -file_header: - severity: error - forbidden_pattern: 'Copyright © \d|Created by .+ on ' - -force_unwrapping: - severity: error - multiline_call_arguments: max_number_of_single_line_parameters: 2 diff --git a/Sources/Networking/Helpers.swift b/Sources/Networking/Helpers.swift index 8f8f841..d2bbad6 100644 --- a/Sources/Networking/Helpers.swift +++ b/Sources/Networking/Helpers.swift @@ -117,14 +117,12 @@ extension HTTPURLResponse { ) { // composedURL has already parsed this URL and thrown on anything that would not, so the only // input Foundation refuses cannot reach here. - // oida:disable force_unwrapping self.init( url: url, statusCode: statusCode, httpVersion: nil, headerFields: headerFields )! - // oida:enable force_unwrapping } } From 5aeaaf4cf95ceb63dc3e1a03f56a6e8a94572a23 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Wed, 16 Sep 2026 21:46:15 +0200 Subject: [PATCH 11/12] The log file is written where the log line is recorded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendToLogFile had one caller, in the same file, as its last statement — the jump bought the reader nothing and its guards read the same inside record. The other two same-file cases stay. removeContinuation is an actor-isolation hop whose caller is a nonisolated termination handler, and inlining it fails to compile. logCompletion is fifty-five lines with three early exits, called from a function that carries on afterwards; folding it turns those exits into nested conditionals and makes complete do three jobs. Suite 180/180 green. --- Sources/Networking/Networking.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Networking/Networking.swift b/Sources/Networking/Networking.swift index ff376cc..078f26c 100644 --- a/Sources/Networking/Networking.swift +++ b/Sources/Networking/Networking.swift @@ -130,10 +130,7 @@ public actor Networking { func record(_ message: String, level: OSLogType) { guard logLevel != .none else { return } logger.log(level: level, "\(message, privacy: .public)") - appendToLogFile(message) - } - private func appendToLogFile(_ message: String) { guard let logFileURL else { return } let entry = "\(Date().ISO8601Format()) \(message)\n" guard let data = entry.data(using: .utf8) else { return } From 43c1af90a95f7d1ab2dcd5b9ef679f7387efc955 Mon Sep 17 00:00:00 2001 From: Elvis Nunez Date: Wed, 16 Sep 2026 21:54:46 +0200 Subject: [PATCH 12/12] The eight that stay say why, and the tree is at zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each carries a scoped disable and the reason above it. Six are seams: the cancel path reached from the verbs, and the cache store's set, write, clear and sweep, each reached from Networking and three of them driven directly by tests. Folding any of them pushes a store's internals into its consumer. Two are same-file and still stay. logCompletion is fifty lines of logging behind a name, with three early exits the caller must never inherit — returning from complete would skip emit and the result. removeContinuation is the hop that makes an actor-isolated write legal from a nonisolated termination handler, and inlining it fails to compile. clear() takes a block disable rather than :next, so its doc comment stays on the declaration it documents. bin/lint --check is clean, --fix is idempotent, suite 180/180 green. The gate is hard from here: any new violation fails. --- Sources/Networking/CacheExpiry.swift | 2 ++ Sources/Networking/CacheStore.swift | 9 +++++++++ Sources/Networking/Networking+New.swift | 3 +++ Sources/Networking/Networking+Private.swift | 2 ++ Sources/Networking/Networking.swift | 3 +++ 5 files changed, 19 insertions(+) diff --git a/Sources/Networking/CacheExpiry.swift b/Sources/Networking/CacheExpiry.swift index 8df0b38..b7fa7af 100644 --- a/Sources/Networking/CacheExpiry.swift +++ b/Sources/Networking/CacheExpiry.swift @@ -18,6 +18,8 @@ final class CacheExpiry: @unchecked Sendable { return .seconds(ttlSeconds) } + // The lock-guarded write itself; the lock is the reason this is a function. + // oida:disable:next no_single_use_void_functions func setTTL(_ ttl: Duration) { lock.lock() defer { lock.unlock() } diff --git a/Sources/Networking/CacheStore.swift b/Sources/Networking/CacheStore.swift index 33e8ca8..3b394f3 100644 --- a/Sources/Networking/CacheStore.swift +++ b/Sources/Networking/CacheStore.swift @@ -24,6 +24,8 @@ final class CacheStore: @unchecked Sendable { var ttl: Duration { expiry.ttl } + // Forwards to an expiry the caller cannot reach, since it is private to the store. + // oida:disable:next no_single_use_void_functions func setTTL(_ ttl: Duration) { expiry.setTTL(ttl) } /// The on-disk URL for a resolved resource key, laid out under `folderName//`. Creates the @@ -137,6 +139,8 @@ final class CacheStore: @unchecked Sendable { } } + // The store's write seam, reached from Networking and driven directly by six tests. + // oida:disable:next no_single_use_void_functions func storeData( _ data: Data?, forResource resource: String, @@ -197,6 +201,8 @@ final class CacheStore: @unchecked Sendable { static let mutationLock = NSLock() static let sweepCursorFileName = ".sweep-shard" + // The store's clear seam, reached from Networking and driven directly by three tests. + // oida:disable no_single_use_void_functions /// Empties **both** tiers (clearing only one would leave the other serving deleted data). Scoped to the /// networking folder; unrelated files in Caches are untouched. func clear() throws { @@ -208,6 +214,7 @@ final class CacheStore: @unchecked Sendable { _ = try FileManager.default.remove(at: folderURL) } } + // oida:enable no_single_use_void_functions private static func folderURL(named folderName: String) -> URL? { guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { @@ -222,6 +229,8 @@ final class CacheStore: @unchecked Sendable { // Deletes expired files from **one** shard per call (rotated via a tiny cursor file), so each launch's // sweep is O(N / shardCount) and everything gets visited over `shardCount` launches. Age is judged by // the file's modification date. Best-effort and off the request path. + // The store's sweep seam, reached from Networking on a detached task. + // oida:disable:next no_single_use_void_functions func sweepExpired() { Self.mutationLock.lock() defer { Self.mutationLock.unlock() } diff --git a/Sources/Networking/Networking+New.swift b/Sources/Networking/Networking+New.swift index 5f467ce..1ab9f99 100644 --- a/Sources/Networking/Networking+New.swift +++ b/Sources/Networking/Networking+New.swift @@ -247,6 +247,9 @@ extension Networking { return result } + // Fifty lines of logging behind a name, and three early exits the caller must never inherit — + // returning from complete would skip emit and the result. + // oida:disable:next no_single_use_void_functions private func logCompletion( context: RequestContext, result: Result, diff --git a/Sources/Networking/Networking+Private.swift b/Sources/Networking/Networking+Private.swift index 98875f3..af427b8 100644 --- a/Sources/Networking/Networking+Private.swift +++ b/Sources/Networking/Networking+Private.swift @@ -349,6 +349,8 @@ extension Networking { return (exchange.data, exchange.response) } + // The cancel path is Networking's own, reached from the verbs in another file. + // oida:disable:next no_single_use_void_functions func cancelRequest( _ sessionTaskType: SessionTaskType, requestType: RequestType, diff --git a/Sources/Networking/Networking.swift b/Sources/Networking/Networking.swift index 078f26c..c2646cb 100644 --- a/Sources/Networking/Networking.swift +++ b/Sources/Networking/Networking.swift @@ -83,6 +83,9 @@ public actor Networking { return stream } + // The hop that makes an actor-isolated write legal from a nonisolated termination handler. + // Inlining it fails to compile: 'actor-isolated property ... can not be mutated'. + // oida:disable:next no_single_use_void_functions private func removeContinuation(_ id: UUID) { streamContinuations[id] = nil }