From ea277181df84274cc4ef9e2d3c33241454b04573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:01:57 +0100 Subject: [PATCH 01/30] chore: Upgrade to Swift 6.1 and update minimum deployment targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update swift-tools-version from 5.1 to 6.1 - Remove redundant Package@swift-5.5.swift (consolidated into Package.swift) - Update minimum deployment targets: - iOS 12 → 15 - macOS 10.10 → 12 - tvOS 12 → 15 - watchOS 5 → 8 - Update podspec Swift version to 6.1 - Remove CoreServices framework dependency (no longer needed) BREAKING CHANGE: Minimum deployment targets have been raised to support Swift 6.1 concurrency features. # Conflicts: # Package.swift --- FTAPIKit.podspec | 12 ++++++------ Package.swift | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/FTAPIKit.podspec b/FTAPIKit.podspec index 4b87e7b..ded55c2 100644 --- a/FTAPIKit.podspec +++ b/FTAPIKit.podspec @@ -17,12 +17,12 @@ Pod::Spec.new do |s| s.source_files = "Sources/FTAPIKit/**/*" s.exclude_files = "Sources/FTAPIKit/Documentation.docc/**/*" - s.frameworks = ["Foundation", "CoreServices"] + s.frameworks = ["Foundation"] s.weak_frameworks = ["Combine"] - s.swift_version = "5.1" - s.ios.deployment_target = "9.0" - s.osx.deployment_target = "10.10" - s.watchos.deployment_target = "5.0" - s.tvos.deployment_target = "12.0" + s.swift_version = "6.1" + s.ios.deployment_target = "15.0" + s.osx.deployment_target = "12.0" + s.watchos.deployment_target = "8.0" + s.tvos.deployment_target = "15.0" end diff --git a/Package.swift b/Package.swift index b88a739..fc9ca94 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.1 import PackageDescription From 867034c4b400041a96caf3a3e7a7b93aea4731b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:02:16 +0100 Subject: [PATCH 02/30] refactor: Remove deprecated CoreServices API and simplify MIME type detection - Remove CoreServices framework usage (deprecated) - Simplify URL+MIME.swift by removing fallback code for pre-macOS 11/iOS 14 - Use UniformTypeIdentifiers exclusively on Apple platforms - Reduce code complexity by ~40 lines - Keep Linux support using file command With minimum deployment targets now at macOS 12+/iOS 15+, we no longer need the CoreServices fallback since UniformTypeIdentifiers is available on all supported OS versions. --- Sources/FTAPIKit/URL+MIME.swift | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/Sources/FTAPIKit/URL+MIME.swift b/Sources/FTAPIKit/URL+MIME.swift index cc0f1bc..870482b 100644 --- a/Sources/FTAPIKit/URL+MIME.swift +++ b/Sources/FTAPIKit/URL+MIME.swift @@ -1,7 +1,4 @@ import Foundation -#if canImport(CoreServices) -import CoreServices -#endif #if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers #endif @@ -13,33 +10,16 @@ extension URL { #if os(Linux) return linuxMimeType(for: path) ?? fallback #else - if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) { - return uniformMimeType(for: pathExtension) ?? fallback - } else { - return coreServicesMimeType(for: pathExtension) ?? fallback - } + return uniformMimeType(for: pathExtension) ?? fallback #endif } #if canImport(UniformTypeIdentifiers) - @available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) private func uniformMimeType(for fileExtension: String) -> String? { UTType(filenameExtension: fileExtension)?.preferredMIMEType } #endif - #if canImport(CoreServices) - private func coreServicesMimeType(for fileExtension: String) -> String? { - if - let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue() - { - return contentType as String - } - return nil - } - #endif - #if os(Linux) private func linuxMimeType(for path: String) -> String? { // Path to `env` on most operatin systems From 9eb3bbbce5c8716c8fab53236cc0366996bedd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:06:56 +0100 Subject: [PATCH 03/30] feat!: Add Sendable conformance requirement to ResponseEndpoint - Require Response types to conform to Sendable in ResponseEndpoint protocol - Add Sendable conformance to User test model - Remove redundant Sendable constraint from async function signature BREAKING CHANGE: All ResponseEndpoint.Response types must now conform to Sendable. This enforces Swift 6 concurrency safety at compile time and provides clear error messages at the endpoint definition site rather than at the call site. This design choice improves developer experience by failing fast with clear errors when defining endpoints with non-Sendable response types, rather than discovering issues later when calling async functions. --- Sources/FTAPIKit/Endpoint.swift | 2 +- Tests/FTAPIKitTests/Mockups/Models.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/FTAPIKit/Endpoint.swift b/Sources/FTAPIKit/Endpoint.swift index f1d54bd..5808b9e 100644 --- a/Sources/FTAPIKit/Endpoint.swift +++ b/Sources/FTAPIKit/Endpoint.swift @@ -82,7 +82,7 @@ public protocol ResponseEndpoint: Endpoint { /// Associated type describing the return type conforming to `Decodable` /// protocol. This is only a phantom-type used by `APIAdapter` /// for automatic decoding/deserialization of API results. - associatedtype Response: Decodable + associatedtype Response: Decodable & Sendable } /// Protocol extending ``Endpoint``, which supports sending `Encodable` data to the server. diff --git a/Tests/FTAPIKitTests/Mockups/Models.swift b/Tests/FTAPIKitTests/Mockups/Models.swift index 8b697f1..84169a9 100644 --- a/Tests/FTAPIKitTests/Mockups/Models.swift +++ b/Tests/FTAPIKitTests/Mockups/Models.swift @@ -1,6 +1,6 @@ import Foundation -struct User: Codable, Equatable { +struct User: Codable, Equatable, Sendable { let uuid: UUID let name: String let age: UInt From 1a6dee45e6a62ed5695c3f613f26edb5279ef81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:07:15 +0100 Subject: [PATCH 04/30] fix: Resolve Swift 6 concurrency safety warnings - Change static var to static let for immutable global state: - APIError+Standard.swift: unhandled property - Test mock Errors.swift: unhandled property - Test files: allTests arrays - Fix SwiftLint violations: - EndpointPublisher: Use proper nesting disable/enable scope - URLRequestBuilder: Remove unneeded synthesized initializer All changes maintain thread safety and resolve Swift 6 strict concurrency checking errors without suppressing warnings. --- Sources/FTAPIKit/APIError+Standard.swift | 2 +- Sources/FTAPIKit/Combine/EndpointPublisher.swift | 3 ++- Sources/FTAPIKit/URLRequestBuilder.swift | 5 ----- Tests/FTAPIKitTests/AsyncTests.swift | 2 +- Tests/FTAPIKitTests/Mockups/Errors.swift | 2 +- Tests/FTAPIKitTests/ResponseTests.swift | 2 +- Tests/FTAPIKitTests/URLQueryTests.swift | 2 +- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Sources/FTAPIKit/APIError+Standard.swift b/Sources/FTAPIKit/APIError+Standard.swift index f982534..0dd263f 100644 --- a/Sources/FTAPIKit/APIError+Standard.swift +++ b/Sources/FTAPIKit/APIError+Standard.swift @@ -41,5 +41,5 @@ public enum APIErrorStandard: APIError { } } - public static var unhandled: Standard = .unhandled(data: nil, response: nil, error: nil) + public static let unhandled: Standard = .unhandled(data: nil, response: nil, error: nil) } diff --git a/Sources/FTAPIKit/Combine/EndpointPublisher.swift b/Sources/FTAPIKit/Combine/EndpointPublisher.swift index 02af89e..141bec6 100644 --- a/Sources/FTAPIKit/Combine/EndpointPublisher.swift +++ b/Sources/FTAPIKit/Combine/EndpointPublisher.swift @@ -1,8 +1,8 @@ -// swiftlint:disable nesting import Foundation #if canImport(Combine) import Combine +// swiftlint:disable nesting @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) extension Publishers { struct Endpoint: Publisher { @@ -19,5 +19,6 @@ extension Publishers { } } } +// swiftlint:enable nesting #endif diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index 0032a9b..fe16938 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -16,11 +16,6 @@ struct URLRequestBuilder { let server: S let endpoint: Endpoint - init(server: S, endpoint: Endpoint) { - self.server = server - self.endpoint = endpoint - } - /// Creates an instance of `URLRequest` corresponding to provided endpoint executed on provided server. /// It is safe to execute this method multiple times per instance lifetime. /// - Returns: A valid `URLRequest`. diff --git a/Tests/FTAPIKitTests/AsyncTests.swift b/Tests/FTAPIKitTests/AsyncTests.swift index 808a27d..e4b6e1d 100644 --- a/Tests/FTAPIKitTests/AsyncTests.swift +++ b/Tests/FTAPIKitTests/AsyncTests.swift @@ -25,7 +25,7 @@ final class AsyncTests: XCTestCase { XCTAssertEqual(user, response.json) } - static var allTests = [ + static let allTests = [ ("testCallWithoutResponse", testCallWithoutResponse), ("testCallWithData", testCallWithData), ("testCallParsingResponse", testCallParsingResponse) diff --git a/Tests/FTAPIKitTests/Mockups/Errors.swift b/Tests/FTAPIKitTests/Mockups/Errors.swift index aad8f06..f91e5f0 100644 --- a/Tests/FTAPIKitTests/Mockups/Errors.swift +++ b/Tests/FTAPIKitTests/Mockups/Errors.swift @@ -12,5 +12,5 @@ struct ThrowawayAPIError: APIError { self.init() } - static var unhandled = Self() + static let unhandled = Self() } diff --git a/Tests/FTAPIKitTests/ResponseTests.swift b/Tests/FTAPIKitTests/ResponseTests.swift index 768d7a5..c974cad 100644 --- a/Tests/FTAPIKitTests/ResponseTests.swift +++ b/Tests/FTAPIKitTests/ResponseTests.swift @@ -219,7 +219,7 @@ final class ResponseTests: XCTestCase { wait(for: [expectation], timeout: timeout) } - static var allTests = [ + static let allTests = [ ("testGet", testGet), ("testClientError", testClientError), ("testServerError", testServerError), diff --git a/Tests/FTAPIKitTests/URLQueryTests.swift b/Tests/FTAPIKitTests/URLQueryTests.swift index 8e999a4..7a75a5e 100644 --- a/Tests/FTAPIKitTests/URLQueryTests.swift +++ b/Tests/FTAPIKitTests/URLQueryTests.swift @@ -38,7 +38,7 @@ final class URLQueryTests: XCTestCase { XCTAssertEqual(query.percentEncoded, "a=&b=") } - static var allTests = [ + static let allTests = [ ("testSpaceEncoding", testSpaceEncoding), ("testDelimitersEncoding", testDelimitersEncoding), ("testQueryAppending", testQueryAppending), From 1977658ac9a9226a62575b6a40e19ba09f62cf77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:07:34 +0100 Subject: [PATCH 05/30] ci: Update GitHub Actions workflows to use macOS 14 runners - Remove macos-10.15.yml (runner no longer available) - Remove macos-11.yml (runner deprecated) - Add macos-14.yml with Xcode 16.2 (supports Swift 6.1) - Update ubuntu-latest.yml to use actions/checkout@v4 - Consolidate to single macOS workflow on latest available runner macOS 14 is the oldest currently supported GitHub Actions runner. Previous runners (macOS 10.15, 11, 13) have been deprecated or removed. --- .github/workflows/ubuntu-latest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ubuntu-latest.yml b/.github/workflows/ubuntu-latest.yml index b1b80df..050552f 100644 --- a/.github/workflows/ubuntu-latest.yml +++ b/.github/workflows/ubuntu-latest.yml @@ -16,7 +16,7 @@ jobs: run: swift --version - name: Checkout FTAPIKit - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Swift build & test run: | From 00f7d31c479cb69d38f047a5e2d795aeb1d77571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 16:07:46 +0100 Subject: [PATCH 06/30] docs: Update documentation for Swift 6.1 migration - Update README.md: - Fix workflow badges to reference new macos-14.yml - Remove outdated macos-10.15 and macos-11 badges Documentation now accurately reflects the codebase state and breaking changes introduced in this migration. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ccbe252..25e1998 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ ![Cocoapods platforms](https://img.shields.io/cocoapods/p/FTAPIKit) ![License](https://img.shields.io/cocoapods/l/FTAPIKit) -![macOS 11](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-11.yml/badge.svg?branch=main) -![macOS 10.15](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-10.15.yml/badge.svg?branch=main) +![macOS 14](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-14.yml/badge.svg?branch=main) ![Ubuntu](https://github.com/futuredapp/FTAPIKit/actions/workflows/ubuntu-latest.yml/badge.svg?branch=main) Declarative and generic REST API framework using Codable. From 788c9b90b5fcdb7a3e74256a2f65815bae46b9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 21:16:42 +0100 Subject: [PATCH 07/30] feat!: Remove Combine and completion handler APIs Remove Combine support and completion handler-based API in favor of async/await only. This simplifies the codebase and aligns with Swift 6.1 concurrency patterns. BREAKING CHANGE: All completion handler methods and Combine publishers have been removed. Users must migrate to async/await patterns. --- .../FTAPIKit/Combine/EndpointPublisher.swift | 24 -- .../Combine/EndpointSubscription.swift | 40 --- .../FTAPIKit/Combine/URLServer+Combine.swift | 56 ---- Sources/FTAPIKit/URLServer+Call.swift | 97 ------- Tests/FTAPIKitTests/CombineTests.swift | 99 -------- Tests/FTAPIKitTests/ResponseTests.swift | 240 ------------------ 6 files changed, 556 deletions(-) delete mode 100644 Sources/FTAPIKit/Combine/EndpointPublisher.swift delete mode 100644 Sources/FTAPIKit/Combine/EndpointSubscription.swift delete mode 100644 Sources/FTAPIKit/Combine/URLServer+Combine.swift delete mode 100644 Sources/FTAPIKit/URLServer+Call.swift delete mode 100644 Tests/FTAPIKitTests/CombineTests.swift delete mode 100644 Tests/FTAPIKitTests/ResponseTests.swift diff --git a/Sources/FTAPIKit/Combine/EndpointPublisher.swift b/Sources/FTAPIKit/Combine/EndpointPublisher.swift deleted file mode 100644 index 141bec6..0000000 --- a/Sources/FTAPIKit/Combine/EndpointPublisher.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -// swiftlint:disable nesting -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -extension Publishers { - struct Endpoint: Publisher { - typealias Output = R - typealias Failure = E - - typealias Builder = (@escaping (Result) -> Void) -> URLSessionTask? - - let builder: Builder - - func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { - let subscription = EndpointSubscription(subscriber: subscriber, builder: builder) - subscriber.receive(subscription: subscription) - } - } -} -// swiftlint:enable nesting - -#endif diff --git a/Sources/FTAPIKit/Combine/EndpointSubscription.swift b/Sources/FTAPIKit/Combine/EndpointSubscription.swift deleted file mode 100644 index 082953a..0000000 --- a/Sources/FTAPIKit/Combine/EndpointSubscription.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -final class EndpointSubscription: Subscription where S.Input == R, S.Failure == E { - private let builder: Publishers.Endpoint.Builder - private var subscriber: S? - - private var task: URLSessionTask? - - init(subscriber: S, builder: @escaping Publishers.Endpoint.Builder) { - self.subscriber = subscriber - self.builder = builder - } - - func request(_ demand: Subscribers.Demand) { - guard demand > .none, task == nil else { - return - } - - task = builder { [subscriber] result in - switch result { - case .success(let input): - _ = subscriber?.receive(input) - subscriber?.receive(completion: .finished) - case .failure(let error): - subscriber?.receive(completion: .failure(error)) - } - } - } - - func cancel() { - task?.cancel() - task = nil - subscriber = nil - } -} - -#endif diff --git a/Sources/FTAPIKit/Combine/URLServer+Combine.swift b/Sources/FTAPIKit/Combine/URLServer+Combine.swift deleted file mode 100644 index b2fc5c8..0000000 --- a/Sources/FTAPIKit/Combine/URLServer+Combine.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -#if canImport(Combine) -import Combine - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) -public extension URLServer { - - /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success void, otherwise error. - func publisher(endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(endpoint: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that won't be parsed by the decoder of the - /// server. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(data endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success plain data, otherwise error. - func publisher(data endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(data: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - /// Performs call to endpoint which returns data which will be parsed by the server decoder. - /// - Note: Canceling this chain will result in the abortion of the URLSessionTask. - /// - Note: This call maps `func call(response endpoint: EP, completion: @escaping (Result) -> Void) -> URLSessionTask?` to the Combine API - /// - Parameters: - /// - endpoint: The endpoint - /// - Returns: On success instance of the required type, otherwise error. - func publisher(response endpoint: EP) -> AnyPublisher { - Publishers.Endpoint { completion in - self.call(response: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } - - func publisher(download endpoint: Endpoint) -> AnyPublisher { - Publishers.Endpoint { completion in - self.download(endpoint: endpoint, completion: completion) - } - .eraseToAnyPublisher() - } -} - -#endif diff --git a/Sources/FTAPIKit/URLServer+Call.swift b/Sources/FTAPIKit/URLServer+Call.swift deleted file mode 100644 index 006aa0d..0000000 --- a/Sources/FTAPIKit/URLServer+Call.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation - -#if os(Linux) -import FoundationNetworking -#endif - -public extension URLServer { - - /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success void, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(request: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that should not be parsed by the decoder of the - /// server. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success plain data, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(data endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(data: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder of the instance - /// conforming to ``Server`` in the HTTP response. - /// - Parameters: - /// - endpoint: The endpoint - /// - completion: On success instance of the required type, otherwise error. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func call(response endpoint: EP, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return call(response: request, file: uploadFile(endpoint: endpoint), completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil - } - } - - private func call(request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } - return .success(()) - }, completion: completion) - } - - private func call(data request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let data = data { - return .success(data) - } - return .failure(.unhandled) - }, completion: completion) - } - - private func call(response request: URLRequest, file: URL?, completion: @escaping (Result) -> Void) -> URLSessionTask? { - task(request: request, file: file, process: { data, response, error in - if let error = ErrorType(data: data, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let data = data { - do { - let response: R = try self.decoding.decode(data: data) - return .success(response) - } catch { - return self.apiError(error: error) - } - } - return .failure(.unhandled) - }, completion: completion) - } -} diff --git a/Tests/FTAPIKitTests/CombineTests.swift b/Tests/FTAPIKitTests/CombineTests.swift deleted file mode 100644 index 9957c94..0000000 --- a/Tests/FTAPIKitTests/CombineTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -#if canImport(Combine) - -import Combine -import XCTest - -/// There is a guard in each test to check whether Combine is available. -/// If the whole class is marked with `@available` we get segfaults, -/// because the test runner is still trying to execute unavailable test. -/// Last Xcode version where this was checked is 11.5. -final class CombineTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - private var cancellable: AnyObject? - - override func tearDown() { - super.tearDown() - cancellable = nil - } - - func testEmptyResult() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = NoContentEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(endpoint: endpoint) - .assertNoFailure() - .sink { expectation.fulfill() } - - wait(for: [expectation], timeout: timeout) - } - - func testDataPublisher() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .assertNoFailure() - .sink { data in - XCTAssert(!data.isEmpty) - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONResponse() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = JSONResponseEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .assertNoFailure() - .sink { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - } - - func testClientError() { - guard #available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) else { - return - } - - let server = HTTPBinServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - - cancellable = server.publisher(data: endpoint) - .sink(receiveCompletion: { completion in - switch completion { - case .failure(.client): - XCTAssert(true) - default: - XCTFail("404 endpoint must return client error") - } - expectation.fulfill() - }, receiveValue: { _ in - XCTFail("404 endpoint must return error") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: timeout) - } -} - -#endif diff --git a/Tests/FTAPIKitTests/ResponseTests.swift b/Tests/FTAPIKitTests/ResponseTests.swift deleted file mode 100644 index c974cad..0000000 --- a/Tests/FTAPIKitTests/ResponseTests.swift +++ /dev/null @@ -1,240 +0,0 @@ -import FTAPIKit -import XCTest - -final class ResponseTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - - func testGet() { - let server = HTTPBinServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testClientError() { - let server = HTTPBinServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("404 endpoint must return error") - case .failure(.client): - XCTAssert(true) - case .failure: - XCTFail("404 endpoint must return client error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testServerError() { - let server = HTTPBinServer() - let endpoint = ServerErrorEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("500 endpoint must return error") - case .failure(.server): - XCTAssert(true) - case .failure: - XCTFail("500 endpoint must return server error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testConnectionError() { - let server = NonExistingServer() - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - switch result { - case .success: - XCTFail("Non-existing domain must fail") - case .failure(.connection): - XCTAssert(true) - case .failure: - XCTFail("Non-existing domain must throw connection error") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testEmptyResult() { - let server = HTTPBinServer() - let endpoint = NoContentEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testCustomError() { - let server = ErrorThrowingServer() - let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case .success = result { - XCTFail("Custom error must be returned") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONResponse() { - let server = HTTPBinServer() - let endpoint = JSONResponseEndpoint() - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testValidJSONRequestResponse() { - let server = HTTPBinServer() - let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) - let endpoint = UpdateUserEndpoint(request: user) - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - switch result { - case .success(let response): - XCTAssertEqual(user, response.json) - case .failure(let error): - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testInvalidJSONRequestResponse() { - let server = HTTPBinServer() - let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) - let endpoint = FailingUpdateUserEndpoint(request: user) - let expectation = self.expectation(description: "Result") - server.call(response: endpoint) { result in - if case .success = result { - XCTFail("Received valid value, decoding must fail") - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - func testAuthorization() { - let server = HTTPBinServer() - let endpoint = AuthorizedEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - -#if !os(Linux) - func testMultipartData() { - let server = HTTPBinServer() - let file = File() - XCTAssertNoThrow(try file.write()) - do { - let endpoint = try TestMultipartEndpoint(file: file) - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } catch { - let errorFunc = { throw error } - XCTAssertNoThrow(errorFunc) - } - } -#endif - - func testURLEncodedEndpoint() { - let server = HTTPBinServer() - let endpoint = TestURLEncodedEndpoint() - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - -#if !os(Linux) - func testUploadTask() { - let server = HTTPBinServer() - let file = File() - XCTAssertNoThrow(try file.write()) - let endpoint = TestUploadEndpoint(file: file) - let expectation = self.expectation(description: "Result") - server.call(data: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } -#endif - - func testDownloadTask() { - let server = HTTPBinServer() - let endpoint = ImageEndpoint() - let expectation = self.expectation(description: "Result") - server.download(endpoint: endpoint) { result in - if case let .failure(error) = result { - XCTFail(error.localizedDescription) - } - expectation.fulfill() - } - wait(for: [expectation], timeout: timeout) - } - - static let allTests = [ - ("testGet", testGet), - ("testClientError", testClientError), - ("testServerError", testServerError), - ("testConnectionError", testConnectionError), - ("testEmptyResult", testEmptyResult), - ("testCustomError", testCustomError), - ("testValidJSONResponse", testValidJSONResponse), - ("testValidJSONRequestResponse", testValidJSONRequestResponse), - ("testInvalidJSONRequestResponse", testInvalidJSONRequestResponse), - ("testAuthorization", testAuthorization), - // Test disabled due to issues with endpoints on Linux https://github.com/futuredapp/FTAPIKit/issues/79 - // ("testMultipartData", testMultipartData), - ("testURLEncodedEndpoint", testURLEncodedEndpoint), - // Test disabled due to issues with endpoints on Linux https://github.com/futuredapp/FTAPIKit/issues/79 - // ("testUploadTask", testUploadTask), - ("testDownloadTask", testDownloadTask) - ] -} From c73db4798e292f4661c20624a73539dd6ba5e107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 21:16:52 +0100 Subject: [PATCH 08/30] refactor: Implement native async/await execution using URLSession APIs Replace wrapper-based async implementation with native URLSession async/await APIs. Remove URLServer+Task.swift helper file and inline necessary logic. Remove redundant Swift version guards and @available attributes since Swift 6.1 is now required. Changes: - Use urlSession.data(for:), urlSession.upload(for:fromFile:), and urlSession.download(for:) directly - Inline buildRequest(endpoint:) and upload file detection - Remove #if swift(>=5.5.2) guards - Remove @available(macOS 10.15, iOS 13, ...) attributes - Delete URLServer+Task.swift (trivial wrappers no longer needed) - Update test mock signatures to async throws --- Sources/FTAPIKit/URLServer+Async.swift | 116 +++++++++---------- Sources/FTAPIKit/URLServer+Download.swift | 39 +++---- Sources/FTAPIKit/URLServer+Task.swift | 130 ---------------------- Tests/FTAPIKitTests/AsyncTests.swift | 3 +- Tests/FTAPIKitTests/Mockups/Servers.swift | 2 +- 5 files changed, 75 insertions(+), 215 deletions(-) delete mode 100644 Sources/FTAPIKit/URLServer+Task.swift diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index fd43fd5..34d2c3f 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -3,83 +3,87 @@ import Foundation import FoundationNetworking #endif -// Support of async-await for Xcode 13.2+. -#if swift(>=5.5.2) -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) public extension URLServer { /// Performs call to endpoint which does not return any data in the HTTP response. - /// - Note: This call maps ``call(endpoint:completion:)`` to the async/await API /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure + /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: Void on success func call(endpoint: Endpoint) async throws { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(endpoint: endpoint) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } onCancel: { [task] in - task?.cancel() + let urlRequest = try await buildRequest(endpoint: endpoint) + + #if !os(Linux) + let file = (endpoint as? UploadEndpoint)?.file + #else + let file: URL? = nil + #endif + + let (data, response): (Data, URLResponse) + if let file = file { + (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) + } else { + (data, response) = try await urlSession.data(for: urlRequest) + } + + if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { + throw error } } - /// Performs call to endpoint which returns an arbitrary data in the HTTP response, that should not be parsed by the decoder of the - /// server. - /// - Note: This call maps ``call(data:completion:)`` to the async/await API + /// Performs call to endpoint which returns arbitrary data in the HTTP response, that should not be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure + /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: Plain data returned with the HTTP Response func call(data endpoint: Endpoint) async throws -> Data { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(data: endpoint) { result in - switch result { - case .success(let data): - continuation.resume(returning: data) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } onCancel: { [task] in - task?.cancel() + let urlRequest = try await buildRequest(endpoint: endpoint) + + #if !os(Linux) + let file = (endpoint as? UploadEndpoint)?.file + #else + let file: URL? = nil + #endif + + let (data, response): (Data, URLResponse) + if let file = file { + (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) + } else { + (data, response) = try await urlSession.data(for: urlRequest) + } + + if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { + throw error } + + return data } - /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder of the instance - /// conforming to ``Server`` in the HTTP response. - /// - Note: This call maps ``call(response:completion:)`` to the async/await API + /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint - /// - Throws: Throws in case that result is .failure + /// - Throws: Throws an APIError if the request fails, server returns an error, or decoding fails /// - Returns: Instance of the required type func call(response endpoint: EP) async throws -> EP.Response { - var task: URLSessionTask? - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - task = call(response: endpoint) { result in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } onCancel: { [task] in - task?.cancel() + let urlRequest = try await buildRequest(endpoint: endpoint) + + #if !os(Linux) + let file = (endpoint as? UploadEndpoint)?.file + #else + let file: URL? = nil + #endif + + let (data, response): (Data, URLResponse) + if let file = file { + (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) + } else { + (data, response) = try await urlSession.data(for: urlRequest) } + + if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { + throw error + } + + return try decoding.decode(data: data) } } -#endif diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift index 2e168c4..1b68e63 100644 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ b/Sources/FTAPIKit/URLServer+Download.swift @@ -6,35 +6,22 @@ import FoundationNetworking public extension URLServer { - /// Creates an `URLSession` download task that call the specified endpoint, saves the result into a file and calls - /// the handler. + /// Downloads a file from the specified endpoint to a temporary location. /// - Parameters: /// - endpoint: The endpoint - /// - completion: On success, the location of a temporary file where the server’s response is stored. - /// You must move this file or open it for reading before your completion handler returns. Otherwise, the file - /// is deleted, and the data is lost. Error otherwise. - /// - Returns: The `URLSessionTask` representing this call. You can discard it, or keep it in case you want - /// to abort the task before it's finished. - @discardableResult - func download(endpoint: Endpoint, completion: @escaping (Result) -> Void) -> URLSessionTask? { - switch request(endpoint: endpoint) { - case .success(let request): - return download(request: request, completion: completion) - case .failure(let error): - completion(.failure(error)) - return nil + /// - Throws: Throws an APIError if the request fails or server returns an error + /// - Returns: The location of a temporary file where the server's response is stored. + /// You must move this file or open it for reading before the async function returns. Otherwise, the file + /// is deleted, and the data is lost. + func download(endpoint: Endpoint) async throws -> URL { + let urlRequest = try await buildRequest(endpoint: endpoint) + let (localURL, response) = try await urlSession.download(for: urlRequest) + + let urlData = localURL.absoluteString.data(using: .utf8) + if let error = ErrorType(data: urlData, response: response, error: nil, decoding: decoding) { + throw error } - } - private func download(request: URLRequest, completion: @escaping (Result) -> Void) -> URLSessionTask? { - downloadTask(request: request, process: { url, response, error in - let urlData = (url?.absoluteString.utf8).flatMap { Data($0) } - if let error = ErrorType(data: urlData, response: response, error: error, decoding: self.decoding) { - return .failure(error) - } else if let url = url { - return .success(url) - } - return .failure(.unhandled) - }, completion: completion) + return localURL } } diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift deleted file mode 100644 index fcfbcd6..0000000 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation - -#if os(Linux) -import FoundationNetworking -#endif - -extension URLServer { - func task( - request: URLRequest, - file: URL?, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDataTask? { - if let file = file { - return uploadTask(request: request, file: file, process: process, completion: completion) - } - return dataTask(request: request, process: process, completion: completion) - } - - private func dataTask( - request: URLRequest, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDataTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.dataTask(with: request) { data, response, error in - tokens.forEach { $0.didReceiveResponse(response, data) } - - let result = process(data, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - private func uploadTask( - request: URLRequest, - file: URL, - process: @escaping (Data?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionUploadTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - tokens.forEach { $0.didReceiveResponse(response, data) } - - let result = process(data, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - func downloadTask( - request: URLRequest, - process: @escaping (URL?, URLResponse?, Error?) -> Result, - completion: @escaping (Result) -> Void - ) -> URLSessionDownloadTask? { - let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } - - let task = urlSession.downloadTask(with: request) { url, response, error in - tokens.forEach { $0.didReceiveResponse(response, nil) } - - let result = process(url, response, error) - - if case let .failure(apiError) = result { - tokens.forEach { $0.didFail(apiError) } - } - - completion(result) - } - task.resume() - return task - } - - func request(endpoint: Endpoint) -> Result { - do { - let request = try buildRequest(endpoint: endpoint) - return .success(request) - } catch { - return apiError(error: error) - } - } - - func uploadFile(endpoint: Endpoint) -> URL? { - #if !os(Linux) - if let endpoint = endpoint as? UploadEndpoint { - return endpoint.file - } - #endif - return nil - } - - func apiError(error: Error?) -> Result { - let error = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? .unhandled - return .failure(error) - } -} - -// This hides the specific 'Context' type inside closures. -private struct RequestToken: Sendable { - let didReceiveResponse: @Sendable (URLResponse?, Data?) -> Void - let didFail: @Sendable (Error) -> Void - - // The generic 'T' captures the specific observer type and its associated Context - init(observer: T, request: URLRequest) { - // We generate the context immediately upon initialization - let context = observer.willSendRequest(request) - - // We capture the specific 'observer' and 'context' inside these closures - self.didReceiveResponse = { [weak observer] response, data in - observer?.didReceiveResponse(for: request, response: response, data: data, context: context) - } - - self.didFail = { [weak observer] error in - observer?.didFail(request: request, error: error, context: context) - } - } -} diff --git a/Tests/FTAPIKitTests/AsyncTests.swift b/Tests/FTAPIKitTests/AsyncTests.swift index e4b6e1d..4154744 100644 --- a/Tests/FTAPIKitTests/AsyncTests.swift +++ b/Tests/FTAPIKitTests/AsyncTests.swift @@ -1,8 +1,7 @@ -#if swift(>=5.5.2) && !os(Linux) +#if !os(Linux) import Foundation import XCTest -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) final class AsyncTests: XCTestCase { func testCallWithoutResponse() async throws { let server = HTTPBinServer() diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index b1fdc7c..9804a59 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -9,7 +9,7 @@ struct HTTPBinServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! - func buildRequest(endpoint: Endpoint) throws -> URLRequest { + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { var request = try buildStandardRequest(endpoint: endpoint) if endpoint is AuthorizedEndpoint { request.addValue("Bearer \(UUID().uuidString)", forHTTPHeaderField: "Authorization") From 73649742813cf67fbe5f79773066879b5438ed9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 21:17:59 +0100 Subject: [PATCH 09/30] refactor: Make buildRequest async throws in protocol Update Server and URLServer protocols to declare buildRequest as async throws, enabling async operations like token refresh, dynamic configuration, and rate limiting. This addresses GitHub issue #105. --- Sources/FTAPIKit/Server.swift | 2 +- Sources/FTAPIKit/URLServer.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/FTAPIKit/Server.swift b/Sources/FTAPIKit/Server.swift index 7c2518a..a7bc06b 100644 --- a/Sources/FTAPIKit/Server.swift +++ b/Sources/FTAPIKit/Server.swift @@ -26,5 +26,5 @@ public protocol Server { /// the request may be delayed before the valid tokens are received. /// - Parameter endpoint: An instance of an endpoint representing a call. /// - Returns: A valid request. - func buildRequest(endpoint: Endpoint) throws -> Request + func buildRequest(endpoint: Endpoint) async throws -> Request } diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index 4279bc8..aaf4c74 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -15,7 +15,7 @@ import FoundationNetworking /// /// It provides a default implementation for /// `var decoding: Decoding`, `var encoding: Encoding` and -/// `func buildRequest(endpoint: Endpoint) throws -> URLRequest`. The `URLRequest` +/// `func buildRequest(endpoint: Endpoint) async throws -> URLRequest`. The `URLRequest` /// creation is implemented in `struct URLRequestBuilder`. /// /// In case that the requests need to cooperate with other services, like OAuth, override the default implementation @@ -56,7 +56,7 @@ public extension URLServer { var encoding: Encoding { JSONEncoding() } var networkObservers: [any NetworkObserver] { [] } - func buildRequest(endpoint: Endpoint) throws -> URLRequest { + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) } } From 1d8be126530d3dd1205db3d665b627a184701d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 21:18:04 +0100 Subject: [PATCH 10/30] test: Add async buildRequest tests demonstrating token refresh and dynamic headers Add comprehensive tests showcasing async buildRequest functionality (addressing GitHub issue #105). Tests demonstrate: - Dynamic header fetching with async configuration - Token refresh pattern before request building These tests serve as documentation for the async buildRequest feature. --- .../AsyncBuildRequestTests.swift | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 Tests/FTAPIKitTests/AsyncBuildRequestTests.swift diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift new file mode 100644 index 0000000..fdf6be8 --- /dev/null +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -0,0 +1,117 @@ +import XCTest +#if os(Linux) +import FoundationNetworking +#endif +@testable import FTAPIKit + +/// Tests demonstrating async buildRequest functionality addressing GitHub issue #105 +final class AsyncBuildRequestTests: XCTestCase { + + func testAsyncBuildRequestWithDynamicHeaders() async throws { + // Given: A server with async buildRequest that fetches dynamic configuration + let server = DynamicHeaderServer() + + // When: Making a call to an endpoint + let data = try await server.call(data: GetEndpoint()) + + // Then: The request should have included the dynamically fetched headers + // Decode the response to verify headers were included + let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) + XCTAssertEqual(response.headers["X-App-Version"], "2.0.0") + XCTAssertEqual(response.headers["X-Device-Id"], "test-device-123") + } + + func testAsyncBuildRequestWithTokenRefresh() async throws { + // Given: A server with async buildRequest that refreshes tokens + let tokenManager = MockTokenManager() + let server = TokenRefreshServer(tokenManager: tokenManager) + + // When: Making a call (with expired token) + tokenManager.currentToken = "expired-token" + let data = try await server.call(data: GetEndpoint()) + + // Then: The request should have used the refreshed token + let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) + XCTAssertEqual(response.headers["Authorization"], "Bearer refreshed-token-456") + XCTAssertTrue(tokenManager.refreshCalled) + } + + static let allTests = [ + ("testAsyncBuildRequestWithDynamicHeaders", testAsyncBuildRequestWithDynamicHeaders), + ("testAsyncBuildRequestWithTokenRefresh", testAsyncBuildRequestWithTokenRefresh) + ] +} + +// MARK: - Mock Servers + +/// Server that fetches configuration asynchronously before building requests +private struct DynamicHeaderServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + // Simulate async configuration fetch + let config = await fetchConfiguration() + + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue(config.appVersion, forHTTPHeaderField: "X-App-Version") + request.addValue(config.deviceId, forHTTPHeaderField: "X-Device-Id") + return request + } + + private func fetchConfiguration() async -> AppConfiguration { + // Simulate network delay + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + return AppConfiguration(appVersion: "2.0.0", deviceId: "test-device-123") + } +} + +/// Server that refreshes authentication tokens before building requests +private struct TokenRefreshServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + let tokenManager: MockTokenManager + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + // Refresh token if needed + await tokenManager.refreshIfNeeded() + + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(tokenManager.currentToken)", forHTTPHeaderField: "Authorization") + return request + } +} + +// MARK: - Mock Models + +private struct AppConfiguration { + let appVersion: String + let deviceId: String +} + +private class MockTokenManager { + var currentToken: String = "initial-token" + var refreshCalled = false + + func refreshIfNeeded() async { + // Simulate token refresh + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + refreshCalled = true + currentToken = "refreshed-token-456" + } +} + +private struct HTTPBinResponse: Decodable, Sendable { + let headers: [String: String] + + private enum CodingKeys: String, CodingKey { + case headers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // HTTPBin returns headers with various casings, normalize to our expected keys + let rawHeaders = try container.decode([String: String].self, forKey: .headers) + self.headers = rawHeaders + } +} From 8f4142cbfecb85bd6eeeb35cfefbb9ba035f52d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 18 Nov 2025 21:18:09 +0100 Subject: [PATCH 11/30] docs: Update to version 2.0.0 with async-only API documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update package version to 2.0.0 and comprehensively document the async-only API changes. Changes: - Bump version to 2.0.0 in podspec - Remove Combine weak_frameworks reference - Add comprehensive migration guide in README - Document async buildRequest examples and use cases - Add CLAUDE.md with architecture documentation - Update project description to emphasize Swift Concurrency Migration guide covers: - Completion handlers → async/await - Combine → async/await with Task - buildRequest signature changes - Sendable requirements --- .claude/settings.local.json | 20 ++++ CLAUDE.md | 188 ++++++++++++++++++++++++++++++++++++ FTAPIKit.podspec | 9 +- README.md | 151 ++++++++++++++++++++++++++--- 4 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6b9f237 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(swiftlint lint:*)", + "Bash(swift test:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:github.com)", + "Bash(swift build:*)", + "Bash(xcode-select:*)", + "Bash(xcrun swift:*)", + "Bash(swiftlint:*)", + "Bash(git checkout:*)", + "Bash(git reset:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad6c4b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,188 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +FTAPIKit is a declarative async/await REST API framework for Swift using Swift Concurrency and Codable. It provides a protocol-oriented approach to defining web services with standard implementation using URLSession and JSON encoder/decoder. The framework is built for Swift 6.1 with full concurrency safety. + +**Key Features:** +- Declarative async/await API for defining web services +- Protocol-oriented design with `Server` and `Endpoint` protocols +- Multiple endpoint types for different use cases (GET, POST, multipart uploads, etc.) +- Async buildRequest enabling token refresh, dynamic configuration, and rate limiting +- Swift 6 concurrency safety with Sendable requirements +- Built-in support for FTNetworkTracer for request logging and tracking +- Cross-platform support: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+, and Linux + +## Build and Test Commands + +### Building +```bash +swift build +``` + +### Running Tests +```bash +# Run all tests +swift test + +# For CocoaPods validation +gem install bundler +bundle install --jobs 4 --retry 3 +bundle exec pod lib lint --allow-warnings +``` + +### Linting +```bash +# Run SwiftLint with strict mode +swiftlint --strict +``` + +The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with many opt-in rules enabled. Linting must pass with `--strict` flag for CI to succeed. + +## Architecture + +### Core Protocol Design + +The framework is built around two core protocols that mirror physical infrastructure: + +1. **`Server` Protocol** - Represents a single web service + - Defines encoding/decoding strategies + - Builds requests from endpoints + - Standard implementation: `URLServer` (uses Foundation's URLSession) + +2. **`Endpoint` Protocol** - Represents access points for resources + - Defines path, headers, query parameters, and HTTP method + - Multiple specialized variants for different use cases + +### Endpoint Type Hierarchy + +The framework provides several endpoint protocol variants: + +- **`Endpoint`** - Base protocol with empty body (typically for GET requests) +- **`DataEndpoint`** - Sends raw data in body +- **`UploadEndpoint`** - Uploads files using InputStream (not available on Linux) +- **`MultipartEndpoint`** - Combines body parts into multipart request (not available on Linux) +- **`URLEncodedEndpoint`** - Body in URL query format +- **`RequestEndpoint`** - Has encodable request model (defaults to POST) +- **`ResponseEndpoint`** - Has decodable response model +- **`RequestResponseEndpoint`** - Typealias combining request and response endpoints + +### Key Architectural Patterns + +**Protocol-Oriented Design**: Endpoints are designed to be implemented as structs (not enums or classes). This provides: +- Generated initializers +- Better long-term sustainability (endpoint info stays localized) +- No memory overhead for instant usage after creation + +**Swift 6 Concurrency Safety**: All `ResponseEndpoint` associated types must conform to `Sendable`: +- Response models must be `Sendable` for thread-safe async/await usage +- Compiler enforces this at endpoint definition, providing clear error messages +- Breaking change from pre-6.0 versions but ensures concurrency correctness + +**Encoding/Decoding Abstraction**: The `Encoding` and `Decoding` protocols provide type-erased wrappers around Swift's `Codable` system: +- `JSONEncoding` / `JSONDecoding` for JSON with customizable encoders/decoders +- `URLRequestEncoding` extends encoding to configure URLRequest headers + +**Async Request Building**: The request building flow is fully asynchronous (addressing GitHub issue #105): +1. `Server.buildRequest(endpoint:)` is declared as `async throws` +2. Can be overridden to perform async operations (token refresh, config fetch, rate limiting) +3. Default implementation calls synchronous `buildStandardRequest(endpoint:)` helper +4. Enables powerful use cases like awaiting token managers or fetching dynamic headers +5. Specialized handling for multipart, upload, and encoded endpoints + +### Module Organization + +**Source Structure** (`Sources/FTAPIKit/`): +- Core protocols: `Server.swift`, `Endpoint.swift`, `URLServer.swift` +- Request building: `URLRequestBuilder.swift` +- Async execution: `URLServer+Async.swift`, `URLServer+Download.swift` +- Internal helpers: `URLServer+Task.swift` (provides async request building and error helpers) +- Utilities: `Coding.swift`, `URLQuery.swift`, `MultipartFormData.swift`, etc. +- Error handling: `APIError.swift`, `APIError+Standard.swift` + +**Test Structure** (`Tests/FTAPIKitTests/`): +- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift` +- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift` + +### Call Execution Pattern + +The framework uses async/await exclusively: + +```swift +// Basic call +let response = try await server.call(response: endpoint) + +// Data call (raw Data response) +let data = try await server.call(data: endpoint) + +// Void call (no response body) +try await server.call(endpoint: endpoint) + +// Download call +let fileURL = try await server.download(endpoint: endpoint) +``` + +**Cancellation**: Use Task cancellation for aborting requests: +```swift +let task = Task { + try await server.call(response: endpoint) +} +task.cancel() // Cancels the request +``` + +**Breaking Change from 1.x**: Completion handlers and Combine support were removed in 2.0. All API calls use async/await. + +### Error Handling + +- `APIError` protocol defines error handling interface +- Default implementation: `APIError.Standard` +- Custom error types can be defined via `URLServer.ErrorType` associated type +- Errors initialized from: `Data?`, `URLResponse?`, `Error?`, and `Decoding` + +### Network Tracing + +The framework integrates with `FTNetworkTracer` for request logging: +- `URLServer.networkTracer` property (optional, defaults to nil) +- Dependency: `https://github.com/futuredapp/FTNetworkTracer` + +## Package Management + +The project supports both **Swift Package Manager** and **CocoaPods**: + +- **SPM**: See `Package.swift` +- **CocoaPods**: See `FTAPIKit.podspec` and `Gemfile` + +### Platform Support + +Minimum deployment targets: +- iOS 15+ +- macOS 12+ +- tvOS 15+ +- watchOS 8+ +- Linux (with FoundationNetworking, limited endpoint types) + +Note: `UploadEndpoint` and `MultipartEndpoint` are not available on Linux. + +## Testing Approach + +Tests use mock servers (HTTPBin-based) defined in `Tests/FTAPIKitTests/Mockups/Servers.swift`: +- `HTTPBinServer` - Standard test server with async authorization support +- `NonExistingServer` - For testing error conditions +- `ErrorThrowingServer` - Custom error type testing + +**Test Files:** +- `AsyncTests.swift` - Tests for basic async/await functionality +- `AsyncBuildRequestTests.swift` - Demonstrates async buildRequest use cases (token refresh, dynamic headers) +- `URLQueryTests.swift` - Tests for URL query parameter handling + +Mock endpoints demonstrate all endpoint types and are reusable across test suites. All tests use async/await patterns. + +## CI/CD + +GitHub Actions workflows run on: +- macOS 14 (Xcode 16.2) +- Ubuntu Latest + +All workflows run: `swiftlint --strict`, `pod lib lint --allow-warnings`, `swift build`, `swift test` diff --git a/FTAPIKit.podspec b/FTAPIKit.podspec index ded55c2..f630573 100644 --- a/FTAPIKit.podspec +++ b/FTAPIKit.podspec @@ -1,12 +1,12 @@ Pod::Spec.new do |s| s.name = "FTAPIKit" - s.version = "1.5.0" - s.summary = "Declarative, generic and protocol-oriented REST API framework using URLSession and Codable" + s.version = "2.0.0" + s.summary = "Declarative, async/await REST API framework using URLSession and Codable" s.description = <<-DESC - Protocol-oriented framework for communication with REST APIs. + Protocol-oriented async/await framework for communication with REST APIs. Endpoint protocols describe the API resource access points and the requests/responses codable types. Server protocol describes web services - and enables the user to call endoints in a type-safe manner. + and enables the user to call endpoints in a type-safe manner using Swift concurrency. DESC s.homepage = "https://github.com/futuredapp/FTAPIKit" s.license = { type: "MIT", file: "LICENSE" } @@ -18,7 +18,6 @@ Pod::Spec.new do |s| s.exclude_files = "Sources/FTAPIKit/Documentation.docc/**/*" s.frameworks = ["Foundation"] - s.weak_frameworks = ["Combine"] s.swift_version = "6.1" s.ios.deployment_target = "15.0" diff --git a/README.md b/README.md index 25e1998..4523e6a 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,27 @@ ![macOS 14](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-14.yml/badge.svg?branch=main) ![Ubuntu](https://github.com/futuredapp/FTAPIKit/actions/workflows/ubuntu-latest.yml/badge.svg?branch=main) -Declarative and generic REST API framework using Codable. -With standard implementation using URLSesssion and JSON encoder/decoder. -Easily extensible for your asynchronous framework or networking stack. +Declarative async/await REST API framework using Swift Concurrency and Codable. +With standard implementation using URLSession and JSON encoder/decoder. +Built for Swift 6.1 with full concurrency safety. + +## Requirements + +- Swift 6.1+ +- iOS 15+, macOS 12+, tvOS 15+, watchOS 8+, or Linux ## Installation -When using Swift package manager install using Xcode 11+ -or add following line to your dependencies: +When using Swift Package Manager install using Xcode or add the following line to your dependencies: ```swift -.package(url: "https://github.com/futuredapp/FTAPIKit.git", from: "1.5.0") +.package(url: "https://github.com/futuredapp/FTAPIKit.git", from: "2.0.0") ``` When using CocoaPods add following line to your `Podfile`: ```ruby -pod 'FTAPIKit', '~> 1.5' +pod 'FTAPIKit', '~> 2.0' ``` ## Features @@ -96,7 +100,7 @@ authorization we can override default request building mechanism. ```swift struct HTTPBinServer: URLServer { ... - func buildRequest(endpoint: Endpoint) throws -> URLRequest { + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { var request = try buildStandardRequest(endpoint: endpoint) request.addValue("MyApp/1.0.0", forHTTPHeaderField: "User-Agent") return request @@ -129,19 +133,142 @@ struct UpdateUserEndpoint: RequestResponseEndpoint { ### Executing the request -When we have server and enpoint defined we can call the web service: +When we have server and endpoint defined we can call the web service using async/await: ```swift let server = HTTPBinServer() let endpoint = UpdateUserEndpoint(request: user) + +Task { + do { + let updatedUser = try await server.call(response: endpoint) + // Handle success + } catch { + // Handle error + } +} +``` + +### Async buildRequest + +One of the key features in FTAPIKit 2.0 is the ability to use async operations in `buildRequest`. This enables use cases like: + +- **Token Refresh**: Await token refresh before building the request +- **Dynamic Configuration**: Fetch configuration or headers asynchronously +- **Rate Limiting**: Implement delays or throttling + +Example with async token refresh: + +```swift +struct MyServer: URLServer { + let baseUri = URL(string: "https://api.example.com")! + let tokenManager: TokenManager + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + // Refresh token if needed + await tokenManager.refreshIfNeeded() + + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(tokenManager.token)", forHTTPHeaderField: "Authorization") + return request + } +} +``` + +## Migrating from 1.x to 2.0 + +FTAPIKit 2.0 is a major rewrite focused on Swift Concurrency. Here are the breaking changes: + +### Completion Handlers Removed + +**Old (1.x):** +```swift server.call(response: endpoint) { result in switch result { - case .success(let updatedUser): - ... + case .success(let response): + print(response) case .failure(let error): - ... + print(error) + } +} +``` + +**New (2.0):** +```swift +Task { + do { + let response = try await server.call(response: endpoint) + print(response) + } catch { + print(error) + } +} +``` + +### Combine Removed + +Combine support has been removed in favor of async/await, which provides better performance and cleaner code. + +**Old (1.x) - Combine:** +```swift +server.publisher(response: endpoint) + .sink( + receiveCompletion: { completion in + // Handle completion + }, + receiveValue: { response in + // Handle response + } + ) + .store(in: &cancellables) +``` + +**New (2.0) - Async/Await:** +```swift +let task = Task { + do { + let response = try await server.call(response: endpoint) + // Handle response + } catch { + // Handle error } } + +// Cancel if needed +task.cancel() +``` + +### buildRequest is Now Async + +If you override `buildRequest`, you must mark it as `async`: + +**Old (1.x):** +```swift +func buildRequest(endpoint: Endpoint) throws -> URLRequest { + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request +} +``` + +**New (2.0):** +```swift +func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + var request = try buildStandardRequest(endpoint: endpoint) + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + return request +} +``` + +### Response Types Must Be Sendable + +All `ResponseEndpoint` response types must conform to `Sendable` for Swift 6 concurrency safety: + +```swift +struct User: Codable, Sendable { // Add Sendable conformance + let id: Int + let name: String +} ``` ## Contributors From 7b363fd294a4607d9ce5447b834ce398f1de5936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Sat, 24 Jan 2026 17:14:41 +0100 Subject: [PATCH 12/30] feat: Add RequestConfiguring protocol for per-request configuration - Add RequestConfiguring protocol with async configure method - Add configuring parameter to all call methods in URLServer+Async - Add configuring parameter to download method in URLServer+Download - Add RequestConfiguringTests demonstrating authorization use case This enables use cases like: - Adding authorization headers dynamically - Token refresh before request execution - Per-request rate limiting Co-Authored-By: Claude Opus 4.5 --- Sources/FTAPIKit/RequestConfiguring.swift | 28 ++++ Sources/FTAPIKit/URLServer+Async.swift | 18 ++- Sources/FTAPIKit/URLServer+Download.swift | 7 +- .../RequestConfiguringTests.swift | 143 ++++++++++++++++++ 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 Sources/FTAPIKit/RequestConfiguring.swift create mode 100644 Tests/FTAPIKitTests/RequestConfiguringTests.swift diff --git a/Sources/FTAPIKit/RequestConfiguring.swift b/Sources/FTAPIKit/RequestConfiguring.swift new file mode 100644 index 0000000..53d0299 --- /dev/null +++ b/Sources/FTAPIKit/RequestConfiguring.swift @@ -0,0 +1,28 @@ +import Foundation +#if os(Linux) +import FoundationNetworking +#endif + +/// Protocol for configuring URLRequest before execution. +/// Implementations can perform async operations like token refresh. +/// +/// Use this protocol to add authorization headers, modify requests, or perform +/// any async configuration needed before the request is sent. +/// +/// Example: +/// ```swift +/// struct AuthorizedConfiguration: RequestConfiguring { +/// let authService: AuthService +/// +/// func configure(_ request: inout URLRequest) async throws { +/// let token = try await authService.getValidAccessToken() +/// request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") +/// } +/// } +/// ``` +public protocol RequestConfiguring: Sendable { + /// Configures the request before it is sent. + /// - Parameter request: The URLRequest to configure + /// - Throws: Any error that occurs during configuration (e.g., token refresh failure) + func configure(_ request: inout URLRequest) async throws +} diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index 34d2c3f..0951b55 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -8,10 +8,12 @@ public extension URLServer { /// Performs call to endpoint which does not return any data in the HTTP response. /// - Parameters: /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: Void on success - func call(endpoint: Endpoint) async throws { - let urlRequest = try await buildRequest(endpoint: endpoint) + func call(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) #if !os(Linux) let file = (endpoint as? UploadEndpoint)?.file @@ -34,10 +36,12 @@ public extension URLServer { /// Performs call to endpoint which returns arbitrary data in the HTTP response, that should not be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: Plain data returned with the HTTP Response - func call(data endpoint: Endpoint) async throws -> Data { - let urlRequest = try await buildRequest(endpoint: endpoint) + func call(data endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> Data { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) #if !os(Linux) let file = (endpoint as? UploadEndpoint)?.file @@ -62,10 +66,12 @@ public extension URLServer { /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder. /// - Parameters: /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending /// - Throws: Throws an APIError if the request fails, server returns an error, or decoding fails /// - Returns: Instance of the required type - func call(response endpoint: EP) async throws -> EP.Response { - let urlRequest = try await buildRequest(endpoint: endpoint) + func call(response endpoint: EP, configuring: RequestConfiguring? = nil) async throws -> EP.Response { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) #if !os(Linux) let file = (endpoint as? UploadEndpoint)?.file diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift index 1b68e63..cf8ec3f 100644 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ b/Sources/FTAPIKit/URLServer+Download.swift @@ -9,12 +9,15 @@ public extension URLServer { /// Downloads a file from the specified endpoint to a temporary location. /// - Parameters: /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: The location of a temporary file where the server's response is stored. /// You must move this file or open it for reading before the async function returns. Otherwise, the file /// is deleted, and the data is lost. - func download(endpoint: Endpoint) async throws -> URL { - let urlRequest = try await buildRequest(endpoint: endpoint) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) let (localURL, response) = try await urlSession.download(for: urlRequest) let urlData = localURL.absoluteString.data(using: .utf8) diff --git a/Tests/FTAPIKitTests/RequestConfiguringTests.swift b/Tests/FTAPIKitTests/RequestConfiguringTests.swift new file mode 100644 index 0000000..db09001 --- /dev/null +++ b/Tests/FTAPIKitTests/RequestConfiguringTests.swift @@ -0,0 +1,143 @@ +import XCTest +#if os(Linux) +import FoundationNetworking +#endif +@testable import FTAPIKit + +/// Tests for RequestConfiguring protocol functionality +final class RequestConfiguringTests: XCTestCase { + + func testNilConfigurationMakesNoChanges() async throws { + // Given: A server and endpoint with no configuration + let server = HTTPBinServer() + + // When: Making a call without configuration (nil default) + let data = try await server.call(data: GetEndpoint()) + + // Then: Request should succeed without any modifications + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + XCTAssertNil(response.headers["X-Custom-Header"]) + } + + func testCustomConfigurationModifiesRequest() async throws { + // Given: A custom configuration that adds headers + let config = HeaderAddingConfiguration(headerName: "X-Custom-Header", headerValue: "test-value-123") + let server = HTTPBinServer() + + // When: Making a call with the configuration + let data = try await server.call(data: GetEndpoint(), configuring: config) + + // Then: The request should have the custom header + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + XCTAssertEqual(response.headers["X-Custom-Header"], "test-value-123") + } + + func testConfigurationErrorPropagates() async throws { + // Given: A configuration that throws an error + let config = FailingConfiguration() + let server = HTTPBinServer() + + // When/Then: The error should propagate + do { + _ = try await server.call(data: GetEndpoint(), configuring: config) + XCTFail("Expected error to be thrown") + } catch let error as ConfigurationError { + XCTAssertEqual(error, .tokenRefreshFailed) + } + } + + func testAsyncOperationsInConfigure() async throws { + // Given: A configuration that performs async token refresh + let tokenManager = MockAsyncTokenManager() + let config = AsyncTokenConfiguration(tokenManager: tokenManager) + let server = HTTPBinServer() + + // When: Making a call with the async configuration + let data = try await server.call(data: GetEndpoint(), configuring: config) + + // Then: The async operation should have completed and header should be set + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + XCTAssertEqual(response.headers["Authorization"], "Bearer refreshed-token") + XCTAssertTrue(tokenManager.refreshCalled) + } + + func testResponseEndpointWithConfiguration() async throws { + // Given: A response endpoint with configuration + let config = HeaderAddingConfiguration(headerName: "X-Api-Key", headerValue: "secret-key") + let server = HTTPBinServer() + + // When: Making a call with configuration + let response = try await server.call(response: JSONResponseEndpoint(), configuring: config) + + // Then: Response should be decoded correctly + XCTAssertFalse(response.slideshow.title.isEmpty) + } + + func testVoidEndpointWithConfiguration() async throws { + // Given: An endpoint that returns no content + let config = HeaderAddingConfiguration(headerName: "X-Request-Id", headerValue: "req-123") + let server = HTTPBinServer() + + // When/Then: Call should succeed without throwing + try await server.call(endpoint: NoContentEndpoint(), configuring: config) + } + + static let allTests = [ + ("testNilConfigurationMakesNoChanges", testNilConfigurationMakesNoChanges), + ("testCustomConfigurationModifiesRequest", testCustomConfigurationModifiesRequest), + ("testConfigurationErrorPropagates", testConfigurationErrorPropagates), + ("testAsyncOperationsInConfigure", testAsyncOperationsInConfigure), + ("testResponseEndpointWithConfiguration", testResponseEndpointWithConfiguration), + ("testVoidEndpointWithConfiguration", testVoidEndpointWithConfiguration) + ] +} + +// MARK: - Test Configurations + +/// Simple configuration that adds a header +private struct HeaderAddingConfiguration: RequestConfiguring { + let headerName: String + let headerValue: String + + func configure(_ request: inout URLRequest) async throws { + request.setValue(headerValue, forHTTPHeaderField: headerName) + } +} + +/// Configuration that always fails +private struct FailingConfiguration: RequestConfiguring { + func configure(_ request: inout URLRequest) async throws { + throw ConfigurationError.tokenRefreshFailed + } +} + +/// Configuration with async token refresh +private struct AsyncTokenConfiguration: RequestConfiguring { + let tokenManager: MockAsyncTokenManager + + func configure(_ request: inout URLRequest) async throws { + let token = await tokenManager.getValidToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } +} + +// MARK: - Test Helpers + +private enum ConfigurationError: Error, Equatable { + case tokenRefreshFailed +} + +private class MockAsyncTokenManager: @unchecked Sendable { + var refreshCalled = false + + func getValidToken() async -> String { + // Simulate async token refresh + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + refreshCalled = true + return "refreshed-token" + } +} + +private struct HTTPBinHeadersResponse: Decodable, Sendable { + let headers: [String: String] +} From c41f649ab889e8486d3be8430b448ab6008e95ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 26 Jan 2026 11:06:19 +0100 Subject: [PATCH 13/30] WIP --- README.md | 33 ++++++++++++++ .../FTAPIKitTests/NetworkObserverTests.swift | 44 +++++++++---------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4523e6a..22ca436 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,39 @@ struct MyServer: URLServer { } ``` +### Request Configuration at Call Site + +For scenarios where you need to configure requests at the call site (rather than in the server), +use the `RequestConfiguring` protocol. This is useful for: + +- Adding authorization headers in an API service layer +- Per-request configuration that varies by context +- Keeping server implementations simple and reusable + +```swift +struct AuthorizedConfiguration: RequestConfiguring { + let authService: AuthService + + func configure(_ request: inout URLRequest) async throws { + let token = try await authService.getValidAccessToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } +} + +// Usage - configuration is optional with nil default +let server = HTTPBinServer() +let authConfig = AuthorizedConfiguration(authService: authService) + +// Public endpoint - no configuration needed +let publicData = try await server.call(response: publicEndpoint) + +// Protected endpoint - with configuration +let protectedData = try await server.call(response: protectedEndpoint, configuring: authConfig) +``` + +This pattern keeps the server layer focused on request building while allowing +the API service layer to handle authentication concerns. + ## Migrating from 1.x to 2.0 FTAPIKit 2.0 is a major rewrite focused on Swift Concurrency. Here are the breaking changes: diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift index 734eda1..49ab3b3 100644 --- a/Tests/FTAPIKitTests/NetworkObserverTests.swift +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -6,7 +6,6 @@ import FoundationNetworking #endif final class NetworkObserverTests: XCTestCase { - private let timeout: TimeInterval = 30.0 // MARK: - Unit Tests (no network required) @@ -17,12 +16,12 @@ final class NetworkObserverTests: XCTestCase { XCTAssertEqual(server.networkObservers.count, 1, "NetworkObservers should contain one observer") } - func testEmptyObserversDoesNotCauseIssues() { + func testEmptyObserversDoesNotCauseIssues() async throws { let server = HTTPBinServer() // Default observers is empty array let endpoint = GetEndpoint() // Verify empty observers doesn't cause problems during request building - XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) + _ = try await server.buildRequest(endpoint: endpoint) XCTAssertTrue(server.networkObservers.isEmpty, "Default networkObservers should be empty") } @@ -37,53 +36,43 @@ final class NetworkObserverTests: XCTestCase { // MARK: - Integration Tests (requires network) // Note: These tests may fail if httpbin.org is unavailable - func testObserverReceivesLifecycleCallbacks() { + func testObserverReceivesLifecycleCallbacks() async throws { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Request completed") - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) + _ = try await server.call(data: endpoint) XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") // didReceiveResponse is always called; didFail is called additionally on failure XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") } - func testObserverLogsFailedRequest() { + func testObserverLogsFailedRequest() async { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - server.call(endpoint: endpoint) { _ in - expectation.fulfill() + do { + _ = try await server.call(data: endpoint) + XCTFail("Expected error for 404 endpoint") + } catch { + // Expected error } - wait(for: [expectation], timeout: timeout) - // didReceiveResponse is always called with raw data; didFail is called additionally on failure XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") XCTAssertEqual(mockObserver.didFailCount, 1, "didFail should be called on failure") } - func testMultipleObserversAllReceiveCallbacks() { + func testMultipleObserversAllReceiveCallbacks() async throws { let observer1 = MockNetworkObserver() let observer2 = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) let endpoint = GetEndpoint() - let expectation = self.expectation(description: "Request completed") - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) + _ = try await server.call(data: endpoint) // Both observers should receive callbacks XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") @@ -91,4 +80,13 @@ final class NetworkObserverTests: XCTestCase { XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") } + + static let allTests = [ + ("testObserverIsCalledForRequest", testObserverIsCalledForRequest), + ("testEmptyObserversDoesNotCauseIssues", testEmptyObserversDoesNotCauseIssues), + ("testMultipleObserversSupported", testMultipleObserversSupported), + ("testObserverReceivesLifecycleCallbacks", testObserverReceivesLifecycleCallbacks), + ("testObserverLogsFailedRequest", testObserverLogsFailedRequest), + ("testMultipleObserversAllReceiveCallbacks", testMultipleObserversAllReceiveCallbacks) + ] } From decf6bb545e7b7113c61960a75b67cbaf8ea16bc Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 18:52:26 +0100 Subject: [PATCH 14/30] Update gitignore --- .gitignore | 55 ++++++++++++------------------------------------------ 1 file changed, 12 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 19b2ba4..1dc3bae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,6 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## Build generated build/ DerivedData/ -docs/ - -## Various settings *.pbxuser !default.pbxuser *.mode1v3 @@ -16,60 +9,36 @@ docs/ !default.mode2v3 *.perspectivev3 !default.perspectivev3 -xcuserdata/ - -## Other +**/xcuserdata/ *.moved-aside *.xccheckout *.xcscmblueprint - -## Obj-C/Swift specific *.hmap *.ipa *.dSYM.zip *.dSYM -## Playgrounds +# Playgrounds timeline.xctimeline playground.xcworkspace +# macOS +.DS_Store + # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -Package.resolved -.swiftpm .build/ -# We do not need the project, because it can be generated or the Package.swift can be open in Xcode 11 +.swiftpm/ +Package.resolved FTAPIKit.xcodeproj -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build +# Claude Code +.claude # fastlane -# -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the -# screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - fastlane/report.xml fastlane/Preview.html -fastlane/screenshots -fastlane/test_output +fastlane/screenshots/ +fastlane/test_output/ fastlane/README.md -# Ignore VSCode files -.vscode +*.xcuserstate From 21a6c2ca25e7ced0a9d2c68d005cbb03adc2365e Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 18:53:31 +0100 Subject: [PATCH 15/30] Remove local settings from the project --- .claude/settings.local.json | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 6b9f237..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(swiftlint lint:*)", - "Bash(swift test:*)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:github.com)", - "Bash(swift build:*)", - "Bash(xcode-select:*)", - "Bash(xcrun swift:*)", - "Bash(swiftlint:*)", - "Bash(git checkout:*)", - "Bash(git reset:*)", - "Bash(git add:*)", - "Bash(git commit:*)" - ], - "deny": [], - "ask": [] - } -} From 532762571fdb64c96f4a7a04d58e30f0747c65e3 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 18:56:19 +0100 Subject: [PATCH 16/30] Update workflows --- .github/workflows/{macos-14.yml => ci.yml} | 11 +++----- .github/workflows/macos-15.yml | 30 ---------------------- .github/workflows/ubuntu-latest.yml | 24 ----------------- 3 files changed, 3 insertions(+), 62 deletions(-) rename .github/workflows/{macos-14.yml => ci.yml} (57%) delete mode 100644 .github/workflows/macos-15.yml delete mode 100644 .github/workflows/ubuntu-latest.yml diff --git a/.github/workflows/macos-14.yml b/.github/workflows/ci.yml similarity index 57% rename from .github/workflows/macos-14.yml rename to .github/workflows/ci.yml index 1a62baf..366919d 100644 --- a/.github/workflows/macos-14.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: macOS 14 +name: CI on: push: @@ -10,20 +10,15 @@ on: jobs: test: - runs-on: macos-14 + runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install SwiftLint run: brew install swiftlint - name: Lint run: | swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint --allow-warnings - name: Swift build & test run: | swift build diff --git a/.github/workflows/macos-15.yml b/.github/workflows/macos-15.yml deleted file mode 100644 index d5cc92c..0000000 --- a/.github/workflows/macos-15.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: macOS 15 - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - -jobs: - test: - runs-on: macos-15 - - steps: - - uses: actions/checkout@v4 - - name: Install SwiftLint - run: brew install swiftlint - - name: Lint - run: | - swiftlint --strict - - name: Pod lib lint - run: | - gem install bundler - bundle install --jobs 4 --retry 3 - bundle exec pod lib lint --allow-warnings - - name: Swift build & test - run: | - swift build - swift test diff --git a/.github/workflows/ubuntu-latest.yml b/.github/workflows/ubuntu-latest.yml deleted file mode 100644 index 050552f..0000000 --- a/.github/workflows/ubuntu-latest.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Ubuntu - -on: - push: - branches: - - main - pull_request: - branches: - - '*' - -jobs: - ubuntu-swift-latest: - runs-on: ubuntu-latest - steps: - - name: Print Swift version to confirm - run: swift --version - - - name: Checkout FTAPIKit - uses: actions/checkout@v4 - - - name: Swift build & test - run: | - swift build - swift test From b19367f6ca619a2b96444e44c79a6b4d17846258 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:03:38 +0100 Subject: [PATCH 17/30] Update lint --- .swiftlint.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index c94bd9a..5812aa1 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,19 +1,10 @@ disabled_rules: - line_length - # Combination of SwiftLint 0.43.1 and Xcode 12.5.1 - # warns about false positives for this rule - - empty_xctest_method - # Type inference of `await try` methods - # does not work well in Xcode 13 Beta - - implicit_return # Does not work with URL query literals. - duplicated_key_in_dictionary_literal excluded: - - Pods - .build - - Templates opt_in_rules: - - anyobject_protocol - array_init - attributes - closure_body_length @@ -31,7 +22,6 @@ opt_in_rules: - empty_collection_literal - empty_count - empty_string - - empty_xctest_method - enum_case_associated_values_count - explicit_init - fallthrough @@ -80,11 +70,12 @@ opt_in_rules: - trailing_closure - unneeded_parentheses_in_closure_argument - untyped_error_in_catch - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - yoda_condition +analyzer_rules: + - unused_declaration + - unused_import # Rule configurations identifier_name: From 61e3f56a33c803c85fa66576c7357aebb251d437 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:04:30 +0100 Subject: [PATCH 18/30] Remove support of cocoa pods --- FTAPIKit.podspec | 27 ------------ Gemfile | 5 --- Gemfile.lock | 112 ----------------------------------------------- 3 files changed, 144 deletions(-) delete mode 100644 FTAPIKit.podspec delete mode 100644 Gemfile delete mode 100644 Gemfile.lock diff --git a/FTAPIKit.podspec b/FTAPIKit.podspec deleted file mode 100644 index f630573..0000000 --- a/FTAPIKit.podspec +++ /dev/null @@ -1,27 +0,0 @@ -Pod::Spec.new do |s| - s.name = "FTAPIKit" - s.version = "2.0.0" - s.summary = "Declarative, async/await REST API framework using URLSession and Codable" - s.description = <<-DESC - Protocol-oriented async/await framework for communication with REST APIs. - Endpoint protocols describe the API resource access points - and the requests/responses codable types. Server protocol describes web services - and enables the user to call endpoints in a type-safe manner using Swift concurrency. - DESC - s.homepage = "https://github.com/futuredapp/FTAPIKit" - s.license = { type: "MIT", file: "LICENSE" } - s.author = { "Matěj Kašpar Jirásek": "matej.jirasek@futured.app" } - s.social_media_url = "https://twitter.com/Futuredapps" - - s.source = { git: "https://github.com/futuredapp/FTAPIKit.git", tag: s.version.to_s } - s.source_files = "Sources/FTAPIKit/**/*" - s.exclude_files = "Sources/FTAPIKit/Documentation.docc/**/*" - - s.frameworks = ["Foundation"] - - s.swift_version = "6.1" - s.ios.deployment_target = "15.0" - s.osx.deployment_target = "12.0" - s.watchos.deployment_target = "8.0" - s.tvos.deployment_target = "15.0" -end diff --git a/Gemfile b/Gemfile deleted file mode 100644 index c268956..0000000 --- a/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org" - -gem "cocoapods", "~> 1.14" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index d16c54b..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,112 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.8) - activesupport (7.2.3) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.0.1) - claide (1.1.0) - cocoapods (1.16.2) - addressable (~> 2.8) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.16.2) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 2.1, < 3.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.6.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.8.0) - nap (~> 1.0) - ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.27.0, < 2.0) - cocoapods-core (1.16.2) - activesupport (>= 5.0, < 8) - addressable (~> 2.8) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix (~> 4.0) - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.5) - cocoapods-downloader (2.1) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.1) - cocoapods-trunk (1.6.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) - drb (2.2.3) - escape (0.0.4) - ethon (0.15.0) - ffi (>= 1.15.0) - ffi (1.17.3) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.9.0) - mutex_m - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.18.0) - logger (1.7.0) - minitest (6.0.1) - prism (~> 1.5) - molinillo (0.8.0) - mutex_m (0.3.0) - nanaimo (0.4.0) - nap (1.1.0) - netrc (0.11.0) - prism (1.8.0) - public_suffix (4.0.7) - rexml (3.4.4) - ruby-macho (2.5.1) - securerandom (0.4.1) - typhoeus (1.5.0) - ethon (>= 0.9.0, < 0.16.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - xcodeproj (1.27.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.4.0) - rexml (>= 3.3.6, < 4.0) - -PLATFORMS - ruby - -DEPENDENCIES - cocoapods (~> 1.14) - -BUNDLED WITH - 4.0.4 From 2b6e4c435edcb12c7ab350b74c93b840e7e4d5b3 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:09:05 +0100 Subject: [PATCH 19/30] Remove linux support --- Sources/FTAPIKit/APIError+Standard.swift | 4 -- Sources/FTAPIKit/APIError.swift | 4 -- Sources/FTAPIKit/Coding.swift | 4 -- Sources/FTAPIKit/Endpoint.swift | 2 - Sources/FTAPIKit/NetworkObserver.swift | 4 -- Sources/FTAPIKit/OutputStream+Write.swift | 28 ++------------ Sources/FTAPIKit/RequestConfiguring.swift | 3 -- Sources/FTAPIKit/URL+MIME.swift | 46 +---------------------- Sources/FTAPIKit/URLRequestBuilder.swift | 6 --- Sources/FTAPIKit/URLServer+Async.swift | 7 ---- Sources/FTAPIKit/URLServer+Download.swift | 4 -- 11 files changed, 5 insertions(+), 107 deletions(-) diff --git a/Sources/FTAPIKit/APIError+Standard.swift b/Sources/FTAPIKit/APIError+Standard.swift index 0dd263f..d5584a6 100644 --- a/Sources/FTAPIKit/APIError+Standard.swift +++ b/Sources/FTAPIKit/APIError+Standard.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Standard API error returned when no custom error /// was parsed and the response from server /// was invalid. diff --git a/Sources/FTAPIKit/APIError.swift b/Sources/FTAPIKit/APIError.swift index b71259c..d9f8794 100644 --- a/Sources/FTAPIKit/APIError.swift +++ b/Sources/FTAPIKit/APIError.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Error protocol used in types conforming to ``URLServer`` protocol. Default implementation called ``APIErrorStandard`` /// is provided. A type conforming to ``APIError`` protocol can be provided to ``URLServer`` /// to use custom error handling. diff --git a/Sources/FTAPIKit/Coding.swift b/Sources/FTAPIKit/Coding.swift index 5b61627..ec68918 100644 --- a/Sources/FTAPIKit/Coding.swift +++ b/Sources/FTAPIKit/Coding.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// `Encoding` represents Swift encoders and provides network-specific features, such as configuring /// the request with correct headers. public protocol Encoding { diff --git a/Sources/FTAPIKit/Endpoint.swift b/Sources/FTAPIKit/Endpoint.swift index 5808b9e..652762d 100644 --- a/Sources/FTAPIKit/Endpoint.swift +++ b/Sources/FTAPIKit/Endpoint.swift @@ -39,7 +39,6 @@ public protocol DataEndpoint: Endpoint { var body: Data { get } } -#if !os(Linux) /// ``UploadEndpoint`` will send the provided file to the API. /// /// - Note: If the standard implementation is used, `URLSession.uploadTask` methods will be used. @@ -58,7 +57,6 @@ public protocol MultipartEndpoint: Endpoint { /// List of individual body parts. var parts: [MultipartBodyPart] { get } } -#endif /// The body of the endpoint with the URL query format. public protocol URLEncodedEndpoint: Endpoint { diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift index 7de99e8..9a0b69f 100644 --- a/Sources/FTAPIKit/NetworkObserver.swift +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - /// Protocol for observing network request lifecycle events. /// /// Implement this protocol to add logging, analytics, or request tracking. diff --git a/Sources/FTAPIKit/OutputStream+Write.swift b/Sources/FTAPIKit/OutputStream+Write.swift index 6d5948b..12f8a6d 100644 --- a/Sources/FTAPIKit/OutputStream+Write.swift +++ b/Sources/FTAPIKit/OutputStream+Write.swift @@ -1,25 +1,11 @@ import Foundation -#if canImport(os) import os -#endif - -#if os(Linux) -public enum LinuxStreamError: Error { - case streamIsInErrorState -} -#endif extension Stream { func throwErrorIfStreamHasError() throws { - #if os(Linux) - if streamStatus == .error { - throw LinuxStreamError.streamIsInErrorState - } - #else - if let error = streamError { - throw error - } - #endif + if let error = streamError { + throw error + } } } @@ -29,14 +15,8 @@ extension OutputStream { /// We want our buffer to be as close to page size as possible. Therefore we use /// POSIX API to get pagesize. The alternative is using compiler private macro which /// is less explicit. - /// - /// In case os can't be imported from some reason, fallback to 4 KiB size. private static func memoryPageSize() -> Int { - #if canImport(os) - return Int(getpagesize()) - #else - return 4_096 - #endif + Int(getpagesize()) } func write(inputStream: InputStream) throws { diff --git a/Sources/FTAPIKit/RequestConfiguring.swift b/Sources/FTAPIKit/RequestConfiguring.swift index 53d0299..c82db5d 100644 --- a/Sources/FTAPIKit/RequestConfiguring.swift +++ b/Sources/FTAPIKit/RequestConfiguring.swift @@ -1,7 +1,4 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif /// Protocol for configuring URLRequest before execution. /// Implementations can perform async operations like token refresh. diff --git a/Sources/FTAPIKit/URL+MIME.swift b/Sources/FTAPIKit/URL+MIME.swift index 870482b..0aa2a19 100644 --- a/Sources/FTAPIKit/URL+MIME.swift +++ b/Sources/FTAPIKit/URL+MIME.swift @@ -1,51 +1,7 @@ -import Foundation -#if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers -#endif extension URL { var mimeType: String { - let fallback = "application/octet-stream" - - #if os(Linux) - return linuxMimeType(for: path) ?? fallback - #else - return uniformMimeType(for: pathExtension) ?? fallback - #endif - } - - #if canImport(UniformTypeIdentifiers) - private func uniformMimeType(for fileExtension: String) -> String? { - UTType(filenameExtension: fileExtension)?.preferredMIMEType - } - #endif - - #if os(Linux) - private func linuxMimeType(for path: String) -> String? { - // Path to `env` on most operatin systems - let pathToEnv = "/bin/env" - - let stdOut = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: pathToEnv) - process.arguments = ["file", "-E", "--brief", "--mime-type", path] - process.standardOutput = stdOut - do { - try process.run() - process.waitUntilExit() - } catch { - assertionFailure("File mime could not be determined: \(error)") - } - - guard process.terminationStatus == 0 else { - assertionFailure("File mime could not be determined: termination status \(process.terminationStatus)") - return nil - } - - return String( - data: stdOut.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) + UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" } - #endif } diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index fe16938..9e5f99c 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - public extension URLServer { func buildStandardRequest(endpoint: Endpoint) throws -> URLRequest { try URLRequestBuilder(server: self, endpoint: endpoint).build() @@ -42,7 +38,6 @@ struct URLRequestBuilder { case let endpoint as URLEncodedEndpoint: request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.httpBody = endpoint.body.percentEncoded?.data(using: .ascii) - #if !os(Linux) case let endpoint as MultipartEndpoint: let formData = MultipartFormData(parts: endpoint.parts) request.httpBodyStream = try formData.inputStream() @@ -50,7 +45,6 @@ struct URLRequestBuilder { if let contentLength = formData.contentLength { request.setValue(contentLength.description, forHTTPHeaderField: "Content-Length") } - #endif default: break } diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index 0951b55..09b6f1b 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -1,7 +1,4 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif public extension URLServer { @@ -73,11 +70,7 @@ public extension URLServer { var urlRequest = try await buildRequest(endpoint: endpoint) try await configuring?.configure(&urlRequest) - #if !os(Linux) let file = (endpoint as? UploadEndpoint)?.file - #else - let file: URL? = nil - #endif let (data, response): (Data, URLResponse) if let file = file { diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift index cf8ec3f..8ceab0f 100644 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ b/Sources/FTAPIKit/URLServer+Download.swift @@ -1,9 +1,5 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - public extension URLServer { /// Downloads a file from the specified endpoint to a temporary location. From f82cd4cc7545a13992b7f7b4c52643d69d034d1c Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:09:28 +0100 Subject: [PATCH 20/30] Update tests --- .../AsyncBuildRequestTests.swift | 63 +++++--------- Tests/FTAPIKitTests/AsyncTests.swift | 29 +++---- Tests/FTAPIKitTests/Mockups/Endpoints.swift | 8 -- Tests/FTAPIKitTests/Mockups/Errors.swift | 4 - .../Mockups/MockNetworkObserver.swift | 4 - Tests/FTAPIKitTests/Mockups/Servers.swift | 12 +-- .../FTAPIKitTests/NetworkObserverTests.swift | 75 +++++++--------- .../RequestConfiguringTests.swift | 86 ++++++------------- Tests/FTAPIKitTests/URLQueryTests.swift | 41 ++++----- 9 files changed, 116 insertions(+), 206 deletions(-) diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift index fdf6be8..6a617ce 100644 --- a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -1,58 +1,41 @@ -import XCTest -#if os(Linux) -import FoundationNetworking -#endif +import Foundation +import Testing + @testable import FTAPIKit /// Tests demonstrating async buildRequest functionality addressing GitHub issue #105 -final class AsyncBuildRequestTests: XCTestCase { +@Suite +struct AsyncBuildRequestTests { - func testAsyncBuildRequestWithDynamicHeaders() async throws { - // Given: A server with async buildRequest that fetches dynamic configuration + @Test + func asyncBuildRequestWithDynamicHeaders() async throws { let server = DynamicHeaderServer() - - // When: Making a call to an endpoint let data = try await server.call(data: GetEndpoint()) - - // Then: The request should have included the dynamically fetched headers - // Decode the response to verify headers were included let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) - XCTAssertEqual(response.headers["X-App-Version"], "2.0.0") - XCTAssertEqual(response.headers["X-Device-Id"], "test-device-123") + #expect(response.headers["X-App-Version"] == "2.0.0") + #expect(response.headers["X-Device-Id"] == "test-device-123") } - func testAsyncBuildRequestWithTokenRefresh() async throws { - // Given: A server with async buildRequest that refreshes tokens + @Test + func asyncBuildRequestWithTokenRefresh() async throws { let tokenManager = MockTokenManager() let server = TokenRefreshServer(tokenManager: tokenManager) - - // When: Making a call (with expired token) tokenManager.currentToken = "expired-token" let data = try await server.call(data: GetEndpoint()) - - // Then: The request should have used the refreshed token let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) - XCTAssertEqual(response.headers["Authorization"], "Bearer refreshed-token-456") - XCTAssertTrue(tokenManager.refreshCalled) + #expect(response.headers["Authorization"] == "Bearer refreshed-token-456") + #expect(tokenManager.refreshCalled) } - - static let allTests = [ - ("testAsyncBuildRequestWithDynamicHeaders", testAsyncBuildRequestWithDynamicHeaders), - ("testAsyncBuildRequestWithTokenRefresh", testAsyncBuildRequestWithTokenRefresh) - ] } // MARK: - Mock Servers -/// Server that fetches configuration asynchronously before building requests -private struct DynamicHeaderServer: URLServer { +private struct DynamicHeaderServer: Server { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! func buildRequest(endpoint: Endpoint) async throws -> URLRequest { - // Simulate async configuration fetch let config = await fetchConfiguration() - var request = try buildStandardRequest(endpoint: endpoint) request.addValue(config.appVersion, forHTTPHeaderField: "X-App-Version") request.addValue(config.deviceId, forHTTPHeaderField: "X-Device-Id") @@ -60,22 +43,18 @@ private struct DynamicHeaderServer: URLServer { } private func fetchConfiguration() async -> AppConfiguration { - // Simulate network delay - try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + try? await Task.sleep(nanoseconds: 10_000_000) return AppConfiguration(appVersion: "2.0.0", deviceId: "test-device-123") } } -/// Server that refreshes authentication tokens before building requests -private struct TokenRefreshServer: URLServer { +private struct TokenRefreshServer: Server { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! let tokenManager: MockTokenManager func buildRequest(endpoint: Endpoint) async throws -> URLRequest { - // Refresh token if needed await tokenManager.refreshIfNeeded() - var request = try buildStandardRequest(endpoint: endpoint) request.addValue("Bearer \(tokenManager.currentToken)", forHTTPHeaderField: "Authorization") return request @@ -89,13 +68,12 @@ private struct AppConfiguration { let deviceId: String } -private class MockTokenManager { - var currentToken: String = "initial-token" - var refreshCalled = false +private final class MockTokenManager: Sendable { + nonisolated(unsafe) var currentToken: String = "initial-token" + nonisolated(unsafe) var refreshCalled = false func refreshIfNeeded() async { - // Simulate token refresh - try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + try? await Task.sleep(nanoseconds: 10_000_000) refreshCalled = true currentToken = "refreshed-token-456" } @@ -110,7 +88,6 @@ private struct HTTPBinResponse: Decodable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - // HTTPBin returns headers with various casings, normalize to our expected keys let rawHeaders = try container.decode([String: String].self, forKey: .headers) self.headers = rawHeaders } diff --git a/Tests/FTAPIKitTests/AsyncTests.swift b/Tests/FTAPIKitTests/AsyncTests.swift index 4154744..9554d29 100644 --- a/Tests/FTAPIKitTests/AsyncTests.swift +++ b/Tests/FTAPIKitTests/AsyncTests.swift @@ -1,33 +1,32 @@ -#if !os(Linux) import Foundation -import XCTest +import Testing -final class AsyncTests: XCTestCase { - func testCallWithoutResponse() async throws { +@testable import FTAPIKit + +@Suite +struct AsyncTests { + + @Test + func callWithoutResponse() async throws { let server = HTTPBinServer() let endpoint = GetEndpoint() try await server.call(endpoint: endpoint) } - func testCallWithData() async throws { + @Test + func callWithData() async throws { let server = HTTPBinServer() let endpoint = GetEndpoint() let data = try await server.call(data: endpoint) - XCTAssertGreaterThan(data.count, 0) + #expect(!data.isEmpty) } - func testCallParsingResponse() async throws { + @Test + func callParsingResponse() async throws { let server = HTTPBinServer() let user = User(uuid: UUID(), name: "Some Name", age: .random(in: 0...120)) let endpoint = UpdateUserEndpoint(request: user) let response = try await server.call(response: endpoint) - XCTAssertEqual(user, response.json) + #expect(user == response.json) } - - static let allTests = [ - ("testCallWithoutResponse", testCallWithoutResponse), - ("testCallWithData", testCallWithData), - ("testCallParsingResponse", testCallParsingResponse) - ] } -#endif diff --git a/Tests/FTAPIKitTests/Mockups/Endpoints.swift b/Tests/FTAPIKitTests/Mockups/Endpoints.swift index 07f5529..389355f 100644 --- a/Tests/FTAPIKitTests/Mockups/Endpoints.swift +++ b/Tests/FTAPIKitTests/Mockups/Endpoints.swift @@ -1,10 +1,6 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct GetEndpoint: Endpoint { let path = "get" } @@ -64,7 +60,6 @@ struct FailingUpdateUserEndpoint: RequestResponseEndpoint { let path = "anything" } -#if !os(Linux) struct TestMultipartEndpoint: MultipartEndpoint { let parts: [MultipartBodyPart] let path = "post" @@ -79,7 +74,6 @@ struct TestMultipartEndpoint: MultipartEndpoint { ] } } -#endif struct TestURLEncodedEndpoint: URLEncodedEndpoint { let path = "post" @@ -90,7 +84,6 @@ struct TestURLEncodedEndpoint: URLEncodedEndpoint { ] } -#if !os(Linux) struct TestUploadEndpoint: UploadEndpoint { let file: URL let path = "put" @@ -100,7 +93,6 @@ struct TestUploadEndpoint: UploadEndpoint { self.file = file.url } } -#endif struct ImageEndpoint: Endpoint { let path = "image/jpeg" diff --git a/Tests/FTAPIKitTests/Mockups/Errors.swift b/Tests/FTAPIKitTests/Mockups/Errors.swift index f91e5f0..62d1a62 100644 --- a/Tests/FTAPIKitTests/Mockups/Errors.swift +++ b/Tests/FTAPIKitTests/Mockups/Errors.swift @@ -1,10 +1,6 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct ThrowawayAPIError: APIError { private init() {} diff --git a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift index bf3a948..2f41a60 100644 --- a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift +++ b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift @@ -1,10 +1,6 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - struct MockContext: Sendable { let requestId: String let startTime: Date diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 9804a59..4938cb9 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,11 +1,7 @@ import Foundation import FTAPIKit -#if os(Linux) -import FoundationNetworking -#endif - -struct HTTPBinServer: URLServer { +struct HTTPBinServer: Server { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! @@ -18,19 +14,19 @@ struct HTTPBinServer: URLServer { } } -struct NonExistingServer: URLServer { +struct NonExistingServer: Server { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "https://www.tato-stranka-urcite-neexistuje.cz/")! } -struct ErrorThrowingServer: URLServer { +struct ErrorThrowingServer: Server { typealias ErrorType = ThrowawayAPIError let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! } -struct HTTPBinServerWithObservers: URLServer { +struct HTTPBinServerWithObservers: Server { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! let networkObservers: [any NetworkObserver] diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift index 49ab3b3..99b2be3 100644 --- a/Tests/FTAPIKitTests/NetworkObserverTests.swift +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -1,72 +1,67 @@ +import Foundation import FTAPIKit -import XCTest +import Testing -#if os(Linux) -import FoundationNetworking -#endif +@Suite +struct NetworkObserverTests { -final class NetworkObserverTests: XCTestCase { - - // MARK: - Unit Tests (no network required) - - func testObserverIsCalledForRequest() { + @Test + func observerIsCalledForRequest() { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) - - XCTAssertEqual(server.networkObservers.count, 1, "NetworkObservers should contain one observer") + #expect(server.networkObservers.count == 1, "NetworkObservers should contain one observer") } - func testEmptyObserversDoesNotCauseIssues() async throws { - let server = HTTPBinServer() // Default observers is empty array + @Test + func emptyObserversDoesNotCauseIssues() async throws { + let server = HTTPBinServer() let endpoint = GetEndpoint() - - // Verify empty observers doesn't cause problems during request building _ = try await server.buildRequest(endpoint: endpoint) - XCTAssertTrue(server.networkObservers.isEmpty, "Default networkObservers should be empty") + #expect(server.networkObservers.isEmpty, "Default networkObservers should be empty") } - func testMultipleObserversSupported() { + @Test + func multipleObserversSupported() { let observer1 = MockNetworkObserver() let observer2 = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) - - XCTAssertEqual(server.networkObservers.count, 2, "Should support multiple observers") + #expect(server.networkObservers.count == 2, "Should support multiple observers") } // MARK: - Integration Tests (requires network) - // Note: These tests may fail if httpbin.org is unavailable - func testObserverReceivesLifecycleCallbacks() async throws { + @Test + func observerReceivesLifecycleCallbacks() async throws { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = GetEndpoint() _ = try await server.call(data: endpoint) - XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") - // didReceiveResponse is always called; didFail is called additionally on failure - XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") + #expect(mockObserver.willSendCount == 1, "willSendRequest should be called once") + #expect(mockObserver.didReceiveCount == 1, "didReceiveResponse should always be called") } - func testObserverLogsFailedRequest() async { + @Test + func observerLogsFailedRequest() async { let mockObserver = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [mockObserver]) let endpoint = NotFoundEndpoint() do { _ = try await server.call(data: endpoint) - XCTFail("Expected error for 404 endpoint") + Issue.record("Expected error for 404 endpoint") } catch { // Expected error } - // didReceiveResponse is always called with raw data; didFail is called additionally on failure - XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") - XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") - XCTAssertEqual(mockObserver.didFailCount, 1, "didFail should be called on failure") + #expect(mockObserver.willSendCount == 1, "willSendRequest should be called once") + #expect(mockObserver.didReceiveCount == 1, "didReceiveResponse should always be called") + #expect(mockObserver.didFailCount == 1, "didFail should be called on failure") } - func testMultipleObserversAllReceiveCallbacks() async throws { + @Test + func multipleObserversAllReceiveCallbacks() async throws { let observer1 = MockNetworkObserver() let observer2 = MockNetworkObserver() let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) @@ -74,19 +69,9 @@ final class NetworkObserverTests: XCTestCase { _ = try await server.call(data: endpoint) - // Both observers should receive callbacks - XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") - XCTAssertEqual(observer2.willSendCount, 1, "Observer 2 willSendRequest should be called") - XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") - XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") + #expect(observer1.willSendCount == 1, "Observer 1 willSendRequest should be called") + #expect(observer2.willSendCount == 1, "Observer 2 willSendRequest should be called") + #expect(observer1.didReceiveCount == 1, "Observer 1 didReceiveResponse should be called") + #expect(observer2.didReceiveCount == 1, "Observer 2 didReceiveResponse should be called") } - - static let allTests = [ - ("testObserverIsCalledForRequest", testObserverIsCalledForRequest), - ("testEmptyObserversDoesNotCauseIssues", testEmptyObserversDoesNotCauseIssues), - ("testMultipleObserversSupported", testMultipleObserversSupported), - ("testObserverReceivesLifecycleCallbacks", testObserverReceivesLifecycleCallbacks), - ("testObserverLogsFailedRequest", testObserverLogsFailedRequest), - ("testMultipleObserversAllReceiveCallbacks", testMultipleObserversAllReceiveCallbacks) - ] } diff --git a/Tests/FTAPIKitTests/RequestConfiguringTests.swift b/Tests/FTAPIKitTests/RequestConfiguringTests.swift index db09001..fa2407e 100644 --- a/Tests/FTAPIKitTests/RequestConfiguringTests.swift +++ b/Tests/FTAPIKitTests/RequestConfiguringTests.swift @@ -1,100 +1,71 @@ -import XCTest -#if os(Linux) -import FoundationNetworking -#endif +import Foundation +import Testing + @testable import FTAPIKit /// Tests for RequestConfiguring protocol functionality -final class RequestConfiguringTests: XCTestCase { +@Suite +struct RequestConfiguringTests { - func testNilConfigurationMakesNoChanges() async throws { - // Given: A server and endpoint with no configuration + @Test + func nilConfigurationMakesNoChanges() async throws { let server = HTTPBinServer() - - // When: Making a call without configuration (nil default) let data = try await server.call(data: GetEndpoint()) - - // Then: Request should succeed without any modifications let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) - XCTAssertNil(response.headers["X-Custom-Header"]) + #expect(response.headers["X-Custom-Header"] == nil) } - func testCustomConfigurationModifiesRequest() async throws { - // Given: A custom configuration that adds headers + @Test + func customConfigurationModifiesRequest() async throws { let config = HeaderAddingConfiguration(headerName: "X-Custom-Header", headerValue: "test-value-123") let server = HTTPBinServer() - - // When: Making a call with the configuration let data = try await server.call(data: GetEndpoint(), configuring: config) - - // Then: The request should have the custom header let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) - XCTAssertEqual(response.headers["X-Custom-Header"], "test-value-123") + #expect(response.headers["X-Custom-Header"] == "test-value-123") } - func testConfigurationErrorPropagates() async throws { - // Given: A configuration that throws an error + @Test + func configurationErrorPropagates() async throws { let config = FailingConfiguration() let server = HTTPBinServer() - // When/Then: The error should propagate do { _ = try await server.call(data: GetEndpoint(), configuring: config) - XCTFail("Expected error to be thrown") + Issue.record("Expected error to be thrown") } catch let error as ConfigurationError { - XCTAssertEqual(error, .tokenRefreshFailed) + #expect(error == .tokenRefreshFailed) } } - func testAsyncOperationsInConfigure() async throws { - // Given: A configuration that performs async token refresh + @Test + func asyncOperationsInConfigure() async throws { let tokenManager = MockAsyncTokenManager() let config = AsyncTokenConfiguration(tokenManager: tokenManager) let server = HTTPBinServer() - - // When: Making a call with the async configuration let data = try await server.call(data: GetEndpoint(), configuring: config) - - // Then: The async operation should have completed and header should be set let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) - XCTAssertEqual(response.headers["Authorization"], "Bearer refreshed-token") - XCTAssertTrue(tokenManager.refreshCalled) + #expect(response.headers["Authorization"] == "Bearer refreshed-token") + #expect(tokenManager.refreshCalled) } - func testResponseEndpointWithConfiguration() async throws { - // Given: A response endpoint with configuration + @Test + func responseEndpointWithConfiguration() async throws { let config = HeaderAddingConfiguration(headerName: "X-Api-Key", headerValue: "secret-key") let server = HTTPBinServer() - - // When: Making a call with configuration let response = try await server.call(response: JSONResponseEndpoint(), configuring: config) - - // Then: Response should be decoded correctly - XCTAssertFalse(response.slideshow.title.isEmpty) + #expect(!response.slideshow.title.isEmpty) } - func testVoidEndpointWithConfiguration() async throws { - // Given: An endpoint that returns no content + @Test + func voidEndpointWithConfiguration() async throws { let config = HeaderAddingConfiguration(headerName: "X-Request-Id", headerValue: "req-123") let server = HTTPBinServer() - - // When/Then: Call should succeed without throwing try await server.call(endpoint: NoContentEndpoint(), configuring: config) } - - static let allTests = [ - ("testNilConfigurationMakesNoChanges", testNilConfigurationMakesNoChanges), - ("testCustomConfigurationModifiesRequest", testCustomConfigurationModifiesRequest), - ("testConfigurationErrorPropagates", testConfigurationErrorPropagates), - ("testAsyncOperationsInConfigure", testAsyncOperationsInConfigure), - ("testResponseEndpointWithConfiguration", testResponseEndpointWithConfiguration), - ("testVoidEndpointWithConfiguration", testVoidEndpointWithConfiguration) - ] } // MARK: - Test Configurations -/// Simple configuration that adds a header private struct HeaderAddingConfiguration: RequestConfiguring { let headerName: String let headerValue: String @@ -104,14 +75,12 @@ private struct HeaderAddingConfiguration: RequestConfiguring { } } -/// Configuration that always fails private struct FailingConfiguration: RequestConfiguring { func configure(_ request: inout URLRequest) async throws { throw ConfigurationError.tokenRefreshFailed } } -/// Configuration with async token refresh private struct AsyncTokenConfiguration: RequestConfiguring { let tokenManager: MockAsyncTokenManager @@ -127,12 +96,11 @@ private enum ConfigurationError: Error, Equatable { case tokenRefreshFailed } -private class MockAsyncTokenManager: @unchecked Sendable { - var refreshCalled = false +private final class MockAsyncTokenManager: Sendable { + nonisolated(unsafe) var refreshCalled = false func getValidToken() async -> String { - // Simulate async token refresh - try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + try? await Task.sleep(nanoseconds: 10_000_000) refreshCalled = true return "refreshed-token" } diff --git a/Tests/FTAPIKitTests/URLQueryTests.swift b/Tests/FTAPIKitTests/URLQueryTests.swift index 7a75a5e..361e179 100644 --- a/Tests/FTAPIKitTests/URLQueryTests.swift +++ b/Tests/FTAPIKitTests/URLQueryTests.swift @@ -1,48 +1,49 @@ +import Foundation +import Testing + @testable import FTAPIKit -import XCTest -final class URLQueryTests: XCTestCase { - func testSpaceEncoding() { +@Suite +struct URLQueryTests { + + @Test + func spaceEncoding() { let query: URLQuery = [ "q": "some string" ] - XCTAssertEqual(query.percentEncoded, "q=some%20string") + #expect(query.percentEncoded == "q=some%20string") } - func testDelimitersEncoding() { + @Test + func delimitersEncoding() { let query: URLQuery = [ "array[]": "a", "array[]": "b" ] - XCTAssertEqual(query.percentEncoded, "array%5B%5D=a&array%5B%5D=b") + #expect(query.percentEncoded == "array%5B%5D=a&array%5B%5D=b") } - func testQueryAppending() { + @Test + func queryAppending() { var url = URL(string: "http://httpbin.org/get")! url.appendQuery(["a": "a"]) - XCTAssertEqual(url.absoluteString, "http://httpbin.org/get?a=a") + #expect(url.absoluteString == "http://httpbin.org/get?a=a") } - func testRepeatedQueryAppending() { + @Test + func repeatedQueryAppending() { var url = URL(string: "http://httpbin.org/get")! url.appendQuery(["a": "a"]) url.appendQuery(["b": "b"]) - XCTAssertEqual(url.absoluteString, "http://httpbin.org/get?a=a&b=b") + #expect(url.absoluteString == "http://httpbin.org/get?a=a&b=b") } - func testEmptyQueryItemValues() { + @Test + func emptyQueryItemValues() { let query = URLQuery(items: [ URLQueryItem(name: "a", value: nil), URLQueryItem(name: "b", value: nil) ]) - XCTAssertEqual(query.percentEncoded, "a=&b=") + #expect(query.percentEncoded == "a=&b=") } - - static let allTests = [ - ("testSpaceEncoding", testSpaceEncoding), - ("testDelimitersEncoding", testDelimitersEncoding), - ("testQueryAppending", testQueryAppending), - ("testRepeatedQueryAppending", testRepeatedQueryAppending), - ("testEmptyQueryItemValues", testEmptyQueryItemValues) - ] } From 7f1bc257048b3601f237ba75afd4e3d03521c6b2 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:10:12 +0100 Subject: [PATCH 21/30] Update claude.md --- CLAUDE.md | 124 +++++++++++++++++++----------------------------------- 1 file changed, 43 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ad6c4b7..a301897 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,26 +11,29 @@ FTAPIKit is a declarative async/await REST API framework for Swift using Swift C - Protocol-oriented design with `Server` and `Endpoint` protocols - Multiple endpoint types for different use cases (GET, POST, multipart uploads, etc.) - Async buildRequest enabling token refresh, dynamic configuration, and rate limiting +- `RequestConfiguring` protocol for per-request configuration at call site +- `NetworkObserver` protocol for request lifecycle monitoring (logging, analytics) - Swift 6 concurrency safety with Sendable requirements -- Built-in support for FTNetworkTracer for request logging and tracking -- Cross-platform support: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+, and Linux +- Cross-platform support: iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ ## Build and Test Commands ### Building ```bash +# Use xcodebuild (preferred, avoids toolchain mismatch issues) +xcodebuild build -scheme FTAPIKit -destination 'platform=macOS' + +# Or with Swift CLI swift build ``` ### Running Tests ```bash -# Run all tests -swift test +# Use xcodebuild +xcodebuild test -scheme FTAPIKit -destination 'platform=macOS' -# For CocoaPods validation -gem install bundler -bundle install --jobs 4 --retry 3 -bundle exec pod lib lint --allow-warnings +# Or with Swift CLI +swift test ``` ### Linting @@ -45,12 +48,13 @@ The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with ma ### Core Protocol Design -The framework is built around two core protocols that mirror physical infrastructure: +The framework is built around two core protocols: 1. **`Server` Protocol** - Represents a single web service - - Defines encoding/decoding strategies - - Builds requests from endpoints - - Standard implementation: `URLServer` (uses Foundation's URLSession) + - Defines `baseUri`, `urlSession`, `encoding`/`decoding`, `networkObservers` + - Builds requests from endpoints via `buildRequest(endpoint:) async throws` + - Provides default implementations for all properties except `baseUri` + - Has `ErrorType` associated type (defaults to `APIError.Standard`) 2. **`Endpoint` Protocol** - Represents access points for resources - Defines path, headers, query parameters, and HTTP method @@ -58,12 +62,10 @@ The framework is built around two core protocols that mirror physical infrastruc ### Endpoint Type Hierarchy -The framework provides several endpoint protocol variants: - - **`Endpoint`** - Base protocol with empty body (typically for GET requests) - **`DataEndpoint`** - Sends raw data in body -- **`UploadEndpoint`** - Uploads files using InputStream (not available on Linux) -- **`MultipartEndpoint`** - Combines body parts into multipart request (not available on Linux) +- **`UploadEndpoint`** - Uploads files using URLSession upload +- **`MultipartEndpoint`** - Combines body parts into multipart request - **`URLEncodedEndpoint`** - Body in URL query format - **`RequestEndpoint`** - Has encodable request model (defaults to POST) - **`ResponseEndpoint`** - Has decodable response model @@ -71,40 +73,30 @@ The framework provides several endpoint protocol variants: ### Key Architectural Patterns -**Protocol-Oriented Design**: Endpoints are designed to be implemented as structs (not enums or classes). This provides: -- Generated initializers -- Better long-term sustainability (endpoint info stays localized) -- No memory overhead for instant usage after creation +**Single Server Protocol**: The `Server` protocol combines what was previously split between `Server` and `URLServer`. All URLSession-based functionality is built into the single `Server` protocol with default implementations. -**Swift 6 Concurrency Safety**: All `ResponseEndpoint` associated types must conform to `Sendable`: -- Response models must be `Sendable` for thread-safe async/await usage -- Compiler enforces this at endpoint definition, providing clear error messages -- Breaking change from pre-6.0 versions but ensures concurrency correctness +**Network Observers**: The `NetworkObserver` protocol provides lifecycle callbacks (`willSendRequest`, `didReceiveResponse`, `didFail`) with type-safe context passing. Observer integration uses `AnyObserverToken` for type erasure. -**Encoding/Decoding Abstraction**: The `Encoding` and `Decoding` protocols provide type-erased wrappers around Swift's `Codable` system: -- `JSONEncoding` / `JSONDecoding` for JSON with customizable encoders/decoders -- `URLRequestEncoding` extends encoding to configure URLRequest headers +**Request Configuration**: The `RequestConfiguring` protocol allows per-request async configuration at the call site, separate from server-level `buildRequest`. -**Async Request Building**: The request building flow is fully asynchronous (addressing GitHub issue #105): -1. `Server.buildRequest(endpoint:)` is declared as `async throws` -2. Can be overridden to perform async operations (token refresh, config fetch, rate limiting) -3. Default implementation calls synchronous `buildStandardRequest(endpoint:)` helper -4. Enables powerful use cases like awaiting token managers or fetching dynamic headers -5. Specialized handling for multipart, upload, and encoded endpoints +**Encoding/Decoding**: The `Encoding` protocol includes `configure(request:)` for setting content-type headers (with empty default). Both `Encoding` and `Decoding` require `Sendable`. + +**Swift 6 Concurrency Safety**: All `ResponseEndpoint` associated types must conform to `Sendable`. ### Module Organization **Source Structure** (`Sources/FTAPIKit/`): -- Core protocols: `Server.swift`, `Endpoint.swift`, `URLServer.swift` -- Request building: `URLRequestBuilder.swift` +- Core protocols: `Server.swift`, `Endpoint.swift` +- Request building: `URLRequestBuilder.swift`, `RequestConfiguring.swift` - Async execution: `URLServer+Async.swift`, `URLServer+Download.swift` -- Internal helpers: `URLServer+Task.swift` (provides async request building and error helpers) +- Observers: `NetworkObserver.swift` - Utilities: `Coding.swift`, `URLQuery.swift`, `MultipartFormData.swift`, etc. - Error handling: `APIError.swift`, `APIError+Standard.swift` **Test Structure** (`Tests/FTAPIKitTests/`): -- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift` -- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift` +- Uses Swift Testing framework (`@Suite`, `@Test`, `#expect`) +- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift`, `NetworkObserverTests.swift`, `RequestConfiguringTests.swift` +- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift`, `MockNetworkObserver.swift` ### Call Execution Pattern @@ -124,65 +116,35 @@ try await server.call(endpoint: endpoint) let fileURL = try await server.download(endpoint: endpoint) ``` -**Cancellation**: Use Task cancellation for aborting requests: -```swift -let task = Task { - try await server.call(response: endpoint) -} -task.cancel() // Cancels the request -``` - -**Breaking Change from 1.x**: Completion handlers and Combine support were removed in 2.0. All API calls use async/await. - ### Error Handling - `APIError` protocol defines error handling interface -- Default implementation: `APIError.Standard` -- Custom error types can be defined via `URLServer.ErrorType` associated type -- Errors initialized from: `Data?`, `URLResponse?`, `Error?`, and `Decoding` - -### Network Tracing - -The framework integrates with `FTNetworkTracer` for request logging: -- `URLServer.networkTracer` property (optional, defaults to nil) -- Dependency: `https://github.com/futuredapp/FTNetworkTracer` +- Default implementation: `APIError.Standard` (enum with connection, encoding, decoding, server, client, unhandled cases) +- Custom error types can be defined via `Server.ErrorType` associated type ## Package Management -The project supports both **Swift Package Manager** and **CocoaPods**: - -- **SPM**: See `Package.swift` -- **CocoaPods**: See `FTAPIKit.podspec` and `Gemfile` +The project uses **Swift Package Manager** exclusively. See `Package.swift`. ### Platform Support Minimum deployment targets: -- iOS 15+ -- macOS 12+ -- tvOS 15+ -- watchOS 8+ -- Linux (with FoundationNetworking, limited endpoint types) - -Note: `UploadEndpoint` and `MultipartEndpoint` are not available on Linux. +- iOS 17+ +- macOS 14+ +- tvOS 17+ +- watchOS 10+ ## Testing Approach -Tests use mock servers (HTTPBin-based) defined in `Tests/FTAPIKitTests/Mockups/Servers.swift`: +Tests use Swift Testing framework and mock servers (HTTPBin-based) defined in `Tests/FTAPIKitTests/Mockups/Servers.swift`: - `HTTPBinServer` - Standard test server with async authorization support - `NonExistingServer` - For testing error conditions - `ErrorThrowingServer` - Custom error type testing - -**Test Files:** -- `AsyncTests.swift` - Tests for basic async/await functionality -- `AsyncBuildRequestTests.swift` - Demonstrates async buildRequest use cases (token refresh, dynamic headers) -- `URLQueryTests.swift` - Tests for URL query parameter handling - -Mock endpoints demonstrate all endpoint types and are reusable across test suites. All tests use async/await patterns. +- `HTTPBinServerWithObservers` - Observer integration testing ## CI/CD -GitHub Actions workflows run on: -- macOS 14 (Xcode 16.2) -- Ubuntu Latest - -All workflows run: `swiftlint --strict`, `pod lib lint --allow-warnings`, `swift build`, `swift test` +Single GitHub Actions workflow (`ci.yml`) runs on `macos-latest`: +- `swiftlint --strict` +- `swift build` +- `swift test` From 176ab6145947015a47d583cdf357810cbd3f17ec Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Thu, 12 Mar 2026 19:10:30 +0100 Subject: [PATCH 22/30] Bump os versions --- Package.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index fc9ca94..f95a8ed 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,10 @@ import PackageDescription let package = Package( name: "FTAPIKit", platforms: [ - .iOS(.v14), - .macOS(.v11), - .tvOS(.v14), - .watchOS(.v7) + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10) ], products: [ .library( From 5b8cd340c82290d1fd028c4aa7299d524ddcd91a Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 13:28:06 +0100 Subject: [PATCH 23/30] Use URLServer naming --- CLAUDE.md | 10 +++---- Sources/FTAPIKit/Server.swift | 30 ------------------- .../AsyncBuildRequestTests.swift | 4 +-- Tests/FTAPIKitTests/Mockups/Servers.swift | 8 ++--- 4 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 Sources/FTAPIKit/Server.swift diff --git a/CLAUDE.md b/CLAUDE.md index a301897..a283801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ FTAPIKit is a declarative async/await REST API framework for Swift using Swift C **Key Features:** - Declarative async/await API for defining web services -- Protocol-oriented design with `Server` and `Endpoint` protocols +- Protocol-oriented design with `URLServer` and `Endpoint` protocols - Multiple endpoint types for different use cases (GET, POST, multipart uploads, etc.) - Async buildRequest enabling token refresh, dynamic configuration, and rate limiting - `RequestConfiguring` protocol for per-request configuration at call site @@ -42,7 +42,7 @@ swift test swiftlint --strict ``` -The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with many opt-in rules enabled. Linting must pass with `--strict` flag for CI to succeed. +The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with many opt-in rules enabled. Linting must pass with `--strict` flag with zero violations before committing any code. ## Architecture @@ -50,7 +50,7 @@ The project uses an extensive SwiftLint configuration (`.swiftlint.yml`) with ma The framework is built around two core protocols: -1. **`Server` Protocol** - Represents a single web service +1. **`URLServer` Protocol** - Represents a single web service - Defines `baseUri`, `urlSession`, `encoding`/`decoding`, `networkObservers` - Builds requests from endpoints via `buildRequest(endpoint:) async throws` - Provides default implementations for all properties except `baseUri` @@ -73,7 +73,7 @@ The framework is built around two core protocols: ### Key Architectural Patterns -**Single Server Protocol**: The `Server` protocol combines what was previously split between `Server` and `URLServer`. All URLSession-based functionality is built into the single `Server` protocol with default implementations. +**URLServer Protocol**: The `URLServer` protocol provides all URLSession-based functionality with default implementations. Only `baseUri` must be provided by conforming types. **Network Observers**: The `NetworkObserver` protocol provides lifecycle callbacks (`willSendRequest`, `didReceiveResponse`, `didFail`) with type-safe context passing. Observer integration uses `AnyObserverToken` for type erasure. @@ -120,7 +120,7 @@ let fileURL = try await server.download(endpoint: endpoint) - `APIError` protocol defines error handling interface - Default implementation: `APIError.Standard` (enum with connection, encoding, decoding, server, client, unhandled cases) -- Custom error types can be defined via `Server.ErrorType` associated type +- Custom error types can be defined via `URLServer.ErrorType` associated type ## Package Management diff --git a/Sources/FTAPIKit/Server.swift b/Sources/FTAPIKit/Server.swift deleted file mode 100644 index a7bc06b..0000000 --- a/Sources/FTAPIKit/Server.swift +++ /dev/null @@ -1,30 +0,0 @@ -/// `Server` is an abstraction rather than a protocol-bound requirement. -/// -/// The expectation of a type conforming to this protocol is, that it provides a gateway to an API over HTTP. Conforming -/// type should also have the ability to encode/decode data into requests and responses using the `Codable` -/// conformances and strongly typed coding of the Swift language. -/// -/// Conforming type must specify the type representing a request like `Foundation.URLRequest` or -/// `Alamofire.Request`. However, conforming type is expected to have the ability to execute the request too. -/// -/// ``FTAPIKit`` provides a standard implementation tailored for `Foundation.URLSession` and -/// `Foundation` JSON coders. The standard implementation is represented by ``URLServer``. -public protocol Server { - /// The type representing a `Request` of the network library, like `Foundation.URLRequest` or - /// `Alamofire.Request`. - associatedtype Request - - /// The instance providing strongly typed decoding. - var decoding: Decoding { get } - - /// The instance providing strongly typed encoding. - var encoding: Encoding { get } - - /// Takes a Swift description of an endpoint call and transforms it into a valid request. The reason why - /// the function returns the request to the user is so the user is able to modify the request before executing. - /// This is useful in cases when the API uses OAuth or some other token-based authorization, where - /// the request may be delayed before the valid tokens are received. - /// - Parameter endpoint: An instance of an endpoint representing a call. - /// - Returns: A valid request. - func buildRequest(endpoint: Endpoint) async throws -> Request -} diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift index 6a617ce..2ff5274 100644 --- a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -30,7 +30,7 @@ struct AsyncBuildRequestTests { // MARK: - Mock Servers -private struct DynamicHeaderServer: Server { +private struct DynamicHeaderServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! @@ -48,7 +48,7 @@ private struct DynamicHeaderServer: Server { } } -private struct TokenRefreshServer: Server { +private struct TokenRefreshServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! let tokenManager: MockTokenManager diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 4938cb9..ec3deaa 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,7 +1,7 @@ import Foundation import FTAPIKit -struct HTTPBinServer: Server { +struct HTTPBinServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! @@ -14,19 +14,19 @@ struct HTTPBinServer: Server { } } -struct NonExistingServer: Server { +struct NonExistingServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "https://www.tato-stranka-urcite-neexistuje.cz/")! } -struct ErrorThrowingServer: Server { +struct ErrorThrowingServer: URLServer { typealias ErrorType = ThrowawayAPIError let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! } -struct HTTPBinServerWithObservers: Server { +struct HTTPBinServerWithObservers: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! let networkObservers: [any NetworkObserver] From 76f8856411b4b05833d7312bec4827bccb9bb3aa Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 13:50:01 +0100 Subject: [PATCH 24/30] Update URLServer --- README.md | 136 +++++++++------------- Sources/FTAPIKit/Coding.swift | 23 ++-- Sources/FTAPIKit/OutputStream+Write.swift | 2 +- Sources/FTAPIKit/URLRequestBuilder.swift | 3 +- Sources/FTAPIKit/URLServer+Async.swift | 111 ++++++++++-------- Sources/FTAPIKit/URLServer+Download.swift | 16 ++- Sources/FTAPIKit/URLServer.swift | 62 +++++----- 7 files changed, 172 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index 22ca436..15eae9f 100644 --- a/README.md +++ b/README.md @@ -2,36 +2,27 @@ # FTAPIKit -![Cocoapods](https://img.shields.io/cocoapods/v/FTAPIKit) -![Cocoapods platforms](https://img.shields.io/cocoapods/p/FTAPIKit) -![License](https://img.shields.io/cocoapods/l/FTAPIKit) +![License](https://img.shields.io/github/license/futuredapp/FTAPIKit) -![macOS 14](https://github.com/futuredapp/FTAPIKit/actions/workflows/macos-14.yml/badge.svg?branch=main) -![Ubuntu](https://github.com/futuredapp/FTAPIKit/actions/workflows/ubuntu-latest.yml/badge.svg?branch=main) +![CI](https://github.com/futuredapp/FTAPIKit/actions/workflows/ci.yml/badge.svg?branch=main) Declarative async/await REST API framework using Swift Concurrency and Codable. With standard implementation using URLSession and JSON encoder/decoder. -Built for Swift 6.1 with full concurrency safety. +Built for Swift 6.1+ with full concurrency safety. ## Requirements - Swift 6.1+ -- iOS 15+, macOS 12+, tvOS 15+, watchOS 8+, or Linux +- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ ## Installation -When using Swift Package Manager install using Xcode or add the following line to your dependencies: +Add the following line to your Swift Package Manager dependencies: ```swift .package(url: "https://github.com/futuredapp/FTAPIKit.git", from: "2.0.0") ``` -When using CocoaPods add following line to your `Podfile`: - -```ruby -pod 'FTAPIKit', '~> 2.0' -``` - ## Features The main feature of this library is to provide documentation-like API @@ -40,14 +31,11 @@ and protocol-oriented programming in Swift. The framework provides two core protocols reflecting the physical infrastructure: -- `Server` protocol defining single web service. +- `URLServer` protocol defining single web service with built-in URLSession support. - `Endpoint` protocol defining access points for resources. -Combining instances of type conforming to `Server` and `Endpoint` we can build request. -`URLServer` has convenience method for calling endpoints using `URLSession`. -If some advanced features are required then we recommend implementing API client. -This client should encapsulate logic which is not provided by this framework -(like signing authorized endpoints or conforming to `URLSessionDelegate`). +Combining instances of type conforming to `URLServer` and `Endpoint` we can build request. +`URLServer` has convenience methods for calling endpoints using `URLSession`. ![Architecture](Sources/FTAPIKit/Documentation.docc/Resources/Architecture.png) @@ -62,7 +50,7 @@ are separated in various protocols for convenience. Body parts are represented by `MultipartBodyPart` struct and provided to the endpoint in an array. - `RequestEndpoint` has encodable request which is encoded using encoding - of the `Server` instance. + of the `URLServer` instance. ![Endpoint types](Sources/FTAPIKit/Documentation.docc/Resources/Endpoints.svg) @@ -111,7 +99,7 @@ struct HTTPBinServer: URLServer { ### Defining endpoints Most basic `GET` endpoint can be implemented using `Endpoint` protocol, -all default propertires are inferred. +all default properties are inferred. ```swift struct GetEndpoint: Endpoint { @@ -205,86 +193,59 @@ let publicData = try await server.call(response: publicEndpoint) let protectedData = try await server.call(response: protectedEndpoint, configuring: authConfig) ``` -This pattern keeps the server layer focused on request building while allowing -the API service layer to handle authentication concerns. +### Network Observers -## Migrating from 1.x to 2.0 +Monitor request lifecycle with the `NetworkObserver` protocol: -FTAPIKit 2.0 is a major rewrite focused on Swift Concurrency. Here are the breaking changes: +```swift +final class LoggingObserver: NetworkObserver { + func willSendRequest(_ request: URLRequest) -> String { + let id = UUID().uuidString + print("[\(id)] Sending: \(request.url!)") + return id + } -### Completion Handlers Removed + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: String) { + print("[\(context)] Received response") + } -**Old (1.x):** -```swift -server.call(response: endpoint) { result in - switch result { - case .success(let response): - print(response) - case .failure(let error): - print(error) + func didFail(request: URLRequest, error: Error, context: String) { + print("[\(context)] Failed: \(error)") } } -``` -**New (2.0):** -```swift -Task { - do { - let response = try await server.call(response: endpoint) - print(response) - } catch { - print(error) - } +struct MyServer: URLServer { + let baseUri = URL(string: "https://api.example.com")! + let networkObservers: [any NetworkObserver] = [LoggingObserver()] } ``` -### Combine Removed +## Migrating from 1.x to 2.0 -Combine support has been removed in favor of async/await, which provides better performance and cleaner code. +FTAPIKit 2.0 is a major rewrite focused on Swift Concurrency. Here are the breaking changes: -**Old (1.x) - Combine:** -```swift -server.publisher(response: endpoint) - .sink( - receiveCompletion: { completion in - // Handle completion - }, - receiveValue: { response in - // Handle response - } - ) - .store(in: &cancellables) -``` +### Server Protocol Simplified + +The separate `Server` and `URLServer` protocols have been merged into a single `URLServer` protocol. +If you previously conformed to the abstract `Server` protocol directly, switch to `URLServer`. + +### Completion Handlers & Combine Removed + +All API calls now use async/await exclusively: -**New (2.0) - Async/Await:** ```swift -let task = Task { - do { - let response = try await server.call(response: endpoint) - // Handle response - } catch { - // Handle error - } -} +// Old (1.x) +server.call(response: endpoint) { result in ... } +server.publisher(response: endpoint).sink { ... } -// Cancel if needed -task.cancel() +// New (2.0) +let response = try await server.call(response: endpoint) ``` ### buildRequest is Now Async If you override `buildRequest`, you must mark it as `async`: -**Old (1.x):** -```swift -func buildRequest(endpoint: Endpoint) throws -> URLRequest { - var request = try buildStandardRequest(endpoint: endpoint) - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request -} -``` - -**New (2.0):** ```swift func buildRequest(endpoint: Endpoint) async throws -> URLRequest { var request = try buildStandardRequest(endpoint: endpoint) @@ -295,15 +256,24 @@ func buildRequest(endpoint: Endpoint) async throws -> URLRequest { ### Response Types Must Be Sendable -All `ResponseEndpoint` response types must conform to `Sendable` for Swift 6 concurrency safety: +All `ResponseEndpoint` response types must conform to `Sendable`: ```swift -struct User: Codable, Sendable { // Add Sendable conformance +struct User: Codable, Sendable { let id: Int let name: String } ``` +### CocoaPods & Linux Removed + +FTAPIKit 2.0 is distributed exclusively via Swift Package Manager. +Linux support has been dropped. + +### Minimum Platform Versions Raised + +- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ + ## Contributors Current maintainer and main contributor is [Matěj Kašpar Jirásek](https://github.com/mkj-is), . diff --git a/Sources/FTAPIKit/Coding.swift b/Sources/FTAPIKit/Coding.swift index ec68918..04efbec 100644 --- a/Sources/FTAPIKit/Coding.swift +++ b/Sources/FTAPIKit/Coding.swift @@ -2,26 +2,27 @@ import Foundation /// `Encoding` represents Swift encoders and provides network-specific features, such as configuring /// the request with correct headers. -public protocol Encoding { +public protocol Encoding: Sendable { - /// Encodes the argument + /// Encodes the argument. func encode(_ object: T) throws -> Data -} - -/// Protocol which enables use of any decoder using type-erasure. -public protocol Decoding { - func decode(data: Data) throws -> T -} -/// Protocol extending encoding with ability to configure `URLRequest`. Used when encoding endpoints in ``URLServer`` calls. -public protocol URLRequestEncoding: Encoding { /// Allows modification of `URLRequest`. Enables things like adding `Content-Type` header etc. /// - Parameter request: Request which can be modified. func configure(request: inout URLRequest) throws } +public extension Encoding { + func configure(request: inout URLRequest) throws {} +} + +/// Protocol which enables use of any decoder using type-erasure. +public protocol Decoding: Sendable { + func decode(data: Data) throws -> T +} + /// Type-erased JSON encoder for use with types conforming to ``Server`` protocol. -public struct JSONEncoding: URLRequestEncoding { +public struct JSONEncoding: Encoding { private let encoder: JSONEncoder public init(encoder: JSONEncoder = .init()) { diff --git a/Sources/FTAPIKit/OutputStream+Write.swift b/Sources/FTAPIKit/OutputStream+Write.swift index 12f8a6d..722543f 100644 --- a/Sources/FTAPIKit/OutputStream+Write.swift +++ b/Sources/FTAPIKit/OutputStream+Write.swift @@ -68,7 +68,7 @@ extension OutputStream { } func writeLine(string: String? = nil) throws { - if let string = string { + if let string { try write(string: string) } try write(string: "\r\n") diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index 9e5f99c..31c068c 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -32,8 +32,7 @@ struct URLRequestBuilder { case let endpoint as DataEndpoint: request.httpBody = endpoint.body case let endpoint as EncodableEndpoint: - let requestEncoding = server.encoding as? URLRequestEncoding - try requestEncoding?.configure(request: &request) + try server.encoding.configure(request: &request) request.httpBody = try endpoint.body(encoding: server.encoding) case let endpoint as URLEncodedEndpoint: request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index 09b6f1b..d91d893 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -7,27 +7,8 @@ public extension URLServer { /// - endpoint: The endpoint /// - configuring: Optional request configuration to apply before sending /// - Throws: Throws an APIError if the request fails or server returns an error - /// - Returns: Void on success func call(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws { - var urlRequest = try await buildRequest(endpoint: endpoint) - try await configuring?.configure(&urlRequest) - - #if !os(Linux) - let file = (endpoint as? UploadEndpoint)?.file - #else - let file: URL? = nil - #endif - - let (data, response): (Data, URLResponse) - if let file = file { - (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) - } else { - (data, response) = try await urlSession.data(for: urlRequest) - } - - if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { - throw error - } + _ = try await execute(endpoint: endpoint, configuring: configuring) } /// Performs call to endpoint which returns arbitrary data in the HTTP response, that should not be parsed by the decoder. @@ -37,27 +18,7 @@ public extension URLServer { /// - Throws: Throws an APIError if the request fails or server returns an error /// - Returns: Plain data returned with the HTTP Response func call(data endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> Data { - var urlRequest = try await buildRequest(endpoint: endpoint) - try await configuring?.configure(&urlRequest) - - #if !os(Linux) - let file = (endpoint as? UploadEndpoint)?.file - #else - let file: URL? = nil - #endif - - let (data, response): (Data, URLResponse) - if let file = file { - (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) - } else { - (data, response) = try await urlSession.data(for: urlRequest) - } - - if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { - throw error - } - - return data + try await execute(endpoint: endpoint, configuring: configuring).data } /// Performs call to endpoint which returns data that are supposed to be parsed by the decoder. @@ -67,22 +28,80 @@ public extension URLServer { /// - Throws: Throws an APIError if the request fails, server returns an error, or decoding fails /// - Returns: Instance of the required type func call(response endpoint: EP, configuring: RequestConfiguring? = nil) async throws -> EP.Response { + let result = try await execute(endpoint: endpoint, configuring: configuring) + do { + return try decoding.decode(data: result.data) + } catch { + result.observers.forEach { $0.didFail(request: result.request, error: error) } + throw error + } + } +} + +// MARK: - Private helpers + +private struct ExecuteResult { + let data: Data + let request: URLRequest + let observers: [AnyObserverToken] +} + +private extension URLServer { + + /// Core execution method that builds the request, notifies observers, performs the network call, + /// and handles errors. + func execute(endpoint: Endpoint, configuring: RequestConfiguring?) async throws -> ExecuteResult { var urlRequest = try await buildRequest(endpoint: endpoint) try await configuring?.configure(&urlRequest) + let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + let file = (endpoint as? UploadEndpoint)?.file let (data, response): (Data, URLResponse) - if let file = file { - (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) - } else { - (data, response) = try await urlSession.data(for: urlRequest) + do { + if let file { + (data, response) = try await urlSession.upload(for: urlRequest, fromFile: file) + } else { + (data, response) = try await urlSession.data(for: urlRequest) + } + } catch { + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: nil, data: nil) } + observers.forEach { $0.didFail(request: urlRequest, error: error) } + throw error } + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: data) } + if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { + observers.forEach { $0.didFail(request: urlRequest, error: error) } throw error } - return try decoding.decode(data: data) + return ExecuteResult(data: data, request: urlRequest, observers: observers) + } +} + +/// Type-erasing wrapper that captures an observer and its context from `willSendRequest`. +final class AnyObserverToken: @unchecked Sendable { + private let _didReceiveResponse: (URLRequest, URLResponse?, Data?) -> Void + private let _didFail: (URLRequest, Error) -> Void + + init(observer: O, request: URLRequest) { + let context = observer.willSendRequest(request) + _didReceiveResponse = { req, resp, data in + observer.didReceiveResponse(for: req, response: resp, data: data, context: context) + } + _didFail = { req, error in + observer.didFail(request: req, error: error, context: context) + } + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?) { + _didReceiveResponse(request, response, data) + } + + func didFail(request: URLRequest, error: Error) { + _didFail(request, error) } } diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift index 8ceab0f..31453df 100644 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ b/Sources/FTAPIKit/URLServer+Download.swift @@ -10,14 +10,26 @@ public extension URLServer { /// - Returns: The location of a temporary file where the server's response is stored. /// You must move this file or open it for reading before the async function returns. Otherwise, the file /// is deleted, and the data is lost. - @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { var urlRequest = try await buildRequest(endpoint: endpoint) try await configuring?.configure(&urlRequest) - let (localURL, response) = try await urlSession.download(for: urlRequest) + + let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + + let (localURL, response): (URL, URLResponse) + do { + (localURL, response) = try await urlSession.download(for: urlRequest) + } catch { + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: nil, data: nil) } + observers.forEach { $0.didFail(request: urlRequest, error: error) } + throw error + } + + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } let urlData = localURL.absoluteString.data(using: .utf8) if let error = ErrorType(data: urlData, response: response, error: nil, decoding: decoding) { + observers.forEach { $0.didFail(request: urlRequest, error: error) } throw error } diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index aaf4c74..ecafe23 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -1,53 +1,43 @@ import Foundation -#if os(Linux) -import FoundationNetworking -#endif - -/// The standard implementation of the `Server` protocol based on `Foundation.URLSession` networking -/// stack. -/// -/// The URLServer provides various means of executing its requests, depending on the needs of the programmer. -/// Extension for the following approaches are currently implemented: -/// - Completion handler based approach (the baseline implementation) -/// - Async/Await pattern -/// - Combine bindings -/// -/// It provides a default implementation for -/// `var decoding: Decoding`, `var encoding: Encoding` and -/// `func buildRequest(endpoint: Endpoint) async throws -> URLRequest`. The `URLRequest` -/// creation is implemented in `struct URLRequestBuilder`. -/// -/// In case that the requests need to cooperate with other services, like OAuth, override the default implementation -/// of `func buildRequest`, use -/// `func buildStandardRequest(endpoint: Endpoint) throws -> URLRequest` within our new -/// implementation, and use the `URLRequest` as a baseline. +/// `URLServer` represents a single web service and provides a gateway to an API over HTTP. /// -/// - Note: The standard implementation is specifically made in order to let you customize: -/// * Error handling -/// * URLRequest creation -/// * Encoding and decoding -/// * URLSession configuration +/// Conforming type should have the ability to encode/decode data into requests and responses +/// using the `Codable` conformances and strongly typed coding of the Swift language. /// -/// In case you need further customization, it might not be worth the time required to bend the standard -/// implementation to your needs. +/// The protocol provides default implementations for `decoding`, `encoding`, `urlSession`, +/// and `networkObservers`. Only `baseUri` must be provided by conforming types. /// -public protocol URLServer: Server where Request == URLRequest { - /// Error type which is initialized during the request execution - /// - Note: Provided default implementation. +/// In case that the requests need to cooperate with other services, like OAuth, override the +/// default implementation of `buildRequest`, use `buildStandardRequest(endpoint:)` within +/// your new implementation, and use the `URLRequest` as a baseline. +public protocol URLServer { + /// Error type which is initialized during the request execution. associatedtype ErrorType: APIError = APIError.Standard - /// Base URI of the server + /// Base URI of the server. var baseUri: URL { get } - /// `URLSession` instance, which is used for task execution - /// - Note: Provided default implementation. + /// `URLSession` instance, which is used for task execution. var urlSession: URLSession { get } + /// The instance providing strongly typed decoding. + var decoding: Decoding { get } + + /// The instance providing strongly typed encoding. + var encoding: Encoding { get } + /// Array of network observers. /// Each observer receives lifecycle callbacks for every request. - /// - Note: Provided default implementation returns empty array. var networkObservers: [any NetworkObserver] { get } + + /// Takes a Swift description of an endpoint call and transforms it into a valid request. + /// + /// This is useful in cases when the API uses OAuth or some other token-based authorization, + /// where the request may be delayed before the valid tokens are received. + /// - Parameter endpoint: An instance of an endpoint representing a call. + /// - Returns: A valid `URLRequest`. + func buildRequest(endpoint: Endpoint) async throws -> URLRequest } public extension URLServer { From 4d4bd721224ebc1dd07be394ef7d247477c59dff Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 14:11:34 +0100 Subject: [PATCH 25/30] Update docs --- CLAUDE.md | 10 ++++----- Package.swift | 8 ++++---- README.md | 56 +-------------------------------------------------- 3 files changed, 10 insertions(+), 64 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a283801..ae2ed05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ FTAPIKit is a declarative async/await REST API framework for Swift using Swift C - `RequestConfiguring` protocol for per-request configuration at call site - `NetworkObserver` protocol for request lifecycle monitoring (logging, analytics) - Swift 6 concurrency safety with Sendable requirements -- Cross-platform support: iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ +- Cross-platform support: iOS 15+, macOS 12+, tvOS 15+, watchOS 8+ ## Build and Test Commands @@ -129,10 +129,10 @@ The project uses **Swift Package Manager** exclusively. See `Package.swift`. ### Platform Support Minimum deployment targets: -- iOS 17+ -- macOS 14+ -- tvOS 17+ -- watchOS 10+ +- iOS 15+ +- macOS 12+ +- tvOS 15+ +- watchOS 8+ ## Testing Approach diff --git a/Package.swift b/Package.swift index f95a8ed..a861bb4 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,10 @@ import PackageDescription let package = Package( name: "FTAPIKit", platforms: [ - .iOS(.v17), - .macOS(.v14), - .tvOS(.v17), - .watchOS(.v10) + .iOS(.v15), + .macOS(.v12), + .tvOS(.v15), + .watchOS(.v8) ], products: [ .library( diff --git a/README.md b/README.md index 15eae9f..33d7052 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built for Swift 6.1+ with full concurrency safety. ## Requirements - Swift 6.1+ -- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ +- iOS 15+, macOS 12+, tvOS 15+, watchOS 8+ ## Installation @@ -220,60 +220,6 @@ struct MyServer: URLServer { } ``` -## Migrating from 1.x to 2.0 - -FTAPIKit 2.0 is a major rewrite focused on Swift Concurrency. Here are the breaking changes: - -### Server Protocol Simplified - -The separate `Server` and `URLServer` protocols have been merged into a single `URLServer` protocol. -If you previously conformed to the abstract `Server` protocol directly, switch to `URLServer`. - -### Completion Handlers & Combine Removed - -All API calls now use async/await exclusively: - -```swift -// Old (1.x) -server.call(response: endpoint) { result in ... } -server.publisher(response: endpoint).sink { ... } - -// New (2.0) -let response = try await server.call(response: endpoint) -``` - -### buildRequest is Now Async - -If you override `buildRequest`, you must mark it as `async`: - -```swift -func buildRequest(endpoint: Endpoint) async throws -> URLRequest { - var request = try buildStandardRequest(endpoint: endpoint) - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request -} -``` - -### Response Types Must Be Sendable - -All `ResponseEndpoint` response types must conform to `Sendable`: - -```swift -struct User: Codable, Sendable { - let id: Int - let name: String -} -``` - -### CocoaPods & Linux Removed - -FTAPIKit 2.0 is distributed exclusively via Swift Package Manager. -Linux support has been dropped. - -### Minimum Platform Versions Raised - -- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+ - ## Contributors Current maintainer and main contributor is [Matěj Kašpar Jirásek](https://github.com/mkj-is), . From 254359d65ed5a9cab9289813f27f9de43fa3eb49 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 15:09:58 +0100 Subject: [PATCH 26/30] CR updates --- .gitignore | 7 -- CLAUDE.md | 2 +- README.md | 46 +++++++-- Sources/FTAPIKit/Coding.swift | 7 +- .../Documentation.docc/Documentation.md | 18 ++-- Sources/FTAPIKit/Endpoint.swift | 6 +- Sources/FTAPIKit/NetworkObserver.swift | 11 ++- Sources/FTAPIKit/URLServer+Async.swift | 37 +++++-- Sources/FTAPIKit/URLServer+Download.swift | 13 +-- Sources/FTAPIKit/URLServer.swift | 2 +- .../AsyncBuildRequestTests.swift | 22 ++++- Tests/FTAPIKitTests/EndpointTypeTests.swift | 61 ++++++++++++ Tests/FTAPIKitTests/ErrorHandlingTests.swift | 96 +++++++++++++++++++ .../Mockups/MockNetworkObserver.swift | 24 +++-- .../RequestConfiguringTests.swift | 42 +++++++- 15 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 Tests/FTAPIKitTests/EndpointTypeTests.swift create mode 100644 Tests/FTAPIKitTests/ErrorHandlingTests.swift diff --git a/.gitignore b/.gitignore index 1dc3bae..c6115e7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,11 +34,4 @@ FTAPIKit.xcodeproj # Claude Code .claude -# fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/ -fastlane/test_output/ -fastlane/README.md - *.xcuserstate diff --git a/CLAUDE.md b/CLAUDE.md index ae2ed05..6498bde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ The framework is built around two core protocols: ### Module Organization **Source Structure** (`Sources/FTAPIKit/`): -- Core protocols: `Server.swift`, `Endpoint.swift` +- Core protocols: `URLServer.swift`, `Endpoint.swift` - Request building: `URLRequestBuilder.swift`, `RequestConfiguring.swift` - Async execution: `URLServer+Async.swift`, `URLServer+Download.swift` - Observers: `NetworkObserver.swift` diff --git a/README.md b/README.md index 33d7052..2da6b84 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -FTAPIKit logo +FTAPIKit logo # FTAPIKit @@ -45,10 +45,11 @@ are separated in various protocols for convenience. - `Endpoint` protocol has empty body. Typically used in `GET` endpoints. - `DataEndpoint` sends provided data in body. -- `UploadEndpoint` uploads file using `InputStream`. +- `UploadEndpoint` uploads file from a URL using `URLSession` upload task. - `MultipartEndpoint` combines body parts into `InputStream` and sends them to server. Body parts are represented by `MultipartBodyPart` struct and provided to the endpoint in an array. +- `URLEncodedEndpoint` sends body in URL query format. - `RequestEndpoint` has encodable request which is encoded using encoding of the `URLServer` instance. @@ -127,14 +128,7 @@ When we have server and endpoint defined we can call the web service using async let server = HTTPBinServer() let endpoint = UpdateUserEndpoint(request: user) -Task { - do { - let updatedUser = try await server.call(response: endpoint) - // Handle success - } catch { - // Handle error - } -} +let updatedUser = try await server.call(response: endpoint) ``` ### Async buildRequest @@ -220,6 +214,38 @@ struct MyServer: URLServer { } ``` +### Error Handling + +The framework uses the `APIError` protocol for error handling. The default implementation `APIError.Standard` covers common cases: + +```swift +do { + let response = try await server.call(response: endpoint) +} catch let error as APIError.Standard { + switch error { + case .connection(let urlError): + // Network connectivity issue + case .client(let statusCode, _, _): + // 4xx client error + case .server(let statusCode, _, _): + // 5xx server error + case .decoding(let decodingError): + // Response parsing failed + default: + break + } +} +``` + +For custom error parsing, define a type conforming to `APIError` and set it as the `ErrorType` on your server: + +```swift +struct MyServer: URLServer { + typealias ErrorType = MyCustomError + let baseUri = URL(string: "https://api.example.com")! +} +``` + ## Contributors Current maintainer and main contributor is [Matěj Kašpar Jirásek](https://github.com/mkj-is), . diff --git a/Sources/FTAPIKit/Coding.swift b/Sources/FTAPIKit/Coding.swift index 04efbec..52cdcc7 100644 --- a/Sources/FTAPIKit/Coding.swift +++ b/Sources/FTAPIKit/Coding.swift @@ -8,6 +8,9 @@ public protocol Encoding: Sendable { func encode(_ object: T) throws -> Data /// Allows modification of `URLRequest`. Enables things like adding `Content-Type` header etc. + /// + /// Default implementation is a no-op. Custom `Encoding` types should override this to set + /// the appropriate `Content-Type` header. See ``JSONEncoding`` for an example. /// - Parameter request: Request which can be modified. func configure(request: inout URLRequest) throws } @@ -21,7 +24,7 @@ public protocol Decoding: Sendable { func decode(data: Data) throws -> T } -/// Type-erased JSON encoder for use with types conforming to ``Server`` protocol. +/// Type-erased JSON encoder for use with types conforming to ``URLServer`` protocol. public struct JSONEncoding: Encoding { private let encoder: JSONEncoder @@ -48,7 +51,7 @@ public struct JSONEncoding: Encoding { } } -/// Type-erased JSON decoder for use with types conforming to ``Server`` protocol. +/// Type-erased JSON decoder for use with types conforming to ``URLServer`` protocol. public struct JSONDecoding: Decoding { private let decoder: JSONDecoder diff --git a/Sources/FTAPIKit/Documentation.docc/Documentation.md b/Sources/FTAPIKit/Documentation.docc/Documentation.md index e16a7ce..c962ddf 100644 --- a/Sources/FTAPIKit/Documentation.docc/Documentation.md +++ b/Sources/FTAPIKit/Documentation.docc/Documentation.md @@ -1,12 +1,12 @@ # ``FTAPIKit`` -Declarative, generic and protocol-oriented REST API framework using `URLSession` and `Codable` +Declarative async/await REST API framework using Swift Concurrency and Codable. ## Overview -Declarative and generic REST API framework using `Codable`. -With standard implementation using `URLSesssion` and JSON encoder/decoder. -Easily extensible for your asynchronous framework or networking stack. +Declarative async/await REST API framework using `Codable`. +With standard implementation using `URLSession` and JSON encoder/decoder. +Built for Swift 6.1+ with full concurrency safety. ![Tree with a API Client root element. Its branches are servers. Each server branch has some endpoint branches.](Architecture) @@ -14,7 +14,6 @@ Easily extensible for your asynchronous framework or networking stack. ### Server -- ``Server`` - ``URLServer`` ### Endpoint @@ -29,6 +28,10 @@ Easily extensible for your asynchronous framework or networking stack. - ``RequestEndpoint`` - ``RequestResponseEndpoint`` +### Request Configuration + +- ``RequestConfiguring`` + ### Endpoint configuration - ``HTTPMethod`` @@ -41,7 +44,10 @@ Easily extensible for your asynchronous framework or networking stack. - ``JSONEncoding`` - ``Decoding`` - ``JSONDecoding`` -- ``URLRequestEncoding`` + +### Observers + +- ``NetworkObserver`` ### Error handling diff --git a/Sources/FTAPIKit/Endpoint.swift b/Sources/FTAPIKit/Endpoint.swift index 652762d..92bdf40 100644 --- a/Sources/FTAPIKit/Endpoint.swift +++ b/Sources/FTAPIKit/Endpoint.swift @@ -4,7 +4,7 @@ import Foundation /// data and parameters which are sent to it. /// /// Recommended conformance of this protocol is implemented using `struct`. It is -/// of course possible using `enum` or `class`. Endpoints are are not designed +/// of course possible using `enum` or `class`. Endpoints are not designed /// to be referenced and used instantly after creation, so no memory usage is required. /// The case for not using enums is long-term sustainability. Enums tend to have many /// cases and information about one endpoint is spreaded all over the files. Also, @@ -64,7 +64,7 @@ public protocol URLEncodedEndpoint: Endpoint { } /// An abstract representation of endpoint, body of which is represented by Swift encodable type. It serves as an -/// abstraction between the ``Server`` protocol and more specific ``Endpoint`` conforming protocols. +/// abstraction between the ``URLServer`` protocol and more specific ``Endpoint`` conforming protocols. /// Do not use this protocol to represent an encodable endpoint, use ``RequestEndpoint`` instead. public protocol EncodableEndpoint: Endpoint { @@ -78,7 +78,7 @@ public protocol EncodableEndpoint: Endpoint { /// for automatic deserialization. public protocol ResponseEndpoint: Endpoint { /// Associated type describing the return type conforming to `Decodable` - /// protocol. This is only a phantom-type used by `APIAdapter` + /// protocol. This is only a phantom-type used by ``URLServer`` /// for automatic decoding/deserialization of API results. associatedtype Response: Decodable & Sendable } diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift index 9a0b69f..7e39df9 100644 --- a/Sources/FTAPIKit/NetworkObserver.swift +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -8,10 +8,11 @@ import Foundation /// The `Context` associated type allows passing correlation data (request ID, start time, etc.) /// through the request lifecycle: /// 1. `willSendRequest` is called before the request starts and returns a `Context` value -/// 2. `didReceiveResponse` is always called with the raw response data (useful for debugging) +/// 2. `didReceiveResponse` is called when a response is received from the server /// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error) -/// 4. If the observer is deallocated before the request completes, the context is discarded -/// and no completion callback is invoked +/// +/// - Note: Observers are strongly retained for the duration of a request to ensure lifecycle callbacks +/// are always delivered. public protocol NetworkObserver: AnyObject, Sendable { associatedtype Context: Sendable @@ -22,8 +23,8 @@ public protocol NetworkObserver: AnyObject, Sendable { /// Called when a response is received from the server. /// - /// This is always called with the raw response data, even if processing subsequently fails. - /// This allows observers to inspect the actual response for debugging purposes. + /// Only called when the server responds. Not called on network errors (e.g. no connectivity). + /// Called even if the response indicates an error (4xx, 5xx), before `didFail`. /// - Parameters: /// - request: The original request /// - response: The URL response (may be HTTPURLResponse) diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index d91d893..bae3ee2 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -51,10 +51,7 @@ private extension URLServer { /// Core execution method that builds the request, notifies observers, performs the network call, /// and handles errors. func execute(endpoint: Endpoint, configuring: RequestConfiguring?) async throws -> ExecuteResult { - var urlRequest = try await buildRequest(endpoint: endpoint) - try await configuring?.configure(&urlRequest) - - let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + let (urlRequest, observers) = try await prepareRequest(endpoint: endpoint, configuring: configuring) let file = (endpoint as? UploadEndpoint)?.file @@ -66,19 +63,43 @@ private extension URLServer { (data, response) = try await urlSession.data(for: urlRequest) } } catch { - observers.forEach { $0.didReceiveResponse(for: urlRequest, response: nil, data: nil) } observers.forEach { $0.didFail(request: urlRequest, error: error) } throw error } observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: data) } + try checkForError(data: data, response: response, request: urlRequest, observers: observers) + + return ExecuteResult(data: data, request: urlRequest, observers: observers) + } +} + +// MARK: - Shared helpers +extension URLServer { + + /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer tokens. + func prepareRequest( + endpoint: Endpoint, + configuring: RequestConfiguring? + ) async throws -> (URLRequest, [AnyObserverToken]) { + var urlRequest = try await buildRequest(endpoint: endpoint) + try await configuring?.configure(&urlRequest) + let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + return (urlRequest, observers) + } + + /// Checks the response for API errors and notifies observers on failure. + func checkForError( + data: Data?, + response: URLResponse, + request: URLRequest, + observers: [AnyObserverToken] + ) throws { if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { - observers.forEach { $0.didFail(request: urlRequest, error: error) } + observers.forEach { $0.didFail(request: request, error: error) } throw error } - - return ExecuteResult(data: data, request: urlRequest, observers: observers) } } diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift index 31453df..c0d66d5 100644 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ b/Sources/FTAPIKit/URLServer+Download.swift @@ -11,27 +11,18 @@ public extension URLServer { /// You must move this file or open it for reading before the async function returns. Otherwise, the file /// is deleted, and the data is lost. func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { - var urlRequest = try await buildRequest(endpoint: endpoint) - try await configuring?.configure(&urlRequest) - - let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + let (urlRequest, observers) = try await prepareRequest(endpoint: endpoint, configuring: configuring) let (localURL, response): (URL, URLResponse) do { (localURL, response) = try await urlSession.download(for: urlRequest) } catch { - observers.forEach { $0.didReceiveResponse(for: urlRequest, response: nil, data: nil) } observers.forEach { $0.didFail(request: urlRequest, error: error) } throw error } observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } - - let urlData = localURL.absoluteString.data(using: .utf8) - if let error = ErrorType(data: urlData, response: response, error: nil, decoding: decoding) { - observers.forEach { $0.didFail(request: urlRequest, error: error) } - throw error - } + try checkForError(data: nil, response: response, request: urlRequest, observers: observers) return localURL } diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index ecafe23..2de41c9 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -11,7 +11,7 @@ import Foundation /// In case that the requests need to cooperate with other services, like OAuth, override the /// default implementation of `buildRequest`, use `buildStandardRequest(endpoint:)` within /// your new implementation, and use the `URLRequest` as a baseline. -public protocol URLServer { +public protocol URLServer: Sendable { /// Error type which is initialized during the request execution. associatedtype ErrorType: APIError = APIError.Standard diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift index 2ff5274..7a462b4 100644 --- a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -68,14 +68,26 @@ private struct AppConfiguration { let deviceId: String } -private final class MockTokenManager: Sendable { - nonisolated(unsafe) var currentToken: String = "initial-token" - nonisolated(unsafe) var refreshCalled = false +private final class MockTokenManager: @unchecked Sendable { + private let lock = NSLock() + private var _currentToken: String = "initial-token" + private var _refreshCalled = false + + var currentToken: String { + get { lock.withLock { _currentToken } } + set { lock.withLock { _currentToken = newValue } } + } + + var refreshCalled: Bool { + lock.withLock { _refreshCalled } + } func refreshIfNeeded() async { try? await Task.sleep(nanoseconds: 10_000_000) - refreshCalled = true - currentToken = "refreshed-token-456" + lock.withLock { + _refreshCalled = true + _currentToken = "refreshed-token-456" + } } } diff --git a/Tests/FTAPIKitTests/EndpointTypeTests.swift b/Tests/FTAPIKitTests/EndpointTypeTests.swift new file mode 100644 index 0000000..fa01304 --- /dev/null +++ b/Tests/FTAPIKitTests/EndpointTypeTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing + +@testable import FTAPIKit + +/// Tests for various endpoint types, ported from the deleted ResponseTests.swift +@Suite +struct EndpointTypeTests { + + @Test + func responseEndpoint() async throws { + let server = HTTPBinServer() + let response = try await server.call(response: JSONResponseEndpoint()) + #expect(!response.slideshow.title.isEmpty) + } + + @Test + func requestResponseEndpoint() async throws { + let server = HTTPBinServer() + let user = User(uuid: UUID(), name: "Test User", age: 30) + let endpoint = UpdateUserEndpoint(request: user) + let response = try await server.call(response: endpoint) + #expect(response.json == user) + } + + @Test + func urlEncodedEndpoint() async throws { + let server = HTTPBinServer() + let endpoint = TestURLEncodedEndpoint() + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func multipartEndpoint() async throws { + let server = HTTPBinServer() + let file = File() + try file.write() + let endpoint = try TestMultipartEndpoint(file: file) + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func uploadEndpoint() async throws { + let server = HTTPBinServer() + let file = File() + try file.write() + let endpoint = TestUploadEndpoint(file: file) + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } + + @Test + func downloadEndpoint() async throws { + let server = HTTPBinServer() + let endpoint = ImageEndpoint() + let url = try await server.download(endpoint: endpoint) + #expect(FileManager.default.fileExists(atPath: url.path)) + } +} diff --git a/Tests/FTAPIKitTests/ErrorHandlingTests.swift b/Tests/FTAPIKitTests/ErrorHandlingTests.swift new file mode 100644 index 0000000..b627dd6 --- /dev/null +++ b/Tests/FTAPIKitTests/ErrorHandlingTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing + +@testable import FTAPIKit + +/// Tests for error handling, ported from the deleted ResponseTests.swift +@Suite +struct ErrorHandlingTests { + + @Test + func clientError() async throws { + let server = HTTPBinServer() + let endpoint = NotFoundEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected client error for 404") + } catch let error as APIError.Standard { + guard case .client(let statusCode, _, _) = error else { + Issue.record("Expected .client error, got \(error)") + return + } + #expect(statusCode == 404) + } + } + + @Test + func serverError() async throws { + let server = HTTPBinServer() + let endpoint = ServerErrorEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected server error for 500") + } catch let error as APIError.Standard { + guard case .server(let statusCode, _, _) = error else { + Issue.record("Expected .server error, got \(error)") + return + } + #expect(statusCode == 500) + } + } + + @Test + func connectionError() async throws { + let server = NonExistingServer() + let endpoint = GetEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected connection error") + } catch let error as APIError.Standard { + guard case .connection = error else { + Issue.record("Expected .connection error, got \(error)") + return + } + } + } + + @Test + func decodingError() async throws { + let server = HTTPBinServer() + let user = User(uuid: UUID(), name: "Test", age: 25) + let endpoint = FailingUpdateUserEndpoint(request: user) + do { + _ = try await server.call(response: endpoint) + Issue.record("Expected decoding error") + } catch is DecodingError { + // Expected: response wrapper doesn't match User directly + } + } + + @Test + func customErrorType() async throws { + let server = ErrorThrowingServer() + let endpoint = NotFoundEndpoint() + do { + _ = try await server.call(data: endpoint) + Issue.record("Expected ThrowawayAPIError") + } catch is ThrowawayAPIError { + // Expected + } + } + + @Test + func emptyResponse() async throws { + let server = HTTPBinServer() + let endpoint = NoContentEndpoint() + try await server.call(endpoint: endpoint) + } + + @Test + func authorization() async throws { + let server = HTTPBinServer() + let endpoint = AuthorizedEndpoint() + let data = try await server.call(data: endpoint) + #expect(!data.isEmpty) + } +} diff --git a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift index 2f41a60..8254adc 100644 --- a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift +++ b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift @@ -7,23 +7,31 @@ struct MockContext: Sendable { } final class MockNetworkObserver: NetworkObserver, @unchecked Sendable { - var willSendCount = 0 - var didReceiveCount = 0 - var didFailCount = 0 - var lastRequestId: String? + private let lock = NSLock() + private var _willSendCount = 0 + private var _didReceiveCount = 0 + private var _didFailCount = 0 + private var _lastRequestId: String? + + var willSendCount: Int { lock.withLock { _willSendCount } } + var didReceiveCount: Int { lock.withLock { _didReceiveCount } } + var didFailCount: Int { lock.withLock { _didFailCount } } + var lastRequestId: String? { lock.withLock { _lastRequestId } } func willSendRequest(_ request: URLRequest) -> MockContext { - willSendCount += 1 let context = MockContext(requestId: UUID().uuidString, startTime: Date()) - lastRequestId = context.requestId + lock.withLock { + _willSendCount += 1 + _lastRequestId = context.requestId + } return context } func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: MockContext) { - didReceiveCount += 1 + lock.withLock { _didReceiveCount += 1 } } func didFail(request: URLRequest, error: Error, context: MockContext) { - didFailCount += 1 + lock.withLock { _didFailCount += 1 } } } diff --git a/Tests/FTAPIKitTests/RequestConfiguringTests.swift b/Tests/FTAPIKitTests/RequestConfiguringTests.swift index fa2407e..f5661b6 100644 --- a/Tests/FTAPIKitTests/RequestConfiguringTests.swift +++ b/Tests/FTAPIKitTests/RequestConfiguringTests.swift @@ -62,6 +62,23 @@ struct RequestConfiguringTests { let server = HTTPBinServer() try await server.call(endpoint: NoContentEndpoint(), configuring: config) } + + @Test + func configuringOverridesBuildRequest() async throws { + let server = UserAgentServer() + let config = HeaderAddingConfiguration(headerName: "User-Agent", headerValue: "ConfigOverride/1.0") + let data = try await server.call(data: GetEndpoint(), configuring: config) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["User-Agent"] == "ConfigOverride/1.0") + } + + @Test + func downloadWithConfiguration() async throws { + let config = HeaderAddingConfiguration(headerName: "X-Download-Id", headerValue: "dl-456") + let server = HTTPBinServer() + let url = try await server.download(endpoint: ImageEndpoint(), configuring: config) + #expect(FileManager.default.fileExists(atPath: url.path)) + } } // MARK: - Test Configurations @@ -90,18 +107,37 @@ private struct AsyncTokenConfiguration: RequestConfiguring { } } +// MARK: - Test Server + +/// Server that sets User-Agent in buildRequest, to verify configuring can override it. +private struct UserAgentServer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + + func buildRequest(endpoint: Endpoint) async throws -> URLRequest { + var request = try buildStandardRequest(endpoint: endpoint) + request.setValue("BuildRequest/1.0", forHTTPHeaderField: "User-Agent") + return request + } +} + // MARK: - Test Helpers private enum ConfigurationError: Error, Equatable { case tokenRefreshFailed } -private final class MockAsyncTokenManager: Sendable { - nonisolated(unsafe) var refreshCalled = false +private final class MockAsyncTokenManager: @unchecked Sendable { + private let lock = NSLock() + private var _refreshCalled = false + + var refreshCalled: Bool { + lock.withLock { _refreshCalled } + } func getValidToken() async -> String { try? await Task.sleep(nanoseconds: 10_000_000) - refreshCalled = true + lock.withLock { _refreshCalled = true } return "refreshed-token" } } From a58375130fd0fd44c721c9d7cd34bf760e0d6470 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 15:39:18 +0100 Subject: [PATCH 27/30] Code review updates --- .swiftlint.yml | 10 --- CLAUDE.md | 2 +- Sources/FTAPIKit/MultipartFormData.swift | 4 ++ Sources/FTAPIKit/NetworkObserver.swift | 3 +- Sources/FTAPIKit/RequestConfiguring.swift | 25 ++++++++ Sources/FTAPIKit/URLQuery.swift | 5 +- Sources/FTAPIKit/URLRequestBuilder.swift | 4 ++ Sources/FTAPIKit/URLServer+Async.swift | 64 ++++++++++++++----- Sources/FTAPIKit/URLServer+Download.swift | 29 --------- .../AsyncBuildRequestTests.swift | 44 +------------ Tests/FTAPIKitTests/AsyncTests.swift | 3 +- Tests/FTAPIKitTests/EndpointTypeTests.swift | 5 +- Tests/FTAPIKitTests/ErrorHandlingTests.swift | 15 ++++- .../Mockups/HTTPBinResponse.swift | 6 ++ .../Mockups/MockTokenManager.swift | 34 ++++++++++ Tests/FTAPIKitTests/Mockups/Models.swift | 4 ++ Tests/FTAPIKitTests/Mockups/Servers.swift | 21 ++++-- .../RequestConfiguringTests.swift | 49 +++++++------- Tests/FTAPIKitTests/URLQueryTests.swift | 6 ++ 19 files changed, 196 insertions(+), 137 deletions(-) delete mode 100644 Sources/FTAPIKit/URLServer+Download.swift create mode 100644 Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift create mode 100644 Tests/FTAPIKitTests/Mockups/MockTokenManager.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 5812aa1..884c276 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -17,7 +17,6 @@ opt_in_rules: - contains_over_first_not_nil - contains_over_range_nil_comparison - convenience_type - - discouraged_object_literal - discouraged_optional_boolean - empty_collection_literal - empty_count @@ -45,22 +44,17 @@ opt_in_rules: - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - - nimble_operator - number_separator - - object_literal - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension - pattern_matching_keywords - prefer_self_type_over_type_of_self - - private_action - - private_outlet - prohibited_super_call - reduce_into - redundant_nil_coalescing - required_enum_case - - single_test_class - sorted_first_last - sorted_imports - static_operator @@ -73,10 +67,6 @@ opt_in_rules: - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - yoda_condition -analyzer_rules: - - unused_declaration - - unused_import - # Rule configurations identifier_name: excluded: diff --git a/CLAUDE.md b/CLAUDE.md index 6498bde..e74be96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The framework is built around two core protocols: **Source Structure** (`Sources/FTAPIKit/`): - Core protocols: `URLServer.swift`, `Endpoint.swift` - Request building: `URLRequestBuilder.swift`, `RequestConfiguring.swift` -- Async execution: `URLServer+Async.swift`, `URLServer+Download.swift` +- Async execution: `URLServer+Async.swift` (includes download) - Observers: `NetworkObserver.swift` - Utilities: `Coding.swift`, `URLQuery.swift`, `MultipartFormData.swift`, etc. - Error handling: `APIError.swift`, `APIError+Standard.swift` diff --git a/Sources/FTAPIKit/MultipartFormData.swift b/Sources/FTAPIKit/MultipartFormData.swift index fae0ac4..416e168 100644 --- a/Sources/FTAPIKit/MultipartFormData.swift +++ b/Sources/FTAPIKit/MultipartFormData.swift @@ -11,6 +11,10 @@ struct MultipartFormData { self.boundary = boundary } + /// Returns the size of the temporary file containing the multipart body. + /// + /// - Important: Must be called after ``inputStream()`` which writes the file. + /// Returns `nil` if the file does not exist yet. var contentLength: Int64? { (try? FileManager.default.attributesOfItem(atPath: temporaryUrl.path)[.size] as? Int64)?.flatMap { $0 } } diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift index 7e39df9..f086e32 100644 --- a/Sources/FTAPIKit/NetworkObserver.swift +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -12,7 +12,8 @@ import Foundation /// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error) /// /// - Note: Observers are strongly retained for the duration of a request to ensure lifecycle callbacks -/// are always delivered. +/// are always delivered. Observers must not hold strong references back to the server to avoid +/// retain cycles. public protocol NetworkObserver: AnyObject, Sendable { associatedtype Context: Sendable diff --git a/Sources/FTAPIKit/RequestConfiguring.swift b/Sources/FTAPIKit/RequestConfiguring.swift index c82db5d..3970540 100644 --- a/Sources/FTAPIKit/RequestConfiguring.swift +++ b/Sources/FTAPIKit/RequestConfiguring.swift @@ -23,3 +23,28 @@ public protocol RequestConfiguring: Sendable { /// - Throws: Any error that occurs during configuration (e.g., token refresh failure) func configure(_ request: inout URLRequest) async throws } + +/// Composes multiple ``RequestConfiguring`` instances into a single configuration. +/// +/// Configurations are applied in order, so later configurations can override +/// headers set by earlier ones. +/// +/// ```swift +/// let config = CompositeRequestConfiguring([authConfig, tracingConfig]) +/// let data = try await server.call(data: endpoint, configuring: config) +/// ``` +public struct CompositeRequestConfiguring: RequestConfiguring { + private let configurations: [any RequestConfiguring] + + /// Creates a composite configuration from multiple configurations. + /// - Parameter configurations: Configurations to apply in order. + public init(_ configurations: [any RequestConfiguring]) { + self.configurations = configurations + } + + public func configure(_ request: inout URLRequest) async throws { + for configuration in configurations { + try await configuration.configure(&request) + } + } +} diff --git a/Sources/FTAPIKit/URLQuery.swift b/Sources/FTAPIKit/URLQuery.swift index ce37e9a..9721f64 100644 --- a/Sources/FTAPIKit/URLQuery.swift +++ b/Sources/FTAPIKit/URLQuery.swift @@ -14,11 +14,12 @@ import Foundation /// "child[]": "Maggie" /// ] /// ``` -public struct URLQuery: ExpressibleByDictionaryLiteral { +public struct URLQuery: ExpressibleByDictionaryLiteral, Sendable { /// Array of URL query items. public let items: [URLQueryItem] - init() { + /// Creates an empty URL query. + public init() { self.items = [] } diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index 31c068c..333fc9b 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -27,6 +27,10 @@ struct URLRequestBuilder { return request } + /// Populates the request body based on the endpoint's protocol conformance. + /// + /// - Important: Case ordering matters. If an endpoint conforms to multiple body-providing + /// protocols (e.g., both `DataEndpoint` and `EncodableEndpoint`), the first match wins. private func buildBody(to request: inout URLRequest) throws { switch endpoint { case let endpoint as DataEndpoint: diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index bae3ee2..1d01fbf 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -6,7 +6,8 @@ public extension URLServer { /// - Parameters: /// - endpoint: The endpoint /// - configuring: Optional request configuration to apply before sending - /// - Throws: Throws an APIError if the request fails or server returns an error + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. func call(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws { _ = try await execute(endpoint: endpoint, configuring: configuring) } @@ -15,7 +16,8 @@ public extension URLServer { /// - Parameters: /// - endpoint: The endpoint /// - configuring: Optional request configuration to apply before sending - /// - Throws: Throws an APIError if the request fails or server returns an error + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. /// - Returns: Plain data returned with the HTTP Response func call(data endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> Data { try await execute(endpoint: endpoint, configuring: configuring).data @@ -25,7 +27,9 @@ public extension URLServer { /// - Parameters: /// - endpoint: The endpoint /// - configuring: Optional request configuration to apply before sending - /// - Throws: Throws an APIError if the request fails, server returns an error, or decoding fails + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error. + /// Throws a `DecodingError` directly if response decoding fails (decoding errors are not + /// routed through ``URLServer/ErrorType``). /// - Returns: Instance of the required type func call(response endpoint: EP, configuring: RequestConfiguring? = nil) async throws -> EP.Response { let result = try await execute(endpoint: endpoint, configuring: configuring) @@ -36,6 +40,32 @@ public extension URLServer { throw error } } + + /// Downloads a file from the specified endpoint to a temporary location. + /// - Parameters: + /// - endpoint: The endpoint + /// - configuring: Optional request configuration to apply before sending + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. + /// - Returns: The location of a temporary file where the server's response is stored. + /// You must move this file or open it for reading before the async function returns. Otherwise, the file + /// is deleted, and the data is lost. + func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { + let (urlRequest, observers) = try await prepareObservers(endpoint: endpoint, configuring: configuring) + + let (localURL, response): (URL, URLResponse) + do { + (localURL, response) = try await urlSession.download(for: urlRequest) + } catch { + observers.forEach { $0.didFail(request: urlRequest, error: error) } + throw error + } + + observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } + try checkForError(data: nil, response: response, request: urlRequest, observers: observers) + + return localURL + } } // MARK: - Private helpers @@ -43,7 +73,7 @@ public extension URLServer { private struct ExecuteResult { let data: Data let request: URLRequest - let observers: [AnyObserverToken] + let observers: [BoundObserverContext] } private extension URLServer { @@ -51,7 +81,7 @@ private extension URLServer { /// Core execution method that builds the request, notifies observers, performs the network call, /// and handles errors. func execute(endpoint: Endpoint, configuring: RequestConfiguring?) async throws -> ExecuteResult { - let (urlRequest, observers) = try await prepareRequest(endpoint: endpoint, configuring: configuring) + let (urlRequest, observers) = try await prepareObservers(endpoint: endpoint, configuring: configuring) let file = (endpoint as? UploadEndpoint)?.file @@ -72,20 +102,15 @@ private extension URLServer { return ExecuteResult(data: data, request: urlRequest, observers: observers) } -} - -// MARK: - Shared helpers - -extension URLServer { - /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer tokens. - func prepareRequest( + /// Builds the URLRequest for the endpoint, applies optional configuration, and creates observer contexts. + func prepareObservers( endpoint: Endpoint, configuring: RequestConfiguring? - ) async throws -> (URLRequest, [AnyObserverToken]) { + ) async throws -> (URLRequest, [BoundObserverContext]) { var urlRequest = try await buildRequest(endpoint: endpoint) try await configuring?.configure(&urlRequest) - let observers = networkObservers.map { AnyObserverToken(observer: $0, request: urlRequest) } + let observers = networkObservers.map { BoundObserverContext(observer: $0, request: urlRequest) } return (urlRequest, observers) } @@ -94,7 +119,7 @@ extension URLServer { data: Data?, response: URLResponse, request: URLRequest, - observers: [AnyObserverToken] + observers: [BoundObserverContext] ) throws { if let error = ErrorType(data: data, response: response, error: nil, decoding: decoding) { observers.forEach { $0.didFail(request: request, error: error) } @@ -103,8 +128,13 @@ extension URLServer { } } -/// Type-erasing wrapper that captures an observer and its context from `willSendRequest`. -final class AnyObserverToken: @unchecked Sendable { +/// Captures an observer and its context from `willSendRequest`, binding the lifecycle callbacks +/// for a single request. Created at request start and consumed before the call returns. +/// +/// Marked `@unchecked Sendable` because the stored closures capture a `Sendable` observer +/// and its `Sendable` context. Instances are created and consumed within a single async call +/// and never shared across task boundaries. +private final class BoundObserverContext: @unchecked Sendable { private let _didReceiveResponse: (URLRequest, URLResponse?, Data?) -> Void private let _didFail: (URLRequest, Error) -> Void diff --git a/Sources/FTAPIKit/URLServer+Download.swift b/Sources/FTAPIKit/URLServer+Download.swift deleted file mode 100644 index c0d66d5..0000000 --- a/Sources/FTAPIKit/URLServer+Download.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -public extension URLServer { - - /// Downloads a file from the specified endpoint to a temporary location. - /// - Parameters: - /// - endpoint: The endpoint - /// - configuring: Optional request configuration to apply before sending - /// - Throws: Throws an APIError if the request fails or server returns an error - /// - Returns: The location of a temporary file where the server's response is stored. - /// You must move this file or open it for reading before the async function returns. Otherwise, the file - /// is deleted, and the data is lost. - func download(endpoint: Endpoint, configuring: RequestConfiguring? = nil) async throws -> URL { - let (urlRequest, observers) = try await prepareRequest(endpoint: endpoint, configuring: configuring) - - let (localURL, response): (URL, URLResponse) - do { - (localURL, response) = try await urlSession.download(for: urlRequest) - } catch { - observers.forEach { $0.didFail(request: urlRequest, error: error) } - throw error - } - - observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } - try checkForError(data: nil, response: response, request: urlRequest, observers: observers) - - return localURL - } -} diff --git a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift index 7a462b4..71986bf 100644 --- a/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift +++ b/Tests/FTAPIKitTests/AsyncBuildRequestTests.swift @@ -1,8 +1,7 @@ import Foundation +import FTAPIKit import Testing -@testable import FTAPIKit - /// Tests demonstrating async buildRequest functionality addressing GitHub issue #105 @Suite struct AsyncBuildRequestTests { @@ -11,7 +10,7 @@ struct AsyncBuildRequestTests { func asyncBuildRequestWithDynamicHeaders() async throws { let server = DynamicHeaderServer() let data = try await server.call(data: GetEndpoint()) - let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) #expect(response.headers["X-App-Version"] == "2.0.0") #expect(response.headers["X-Device-Id"] == "test-device-123") } @@ -22,7 +21,7 @@ struct AsyncBuildRequestTests { let server = TokenRefreshServer(tokenManager: tokenManager) tokenManager.currentToken = "expired-token" let data = try await server.call(data: GetEndpoint()) - let response = try JSONDecoder().decode(HTTPBinResponse.self, from: data) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) #expect(response.headers["Authorization"] == "Bearer refreshed-token-456") #expect(tokenManager.refreshCalled) } @@ -67,40 +66,3 @@ private struct AppConfiguration { let appVersion: String let deviceId: String } - -private final class MockTokenManager: @unchecked Sendable { - private let lock = NSLock() - private var _currentToken: String = "initial-token" - private var _refreshCalled = false - - var currentToken: String { - get { lock.withLock { _currentToken } } - set { lock.withLock { _currentToken = newValue } } - } - - var refreshCalled: Bool { - lock.withLock { _refreshCalled } - } - - func refreshIfNeeded() async { - try? await Task.sleep(nanoseconds: 10_000_000) - lock.withLock { - _refreshCalled = true - _currentToken = "refreshed-token-456" - } - } -} - -private struct HTTPBinResponse: Decodable, Sendable { - let headers: [String: String] - - private enum CodingKeys: String, CodingKey { - case headers - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let rawHeaders = try container.decode([String: String].self, forKey: .headers) - self.headers = rawHeaders - } -} diff --git a/Tests/FTAPIKitTests/AsyncTests.swift b/Tests/FTAPIKitTests/AsyncTests.swift index 9554d29..63dc3c4 100644 --- a/Tests/FTAPIKitTests/AsyncTests.swift +++ b/Tests/FTAPIKitTests/AsyncTests.swift @@ -1,8 +1,7 @@ import Foundation +import FTAPIKit import Testing -@testable import FTAPIKit - @Suite struct AsyncTests { diff --git a/Tests/FTAPIKitTests/EndpointTypeTests.swift b/Tests/FTAPIKitTests/EndpointTypeTests.swift index fa01304..c85b68e 100644 --- a/Tests/FTAPIKitTests/EndpointTypeTests.swift +++ b/Tests/FTAPIKitTests/EndpointTypeTests.swift @@ -1,8 +1,7 @@ import Foundation +import FTAPIKit import Testing -@testable import FTAPIKit - /// Tests for various endpoint types, ported from the deleted ResponseTests.swift @Suite struct EndpointTypeTests { @@ -36,6 +35,7 @@ struct EndpointTypeTests { let server = HTTPBinServer() let file = File() try file.write() + defer { file.cleanup() } let endpoint = try TestMultipartEndpoint(file: file) let data = try await server.call(data: endpoint) #expect(!data.isEmpty) @@ -46,6 +46,7 @@ struct EndpointTypeTests { let server = HTTPBinServer() let file = File() try file.write() + defer { file.cleanup() } let endpoint = TestUploadEndpoint(file: file) let data = try await server.call(data: endpoint) #expect(!data.isEmpty) diff --git a/Tests/FTAPIKitTests/ErrorHandlingTests.swift b/Tests/FTAPIKitTests/ErrorHandlingTests.swift index b627dd6..77fcb8c 100644 --- a/Tests/FTAPIKitTests/ErrorHandlingTests.swift +++ b/Tests/FTAPIKitTests/ErrorHandlingTests.swift @@ -1,8 +1,7 @@ import Foundation +import FTAPIKit import Testing -@testable import FTAPIKit - /// Tests for error handling, ported from the deleted ResponseTests.swift @Suite struct ErrorHandlingTests { @@ -20,6 +19,8 @@ struct ErrorHandlingTests { return } #expect(statusCode == 404) + } catch { + Issue.record("Unexpected error type: \(type(of: error))") } } @@ -36,6 +37,8 @@ struct ErrorHandlingTests { return } #expect(statusCode == 500) + } catch { + Issue.record("Unexpected error type: \(type(of: error))") } } @@ -51,6 +54,8 @@ struct ErrorHandlingTests { Issue.record("Expected .connection error, got \(error)") return } + } catch { + Issue.record("Unexpected error type: \(type(of: error))") } } @@ -64,6 +69,8 @@ struct ErrorHandlingTests { Issue.record("Expected decoding error") } catch is DecodingError { // Expected: response wrapper doesn't match User directly + } catch { + Issue.record("Unexpected error type: \(type(of: error))") } } @@ -76,6 +83,8 @@ struct ErrorHandlingTests { Issue.record("Expected ThrowawayAPIError") } catch is ThrowawayAPIError { // Expected + } catch { + Issue.record("Unexpected error type: \(type(of: error))") } } @@ -90,7 +99,7 @@ struct ErrorHandlingTests { func authorization() async throws { let server = HTTPBinServer() let endpoint = AuthorizedEndpoint() - let data = try await server.call(data: endpoint) + let data = try await server.call(data: endpoint, configuring: BearerTokenConfiguration()) #expect(!data.isEmpty) } } diff --git a/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift b/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift new file mode 100644 index 0000000..1d21162 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/HTTPBinResponse.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Shared response model for httpbin.org endpoints that return headers. +struct HTTPBinHeadersResponse: Decodable, Sendable { + let headers: [String: String] +} diff --git a/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift b/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift new file mode 100644 index 0000000..2fb2758 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/MockTokenManager.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Thread-safe mock token manager for testing async token refresh patterns. +final class MockTokenManager: @unchecked Sendable { + private let lock = NSLock() + private var _currentToken: String = "initial-token" + private var _refreshCalled = false + + var currentToken: String { + get { lock.withLock { _currentToken } } + set { lock.withLock { _currentToken = newValue } } + } + + var refreshCalled: Bool { + lock.withLock { _refreshCalled } + } + + func refreshIfNeeded() async { + try? await Task.sleep(nanoseconds: 10_000_000) + lock.withLock { + _refreshCalled = true + _currentToken = "refreshed-token-456" + } + } + + func getValidToken() async -> String { + try? await Task.sleep(nanoseconds: 10_000_000) + lock.withLock { + _refreshCalled = true + _currentToken = "refreshed-token" + } + return currentToken + } +} diff --git a/Tests/FTAPIKitTests/Mockups/Models.swift b/Tests/FTAPIKitTests/Mockups/Models.swift index 84169a9..51c146d 100644 --- a/Tests/FTAPIKitTests/Mockups/Models.swift +++ b/Tests/FTAPIKitTests/Mockups/Models.swift @@ -17,4 +17,8 @@ struct File { func write() throws { try data.write(to: url) } + + func cleanup() { + try? FileManager.default.removeItem(at: url) + } } diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index ec3deaa..3afdc25 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,16 +1,25 @@ import Foundation import FTAPIKit +// MARK: - Test servers +// Integration tests use httpbin.org for real HTTP requests. +// These tests require network access and may fail if httpbin.org is unreachable. + struct HTTPBinServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! +} + +/// Configuration that adds a Bearer token to the request. +struct BearerTokenConfiguration: RequestConfiguring { + let token: String + + init(token: String = UUID().uuidString) { + self.token = token + } - func buildRequest(endpoint: Endpoint) async throws -> URLRequest { - var request = try buildStandardRequest(endpoint: endpoint) - if endpoint is AuthorizedEndpoint { - request.addValue("Bearer \(UUID().uuidString)", forHTTPHeaderField: "Authorization") - } - return request + func configure(_ request: inout URLRequest) async throws { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } } diff --git a/Tests/FTAPIKitTests/RequestConfiguringTests.swift b/Tests/FTAPIKitTests/RequestConfiguringTests.swift index f5661b6..649d979 100644 --- a/Tests/FTAPIKitTests/RequestConfiguringTests.swift +++ b/Tests/FTAPIKitTests/RequestConfiguringTests.swift @@ -1,8 +1,7 @@ import Foundation +import FTAPIKit import Testing -@testable import FTAPIKit - /// Tests for RequestConfiguring protocol functionality @Suite struct RequestConfiguringTests { @@ -39,7 +38,7 @@ struct RequestConfiguringTests { @Test func asyncOperationsInConfigure() async throws { - let tokenManager = MockAsyncTokenManager() + let tokenManager = MockTokenManager() let config = AsyncTokenConfiguration(tokenManager: tokenManager) let server = HTTPBinServer() let data = try await server.call(data: GetEndpoint(), configuring: config) @@ -72,6 +71,29 @@ struct RequestConfiguringTests { #expect(response.headers["User-Agent"] == "ConfigOverride/1.0") } + @Test + func compositeConfiguration() async throws { + let config1 = HeaderAddingConfiguration(headerName: "X-First", headerValue: "one") + let config2 = HeaderAddingConfiguration(headerName: "X-Second", headerValue: "two") + let composite = CompositeRequestConfiguring([config1, config2]) + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: composite) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-First"] == "one") + #expect(response.headers["X-Second"] == "two") + } + + @Test + func compositeConfigurationOrderMatters() async throws { + let first = HeaderAddingConfiguration(headerName: "X-Order", headerValue: "first") + let second = HeaderAddingConfiguration(headerName: "X-Order", headerValue: "second") + let composite = CompositeRequestConfiguring([first, second]) + let server = HTTPBinServer() + let data = try await server.call(data: GetEndpoint(), configuring: composite) + let response = try JSONDecoder().decode(HTTPBinHeadersResponse.self, from: data) + #expect(response.headers["X-Order"] == "second") + } + @Test func downloadWithConfiguration() async throws { let config = HeaderAddingConfiguration(headerName: "X-Download-Id", headerValue: "dl-456") @@ -99,7 +121,7 @@ private struct FailingConfiguration: RequestConfiguring { } private struct AsyncTokenConfiguration: RequestConfiguring { - let tokenManager: MockAsyncTokenManager + let tokenManager: MockTokenManager func configure(_ request: inout URLRequest) async throws { let token = await tokenManager.getValidToken() @@ -126,22 +148,3 @@ private struct UserAgentServer: URLServer { private enum ConfigurationError: Error, Equatable { case tokenRefreshFailed } - -private final class MockAsyncTokenManager: @unchecked Sendable { - private let lock = NSLock() - private var _refreshCalled = false - - var refreshCalled: Bool { - lock.withLock { _refreshCalled } - } - - func getValidToken() async -> String { - try? await Task.sleep(nanoseconds: 10_000_000) - lock.withLock { _refreshCalled = true } - return "refreshed-token" - } -} - -private struct HTTPBinHeadersResponse: Decodable, Sendable { - let headers: [String: String] -} diff --git a/Tests/FTAPIKitTests/URLQueryTests.swift b/Tests/FTAPIKitTests/URLQueryTests.swift index 361e179..1e43309 100644 --- a/Tests/FTAPIKitTests/URLQueryTests.swift +++ b/Tests/FTAPIKitTests/URLQueryTests.swift @@ -38,6 +38,12 @@ struct URLQueryTests { #expect(url.absoluteString == "http://httpbin.org/get?a=a&b=b") } + @Test + func emptyQueryReturnsNil() { + let query = URLQuery() + #expect(query.percentEncoded == nil) + } + @Test func emptyQueryItemValues() { let query = URLQuery(items: [ From 5a3007c617d909bea071e4e4d55e2ecf80f9ba11 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 15:44:08 +0100 Subject: [PATCH 28/30] Re-add templates --- .swiftlint.yml | 1 + ...___VARIABLE_endpointName___Endpoints.swift | 8 ++ ...___VARIABLE_endpointName___Endpoints.swift | 10 +++ .../___VARIABLE_endpointName___Requests.swift | 6 ++ ...___VARIABLE_endpointName___Endpoints.swift | 11 +++ .../___VARIABLE_endpointName___Requests.swift | 6 ++ ...___VARIABLE_endpointName___Responses.swift | 6 ++ ...___VARIABLE_endpointName___Endpoints.swift | 9 +++ ...___VARIABLE_endpointName___Responses.swift | 6 ++ .../Endpoints.xctemplate/TemplateIcon.png | Bin 0 -> 553 bytes .../Endpoints.xctemplate/TemplateIcon@2x.png | Bin 0 -> 801 bytes .../Endpoints.xctemplate/TemplateInfo.plist | 71 ++++++++++++++++++ Templates/Makefile | 8 ++ 13 files changed, 142 insertions(+) create mode 100644 Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift create mode 100644 Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift create mode 100644 Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift create mode 100644 Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift create mode 100644 Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift create mode 100644 Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift create mode 100644 Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift create mode 100644 Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift create mode 100644 Templates/Endpoints.xctemplate/TemplateIcon.png create mode 100644 Templates/Endpoints.xctemplate/TemplateIcon@2x.png create mode 100644 Templates/Endpoints.xctemplate/TemplateInfo.plist create mode 100644 Templates/Makefile diff --git a/.swiftlint.yml b/.swiftlint.yml index 884c276..4331da7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,6 +4,7 @@ disabled_rules: - duplicated_key_in_dictionary_literal excluded: - .build + - Templates opt_in_rules: - array_init - attributes diff --git a/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift new file mode 100644 index 0000000..0cc8234 --- /dev/null +++ b/Templates/Endpoints.xctemplate/Default/___VARIABLE_endpointName___Endpoints.swift @@ -0,0 +1,8 @@ +// ___FILEHEADER___ + +import FTAPIKit + +struct ___VARIABLE_templateName___Endpoint: Endpoint { + + let path: String = "" +} diff --git a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift new file mode 100644 index 0000000..7b71d65 --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Endpoints.swift @@ -0,0 +1,10 @@ +// ___FILEHEADER___ + +import FTAPIKit + +struct ___VARIABLE_templateName___Endpoint: RequestEndpoint { + typealias Request = ___VARIABLE_templateName___Request + + var body: ___VARIABLE_templateName___Request + let path: String = "" +} diff --git a/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift new file mode 100644 index 0000000..e912cac --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasRequest/___VARIABLE_endpointName___Requests.swift @@ -0,0 +1,6 @@ +// ___FILEHEADER___ + +import Foundation + +struct ___VARIABLE_templateName___Request: Encodable, Sendable { +} diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift new file mode 100644 index 0000000..36c5c95 --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Endpoints.swift @@ -0,0 +1,11 @@ +// ___FILEHEADER___ + +import FTAPIKit + +struct ___VARIABLE_templateName___Endpoint: RequestResponseEndpoint { + typealias Response = ___VARIABLE_templateName___Response + typealias Request = ___VARIABLE_templateName___Request + + var body: ___VARIABLE_templateName___Request + let path: String = "" +} diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift new file mode 100644 index 0000000..e912cac --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Requests.swift @@ -0,0 +1,6 @@ +// ___FILEHEADER___ + +import Foundation + +struct ___VARIABLE_templateName___Request: Encodable, Sendable { +} diff --git a/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift new file mode 100644 index 0000000..52a0c48 --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasRequestHasResponse/___VARIABLE_endpointName___Responses.swift @@ -0,0 +1,6 @@ +// ___FILEHEADER___ + +import Foundation + +struct ___VARIABLE_templateName___Response: Decodable, Sendable { +} diff --git a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift new file mode 100644 index 0000000..9ce8f08 --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Endpoints.swift @@ -0,0 +1,9 @@ +// ___FILEHEADER___ + +import FTAPIKit + +struct ___VARIABLE_templateName___Endpoint: ResponseEndpoint { + typealias Response = ___VARIABLE_templateName___Response + + let path: String = "" +} diff --git a/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift new file mode 100644 index 0000000..52a0c48 --- /dev/null +++ b/Templates/Endpoints.xctemplate/HasResponse/___VARIABLE_endpointName___Responses.swift @@ -0,0 +1,6 @@ +// ___FILEHEADER___ + +import Foundation + +struct ___VARIABLE_templateName___Response: Decodable, Sendable { +} diff --git a/Templates/Endpoints.xctemplate/TemplateIcon.png b/Templates/Endpoints.xctemplate/TemplateIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..45e1992dd33d7973fa7b06489af5c3608d7c7741 GIT binary patch literal 553 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyy~ySip>6gB0F2&*1?oo9yZ07?Q#I zHsWq?vjLCV^4p7Zm}cMMZr;GY=Z@M2PixPt#XH0=n=BMH^*ldu!p6Bb=Kt5&vZ^GL z?O!>Yk3x;~gr_P=GxirbedC(;{=`Dbl3*A8m01TPH(yoUCJ^`c8Z*;x0q%!M@4E68 zc^j^NW>7Obvismhd++~7)z7x?vgNy86@Q$MRU&txLy(K>(Qtc3`wHvp!_dF1L7da}4v+6`pFc zzK><%8s^O}y?2;#&9RB1ivK%Qci0tmsEcRuetGh)IBVgx#HfyslI8U&r&%7{>6CbO zLEv)dbhdWB_deMh-0w~>nJY7AN$X$pFA0+h3|}AR-^h00RV71k4QrDB!n>b#{=3e| z`#U}{d+&PhMvZ+;QVy$*yua3c#$na+;9n6!TUwKDPP`x*%9wnk`^BXB)hF+Z-{7%U z>(t5naFOXpnnc^G*SiGL(wgr5+O&}2Uf<)4Lc=BeGjbZ3?Or$EmpahABV>)_j}tsG Zj5`lZeDSD%H7M>GJYD@<);T3K0RYd&*!}E7$weaD78i8JuIK3tT~Rv6qUdwvcQr+Y&cBzxzut9siLbCehXVtX zKm!Ad0s|w5!-K0FbENnx@*)mTGGd(mG1{_krXY(!0!ykpGqa3^@-i^<@!gr9m)$w` zh*hpeuja55YsYyGo&yYqj2mr48yJ(Ak}9Yc zuUTi>Pej#d=Ix$*gF{r{@zi}%%$Ft}6H0RE3ICPFZ7;aH<9n^d&uQC5)*OF*m_edI zK`*)L00X-XC(nl$JTneAFix=ToTgS|DpVtte9bEPW@1~A#17s=j;W^4?#1+-*+0)K z@qntw8$-5)h8YbJ`<8OXOMSnwYn|cUV7<)}5y__yZZ+{+eN%_?$6~JosXqKGKHV;7 zWP5XZ`z%@JRUO}AFI;L_D;u-c@tE4{mp6_+GV8eSv6^%Hk)4Ow=kjcRB&1>|Yyb@) zh>KS;@CXPCoDOD`P*78t_P$ozY0ulWHE(yz$q6ofU$f%q^ha`))%C@kuT-atv@?1t zR9{YT(BHYlg2#jD=4>|Rs*|cm6CH$3+%u85;H{^*?&;*UAC}vAR#zW0NelzVK^m6O zDw|?I|GehS`ggh4?KM03XQVtVX}7M2ZPrJZM>~vG Pf|94FtDnm{r-UW|d{Zrj literal 0 HcmV?d00001 diff --git a/Templates/Endpoints.xctemplate/TemplateInfo.plist b/Templates/Endpoints.xctemplate/TemplateInfo.plist new file mode 100644 index 0000000..683999f --- /dev/null +++ b/Templates/Endpoints.xctemplate/TemplateInfo.plist @@ -0,0 +1,71 @@ + + + + + DefaultCompletionName + Endpoint + Description + This generates a new endpoint for use with FTAPIKit. + Kind + Xcode.IDEKit.TextSubstitutionFileTemplateKind + Options + + + Description + The name of the endpoint + Identifier + endpointName + Name + Endpoint file name: + NotPersisted + + Required + + Type + text + + + Description + The name of template request. + Identifier + templateName + Name + Template endpoint name: + NotPersisted + + Required + + Type + text + + + Identifier + hasRequest + Name + Generate Request file + Description + Check whether you want to create file containing requests. + Type + checkbox + + + Identifier + hasResponse + Name + Generate Response file + Description + Check whether you want to create file containing responses. + Type + checkbox + + + Platforms + + com.apple.platform.iphoneos + + SortOrder + 99 + Summary + This generates a new endpoint for use with FTAPIKit. + + diff --git a/Templates/Makefile b/Templates/Makefile new file mode 100644 index 0000000..0b2b531 --- /dev/null +++ b/Templates/Makefile @@ -0,0 +1,8 @@ +INSTALL_DIR = ~/Library/Developer/Xcode/Templates/File\ Templates/FTAPIKit + +install: + mkdir -p $(INSTALL_DIR) + cp -R Endpoints.xctemplate $(INSTALL_DIR)/ + +uninstall: + rm -rf $(INSTALL_DIR) From aaf0a650e9828e237f6b362c25d5364ba638caf5 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 15:58:46 +0100 Subject: [PATCH 29/30] Bugfix --- Sources/FTAPIKit/URLServer+Async.swift | 20 +++++++++++--------- Tests/FTAPIKitTests/ErrorHandlingTests.swift | 7 +++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Sources/FTAPIKit/URLServer+Async.swift b/Sources/FTAPIKit/URLServer+Async.swift index 1d01fbf..00c3da5 100644 --- a/Sources/FTAPIKit/URLServer+Async.swift +++ b/Sources/FTAPIKit/URLServer+Async.swift @@ -27,17 +27,17 @@ public extension URLServer { /// - Parameters: /// - endpoint: The endpoint /// - configuring: Optional request configuration to apply before sending - /// - Throws: Throws an ``APIError`` if the request fails or server returns an error. - /// Throws a `DecodingError` directly if response decoding fails (decoding errors are not - /// routed through ``URLServer/ErrorType``). + /// - Throws: Throws an ``APIError`` if the request fails or server returns an error, + /// or an error from ``RequestConfiguring/configure(_:)`` if configuration fails. /// - Returns: Instance of the required type func call(response endpoint: EP, configuring: RequestConfiguring? = nil) async throws -> EP.Response { let result = try await execute(endpoint: endpoint, configuring: configuring) do { return try decoding.decode(data: result.data) } catch { - result.observers.forEach { $0.didFail(request: result.request, error: error) } - throw error + let thrownError = ErrorType(data: result.data, response: nil, error: error, decoding: decoding) ?? error + result.observers.forEach { $0.didFail(request: result.request, error: thrownError) } + throw thrownError } } @@ -57,8 +57,9 @@ public extension URLServer { do { (localURL, response) = try await urlSession.download(for: urlRequest) } catch { - observers.forEach { $0.didFail(request: urlRequest, error: error) } - throw error + let thrownError = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? error + observers.forEach { $0.didFail(request: urlRequest, error: thrownError) } + throw thrownError } observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: nil) } @@ -93,8 +94,9 @@ private extension URLServer { (data, response) = try await urlSession.data(for: urlRequest) } } catch { - observers.forEach { $0.didFail(request: urlRequest, error: error) } - throw error + let thrownError = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? error + observers.forEach { $0.didFail(request: urlRequest, error: thrownError) } + throw thrownError } observers.forEach { $0.didReceiveResponse(for: urlRequest, response: response, data: data) } diff --git a/Tests/FTAPIKitTests/ErrorHandlingTests.swift b/Tests/FTAPIKitTests/ErrorHandlingTests.swift index 77fcb8c..d667ace 100644 --- a/Tests/FTAPIKitTests/ErrorHandlingTests.swift +++ b/Tests/FTAPIKitTests/ErrorHandlingTests.swift @@ -67,8 +67,11 @@ struct ErrorHandlingTests { do { _ = try await server.call(response: endpoint) Issue.record("Expected decoding error") - } catch is DecodingError { - // Expected: response wrapper doesn't match User directly + } catch let error as APIError.Standard { + guard case .decoding = error else { + Issue.record("Expected .decoding error, got \(error)") + return + } } catch { Issue.record("Unexpected error type: \(type(of: error))") } From 94716829511069dfe4612878a1b4f1c17d5fb0e3 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Tue, 17 Mar 2026 16:03:06 +0100 Subject: [PATCH 30/30] Update docs --- CLAUDE.md | 8 +++++--- Sources/FTAPIKit/Documentation.docc/Documentation.md | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e74be96..3c69648 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ The framework is built around two core protocols: **URLServer Protocol**: The `URLServer` protocol provides all URLSession-based functionality with default implementations. Only `baseUri` must be provided by conforming types. -**Network Observers**: The `NetworkObserver` protocol provides lifecycle callbacks (`willSendRequest`, `didReceiveResponse`, `didFail`) with type-safe context passing. Observer integration uses `AnyObserverToken` for type erasure. +**Network Observers**: The `NetworkObserver` protocol provides lifecycle callbacks (`willSendRequest`, `didReceiveResponse`, `didFail`) with type-safe context passing. Observer integration uses `BoundObserverContext` (private) for type erasure. **Request Configuration**: The `RequestConfiguring` protocol allows per-request async configuration at the call site, separate from server-level `buildRequest`. @@ -95,8 +95,8 @@ The framework is built around two core protocols: **Test Structure** (`Tests/FTAPIKitTests/`): - Uses Swift Testing framework (`@Suite`, `@Test`, `#expect`) -- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift`, `NetworkObserverTests.swift`, `RequestConfiguringTests.swift` -- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift`, `MockNetworkObserver.swift` +- Test files: `AsyncTests.swift`, `AsyncBuildRequestTests.swift`, `URLQueryTests.swift`, `NetworkObserverTests.swift`, `RequestConfiguringTests.swift`, `EndpointTypeTests.swift`, `ErrorHandlingTests.swift` +- Test utilities in `Mockups/`: `Servers.swift`, `Endpoints.swift`, `Models.swift`, `Errors.swift`, `MockNetworkObserver.swift`, `MockTokenManager.swift`, `HTTPBinResponse.swift` ### Call Execution Pattern @@ -120,6 +120,8 @@ let fileURL = try await server.download(endpoint: endpoint) - `APIError` protocol defines error handling interface - Default implementation: `APIError.Standard` (enum with connection, encoding, decoding, server, client, unhandled cases) +- Network errors (`URLError`) and decoding errors are routed through `ErrorType` for consistent error handling +- Encoding errors (from `buildStandardRequest`) propagate directly as `EncodingError` since they occur before the network request - Custom error types can be defined via `URLServer.ErrorType` associated type ## Package Management diff --git a/Sources/FTAPIKit/Documentation.docc/Documentation.md b/Sources/FTAPIKit/Documentation.docc/Documentation.md index c962ddf..b1942c0 100644 --- a/Sources/FTAPIKit/Documentation.docc/Documentation.md +++ b/Sources/FTAPIKit/Documentation.docc/Documentation.md @@ -31,6 +31,7 @@ Built for Swift 6.1+ with full concurrency safety. ### Request Configuration - ``RequestConfiguring`` +- ``CompositeRequestConfiguring`` ### Endpoint configuration