From d993224fa02ceef1242fb3617a73491663aa015d Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Wed, 1 Oct 2025 11:12:10 +0200 Subject: [PATCH 01/18] Swift 6.2, Sendable conformance, Session actor, Injection in testbed --- .github/workflows/swift-package.yml | 2 +- .github/workflows/swiftformat.yml | 2 +- .../project.pbxproj | 31 ++++- EndpointsTestbed/EndpointsTestbed/DI.swift | 17 +++ .../EndpointsTestbedApp.swift | 4 + .../ExampleAsyncReactor/ExampleReactor.swift | 11 +- .../ExampleMVVM/ExampleViewModel.swift | 11 +- .../EndpointsTestbed/Networking/API.swift | 32 +++++ .../Networking/HTTPBinClient.swift | 8 +- .../Networking/ManipulatedHTTPBinClient.swift | 18 +-- .../Networking/PostmanEchoClient.swift | 10 +- EndpointsTestbed/EndpointsTestbed/World.swift | 25 ---- Package.resolved | 20 +++- Package.swift | 2 +- Sources/Async/AnyClient.swift | 11 +- Sources/Async/Client.swift | 31 ++++- Sources/Async/Session.swift | 13 ++- Sources/Body/Body.swift | 2 +- .../Body/Multipart/MultipartBodyPart.swift | 2 +- Sources/Convenience/AnyCall.swift | 2 +- Sources/Core/Call.swift | 2 +- Sources/Core/HTTPMethod.swift | 2 +- Sources/Core/URLRequestEncodable.swift | 2 +- Sources/Parsing/DataParser.swift | 2 +- .../Parsing/ResponseParser/JSONParser.swift | 12 +- .../ResponseValidator/ResponseValidator.swift | 2 +- .../StatusCodeValidator.swift | 2 +- Sources/Testing/FakeResultProvider.swift | 2 +- Sources/Testing/FakeSession.swift | 109 +++++++++--------- 29 files changed, 252 insertions(+), 137 deletions(-) create mode 100644 EndpointsTestbed/EndpointsTestbed/DI.swift create mode 100644 EndpointsTestbed/EndpointsTestbed/Networking/API.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/World.swift diff --git a/.github/workflows/swift-package.yml b/.github/workflows/swift-package.yml index d3fe39c..7f493fb 100644 --- a/.github/workflows/swift-package.yml +++ b/.github/workflows/swift-package.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4.0' + xcode-version: '26.0' - name: Build run: swift build -v - name: Run tests diff --git a/.github/workflows/swiftformat.yml b/.github/workflows/swiftformat.yml index ca22790..d81f357 100644 --- a/.github/workflows/swiftformat.yml +++ b/.github/workflows/swiftformat.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4.0' + xcode-version: '26.0' - name: Check SwiftFromat run: swift run swiftformat . --lint diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj index 4a42133..4ad6830 100644 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj +++ b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj @@ -19,9 +19,11 @@ 82BA52F829DEE35D00F7726A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 82BA52F729DEE35D00F7726A /* Preview Assets.xcassets */; }; 82BA530229DEE35D00F7726A /* EndpointsTestbedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BA530129DEE35D00F7726A /* EndpointsTestbedTests.swift */; }; 82BA531E29DEE73D00F7726A /* Endpoints in Frameworks */ = {isa = PBXBuildFile; productRef = 82BA531D29DEE73D00F7726A /* Endpoints */; }; - 82E74FFA2AADDF68001A230C /* World.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E74FF92AADDF68001A230C /* World.swift */; }; 82E74FFC2AADE042001A230C /* HTTPBinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */; }; 82E74FFE2AADE19A001A230C /* ManipulatedHTTPBinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */; }; + FA7846C72E72A94500E4B067 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7846C62E72A93900E4B067 /* API.swift */; }; + FAD06B6B2E8C1C8C00E87155 /* Injection in Frameworks */ = {isa = PBXBuildFile; productRef = FAD06B6A2E8C1C8C00E87155 /* Injection */; }; + FAD06B6D2E8C1D5800E87155 /* DI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD06B6C2E8C1D5600E87155 /* DI.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,9 +50,10 @@ 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EndpointsTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 82BA530129DEE35D00F7726A /* EndpointsTestbedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointsTestbedTests.swift; sourceTree = ""; }; 82BA531B29DEE38000F7726A /* Endpoints */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Endpoints; path = ..; sourceTree = ""; }; - 82E74FF92AADDF68001A230C /* World.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = World.swift; sourceTree = ""; }; 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPBinClient.swift; sourceTree = ""; }; 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManipulatedHTTPBinClient.swift; sourceTree = ""; }; + FA7846C62E72A93900E4B067 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + FAD06B6C2E8C1D5600E87155 /* DI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DI.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,6 +61,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FAD06B6B2E8C1C8C00E87155 /* Injection in Frameworks */, 8211A84D2AFBDBFD00A36244 /* AsyncReactor in Frameworks */, 82BA531E29DEE73D00F7726A /* Endpoints in Frameworks */, ); @@ -95,6 +99,7 @@ 82B1ABA329E354B0007B23C1 /* Networking */ = { isa = PBXGroup; children = ( + FA7846C62E72A93900E4B067 /* API.swift */, 82B1ABA429E354C0007B23C1 /* PostmanEchoClient.swift */, 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */, 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */, @@ -125,11 +130,11 @@ 82BA52EF29DEE35C00F7726A /* EndpointsTestbed */ = { isa = PBXGroup; children = ( + FAD06B6C2E8C1D5600E87155 /* DI.swift */, 82B1ABA329E354B0007B23C1 /* Networking */, 8211A84A2AFBDBD200A36244 /* ExampleAsyncReactor */, 82B1AB9C29E3543C007B23C1 /* ExampleMVVM */, 82BA52F029DEE35C00F7726A /* EndpointsTestbedApp.swift */, - 82E74FF92AADDF68001A230C /* World.swift */, 82BA52F429DEE35D00F7726A /* Assets.xcassets */, 82BA52F629DEE35D00F7726A /* Preview Content */, ); @@ -186,6 +191,7 @@ packageProductDependencies = ( 82BA531D29DEE73D00F7726A /* Endpoints */, 8211A84C2AFBDBFD00A36244 /* AsyncReactor */, + FAD06B6A2E8C1C8C00E87155 /* Injection */, ); productName = EndpointsTestbed; productReference = 82BA52ED29DEE35C00F7726A /* EndpointsTestbed.app */; @@ -238,6 +244,7 @@ mainGroup = 82BA52E429DEE35C00F7726A; packageReferences = ( 8211A84B2AFBDBFD00A36244 /* XCRemoteSwiftPackageReference "AsyncReactor" */, + FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */, ); preferredProjectObjectVersion = 77; productRefGroup = 82BA52EE29DEE35C00F7726A /* Products */; @@ -278,10 +285,11 @@ 82B1ABA029E35455007B23C1 /* ExampleViewModel.swift in Sources */, 82E74FFC2AADE042001A230C /* HTTPBinClient.swift in Sources */, 82B1ABA229E35486007B23C1 /* ExampleModel.swift in Sources */, + FAD06B6D2E8C1D5800E87155 /* DI.swift in Sources */, + FA7846C72E72A94500E4B067 /* API.swift in Sources */, 8211A84F2AFBDC1200A36244 /* ExampleReactorView.swift in Sources */, 82BA52F129DEE35C00F7726A /* EndpointsTestbedApp.swift in Sources */, 82E74FFE2AADE19A001A230C /* ManipulatedHTTPBinClient.swift in Sources */, - 82E74FFA2AADDF68001A230C /* World.swift in Sources */, 82B1AB9E29E3544B007B23C1 /* ExampleView.swift in Sources */, 8211A8512AFBDC2000A36244 /* ExampleReactor.swift in Sources */, ); @@ -366,6 +374,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -422,6 +431,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; }; name = Release; @@ -561,6 +571,14 @@ minimumVersion = 1.0.0; }; }; + FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/diamirio/Injection"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -573,6 +591,11 @@ isa = XCSwiftPackageProductDependency; productName = Endpoints; }; + FAD06B6A2E8C1C8C00E87155 /* Injection */ = { + isa = XCSwiftPackageProductDependency; + package = FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */; + productName = Injection; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 82BA52E529DEE35C00F7726A /* Project object */; diff --git a/EndpointsTestbed/EndpointsTestbed/DI.swift b/EndpointsTestbed/EndpointsTestbed/DI.swift new file mode 100644 index 0000000..782ccde --- /dev/null +++ b/EndpointsTestbed/EndpointsTestbed/DI.swift @@ -0,0 +1,17 @@ +import Endpoints +import Foundation +import Injection + +@MainActor +enum DI { + static func register() { + let postmanSession = Session(with: PostmanEchoClient()) + DependencyInjector.register(postmanSession) + + let httpBinSession = Session(with: HTTPBinClient()) + DependencyInjector.register(httpBinSession) + + let manipulatedHttpBinSession = Session(with: ManipulatedHTTPBinClient()) + DependencyInjector.register(manipulatedHttpBinSession) + } +} diff --git a/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift b/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift index 893c819..310c9a5 100644 --- a/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift +++ b/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift @@ -3,6 +3,10 @@ import SwiftUI @main struct EndpointsTestbedApp: App { + init() { + DI.register() + } + var body: some Scene { WindowGroup { NavigationStack { diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift b/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift index 1205cd7..54307bc 100644 --- a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift +++ b/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift @@ -1,6 +1,7 @@ import AsyncReactor import Endpoints import Foundation +import Injection class ExampleReactor: AsyncReactor { enum Action { @@ -14,6 +15,9 @@ class ExampleReactor: AsyncReactor { @Published private(set) var state = State() + @Inject + var postmanSession: Session + func action(_ action: Action) async { switch action { case .executeRequests: @@ -23,15 +27,12 @@ class ExampleReactor: AsyncReactor { private func executeRequest() async { do { - let (body, response) = try await world.postmanSession.dataTask( + let (body, response) = try await postmanSession.dataTask( for: PostmanEchoClient.ExampleGetCall() ) guard response.statusCode == 200 else { return } - - await MainActor.run { - state.text = body.url - } + state.text = body.url } catch { guard let error = error as? EndpointsError else { return } print(error.response?.statusCode ?? "") diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift b/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift index ab9cda8..b0d50e4 100644 --- a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift +++ b/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift @@ -1,14 +1,21 @@ import Endpoints import Foundation +import Injection @MainActor class ExampleViewModel: ObservableObject { @Published var text: String = "" + @Inject + var postmanSession: Session + + @Inject + var manipulatedHttpBinSession: Session + func executeRequests() { Task { - let (body, response) = try await world.postmanSession.dataTask( + let (body, response) = try await postmanSession.dataTask( for: PostmanEchoClient.ExampleGetCall() ) guard response.statusCode == 200 else { return } @@ -19,7 +26,7 @@ class ExampleViewModel: ObservableObject { } Task { - let (_, response) = try await world.manipulatedHttpBinSession.dataTask( + let (_, response) = try await manipulatedHttpBinSession.dataTask( for: ManipulatedHTTPBinClient.GetStatusCode(deliveredStatusCode: 220) ) guard response.statusCode == 200 else { return } diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/API.swift b/EndpointsTestbed/EndpointsTestbed/Networking/API.swift new file mode 100644 index 0000000..bdd8c75 --- /dev/null +++ b/EndpointsTestbed/EndpointsTestbed/Networking/API.swift @@ -0,0 +1,32 @@ +// +// API.swift +// EndpointsTestbed +// +// Created by Alexander Kauer on 11.09.25. +// + +import Endpoints +import Foundation + +actor API { + var postmanSession: Session + var httpBinSession: Session + var manipulatedHttpBinSession: Session + + init() { + let postmanClient = PostmanEchoClient() + self.postmanSession = Session(with: postmanClient) + + let httpBinClient = HTTPBinClient() + self.httpBinSession = Session(with: httpBinClient) + + let manipulatedHttpBinClient = ManipulatedHTTPBinClient() + self.manipulatedHttpBinSession = Session(with: manipulatedHttpBinClient) + } + + func loadData() async throws -> (ExampleModel, HTTPURLResponse) { + try await postmanSession.dataTask( + for: PostmanEchoClient.ExampleGetCall() + ) + } +} diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift index 0cee9e9..aa1addf 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift @@ -1,10 +1,12 @@ import Endpoints import Foundation -class HTTPBinClient: AnyClient { - public init() { +struct HTTPBinClient: Client { + var client: Client + + init() { let url = URL(string: "https://httpbin.org/")! - super.init(baseURL: url) + self.client = DefaultClient(baseURL: url) } struct GetStatusCode: Call { diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift index ff6857f..4120d0d 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift @@ -1,31 +1,31 @@ import Endpoints import Foundation -class ManipulatedHTTPBinClient: AnyClient { - private var defaultClient: AnyClient +struct ManipulatedHTTPBinClient: Client { + var client: Client init() { let url = URL(string: "https://httpbin.org/")! - self.defaultClient = AnyClient(baseURL: url) - super.init(baseURL: url) + self.client = DefaultClient(baseURL: url) } - override func encode(call: some Endpoints.Call) async throws -> URLRequest { + func encode(call: some Endpoints.Call) async throws -> URLRequest { // Custom manipulation i.e. OAuth implementation print("- MANIPULATED encode -") - return try await defaultClient.encode(call: call) + return try await client.encode(call: call) } - override func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType + func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType where C: Call { // Custom manipulation i.e. react on error responses or invalid tokens print("- MANIPULATED parse -") - return try await defaultClient.parse(response: response, data: data, for: call) + return try await client.parse(response: response, data: data, for: call) } - override func validate(response: HTTPURLResponse?, data: Data?) async throws { + func validate(response: HTTPURLResponse?, data: Data?) async throws { // Custom validation if needed print("- MANIPULATED validate -") + return try await client.validate(response: response, data: data) } struct GetStatusCode: Call { diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift index a79137c..06e8238 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift @@ -1,10 +1,12 @@ import Endpoints import Foundation -public class PostmanEchoClient: AnyClient { - public init() { - let url = URL(string: "https://postman-echo.com")! - super.init(baseURL: url) +struct PostmanEchoClient: Client { + let client: Client + + init() { + let baseURL = URL(string: "https://postman-echo.com")! + self.client = DefaultClient(baseURL: baseURL) } struct ExampleGetCall: Call { diff --git a/EndpointsTestbed/EndpointsTestbed/World.swift b/EndpointsTestbed/EndpointsTestbed/World.swift deleted file mode 100644 index 14004c0..0000000 --- a/EndpointsTestbed/EndpointsTestbed/World.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Endpoints -import Foundation - -@MainActor -let world = World() - -struct World { - let postmanSession: Session - let httpBinSession: Session - let manipulatedHttpBinSession: Session - - init() { - let postmanClient = PostmanEchoClient() - self.postmanSession = Session(with: postmanClient) - postmanSession.debug = true - - let httpBinClient = HTTPBinClient() - self.httpBinSession = Session(with: httpBinClient) - httpBinSession.debug = true - - let manipulatedHttpBinClient = ManipulatedHTTPBinClient() - self.manipulatedHttpBinSession = Session(with: manipulatedHttpBinClient) - manipulatedHttpBinSession.debug = true - } -} diff --git a/Package.resolved b/Package.resolved index cc7023d..b3a48dc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "6be90f4e656cb9ee68672462c82ac186ebd3d650a42c62ae1c35723995bd7c2a", + "originHash" : "8f53f9125394eadf02a9aafd4af63a7efd6c6d7da3c28070ac429a207bf1b97d", "pins" : [ + { + "identity" : "asyncreactor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/diamirio/AsyncReactor", + "state" : { + "revision" : "23292274e53eedae5c2198536a3a476355fe59d4", + "version" : "1.2.0" + } + }, + { + "identity" : "injection", + "kind" : "remoteSourceControl", + "location" : "https://github.com/diamirio/Injection", + "state" : { + "revision" : "57a66d3f21ff5564542a6b157838ae97b67447f0", + "version" : "1.1.0" + } + }, { "identity" : "swiftformat", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 4e2d7d3..433c837 100755 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.1 +// swift-tools-version:6.2 import PackageDescription diff --git a/Sources/Async/AnyClient.swift b/Sources/Async/AnyClient.swift index 4df663e..ae3d8b0 100644 --- a/Sources/Async/AnyClient.swift +++ b/Sources/Async/AnyClient.swift @@ -2,7 +2,10 @@ import Foundation -open class AnyClient: Client { +public struct DefaultClient: Client { + /// To satisfy conformance only + public var client: Client { self } + /// The base URL used by `encode` to convert `Call`s into `URLRequest`s. public let baseURL: URL @@ -14,7 +17,7 @@ open class AnyClient: Client { self.baseURL = baseURL } - open func encode( + public func encode( call: some Call ) async throws -> URLRequest { var urlRequest = call.request.urlRequest @@ -26,7 +29,7 @@ open class AnyClient: Client { return urlRequest } - open func parse( + public func parse( response: HTTPURLResponse?, data: Data?, for call: C @@ -38,7 +41,7 @@ open class AnyClient: Client { return try C.Parser().parse(response: response, data: data) } - open func validate( + public func validate( response: HTTPURLResponse?, data: Data? ) async throws { diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index e481079..6d21c9e 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -4,7 +4,9 @@ import Foundation /// A type responsible for encoding and parsing all calls for a given Web API. /// A basic implementation is provided by `AnyClient`. -public protocol Client: ResponseValidator { +public protocol Client: ResponseValidator, Sendable { + var client: Client { get } + /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. func encode(call: C) async throws -> URLRequest @@ -19,3 +21,30 @@ public protocol Client: ResponseValidator { for call: C ) async throws -> C.Parser.OutputType } + +public extension Client { + /// Converts a `Call` created for this client's Web API + /// into a `URLRequest`. + func encode(call: C) async throws -> URLRequest { + try await client.encode(call: call) + } + + /// Converts the `URLSession`s result for a `Call` to + /// this client's Web API into the expected output type. + /// + /// - throws: Any `Error` if `result` is considered invalid. + func parse( + response: HTTPURLResponse?, + data: Data?, + for call: C + ) async throws -> C.Parser.OutputType { + try await client.parse(response: response, data: data, for: call) + } + + func validate( + response: HTTPURLResponse?, + data: Data? + ) async throws { + try await client.validate(response: response, data: data) + } +} diff --git a/Sources/Async/Session.swift b/Sources/Async/Session.swift index c80ad46..0e3062d 100644 --- a/Sources/Async/Session.swift +++ b/Sources/Async/Session.swift @@ -2,13 +2,12 @@ import Foundation #if canImport(OSLog) - import OSLog +import OSLog #endif -open class Session { - public var debug = false - - public var urlSession: URLSession +public actor Session { + public let debug = false + public let urlSession: URLSession public let client: CL public init( @@ -24,7 +23,9 @@ open class Session { } @discardableResult - open func dataTask(for call: C) async throws -> (C.Parser.OutputType, HTTPURLResponse) { + public func dataTask( + for call: C + ) async throws -> (C.Parser.OutputType, HTTPURLResponse) { let urlRequest = try await client.encode(call: call) let (data, response) = try await urlSession.data(for: urlRequest) diff --git a/Sources/Body/Body.swift b/Sources/Body/Body.swift index 6706c64..0475d18 100644 --- a/Sources/Body/Body.swift +++ b/Sources/Body/Body.swift @@ -8,7 +8,7 @@ import Foundation /// /// Adopted by `Data` and `String`. /// - seealso: `FormEncodedBody`, `JSONEncodedBody`. -public protocol Body { +public protocol Body: Sendable { /// Returns HTTP Header parameters required for `self`, if any. /// /// This is usally a "Content-Type" header like "application/json" for a diff --git a/Sources/Body/Multipart/MultipartBodyPart.swift b/Sources/Body/Multipart/MultipartBodyPart.swift index f128bea..eb1cce0 100644 --- a/Sources/Body/Multipart/MultipartBodyPart.swift +++ b/Sources/Body/Multipart/MultipartBodyPart.swift @@ -2,7 +2,7 @@ import Foundation -public protocol MultipartBodyPart { +public protocol MultipartBodyPart: Sendable { /// The name (usually from the HTML form) /// /// There can be multiple parts with the same name. diff --git a/Sources/Convenience/AnyCall.swift b/Sources/Convenience/AnyCall.swift index eeeed8b..e827142 100644 --- a/Sources/Convenience/AnyCall.swift +++ b/Sources/Convenience/AnyCall.swift @@ -5,7 +5,7 @@ import Foundation public struct AnyCall: Call { public typealias Parser = Parser - public typealias ValidationBlock = (HTTPURLResponse?, Data?) throws -> Void + public typealias ValidationBlock = @Sendable (HTTPURLResponse?, Data?) throws -> Void public var request: URLRequestEncodable diff --git a/Sources/Core/Call.swift b/Sources/Core/Call.swift index 6faf24c..6832af8 100644 --- a/Sources/Core/Call.swift +++ b/Sources/Core/Call.swift @@ -39,7 +39,7 @@ import Foundation /// request before using its `Parser` to parse it. /// /// - seealso: `Client`, `Session`, `DataParser`, `Request` -public protocol Call: ResponseValidator { +public protocol Call: ResponseValidator, Sendable { associatedtype Parser: ResponseParser var request: URLRequestEncodable { get } diff --git a/Sources/Core/HTTPMethod.swift b/Sources/Core/HTTPMethod.swift index fcb9140..ae27a18 100644 --- a/Sources/Core/HTTPMethod.swift +++ b/Sources/Core/HTTPMethod.swift @@ -3,7 +3,7 @@ import Foundation /// An enum containing all HTTPMethods defined in RFC 2616 -public enum HTTPMethod: String { +public enum HTTPMethod: String, Sendable { case get = "GET" case post = "POST" case put = "PUT" diff --git a/Sources/Core/URLRequestEncodable.swift b/Sources/Core/URLRequestEncodable.swift index 9707461..835b23b 100644 --- a/Sources/Core/URLRequestEncodable.swift +++ b/Sources/Core/URLRequestEncodable.swift @@ -5,7 +5,7 @@ import Foundation /// A type that can transform itself into an `URLRequest`. /// /// This protocol is adopted by `Request`, `URLRequest`, `URL` and `Call`. -public protocol URLRequestEncodable: CustomDebugStringConvertible { +public protocol URLRequestEncodable: CustomDebugStringConvertible, Sendable { /// Returns an `URLRequest` configured with the data encapsulated by `self`. var urlRequest: URLRequest { get } } diff --git a/Sources/Parsing/DataParser.swift b/Sources/Parsing/DataParser.swift index 7d8537e..4b7bfe3 100644 --- a/Sources/Parsing/DataParser.swift +++ b/Sources/Parsing/DataParser.swift @@ -6,7 +6,7 @@ import Foundation /// /// Used by `Call` to define the expected response type for its associated /// request. -public protocol DataParser { +public protocol DataParser: Sendable { /// The type that can be produced by `self`. associatedtype OutputType diff --git a/Sources/Parsing/ResponseParser/JSONParser.swift b/Sources/Parsing/ResponseParser/JSONParser.swift index 76453db..4e6b413 100644 --- a/Sources/Parsing/ResponseParser/JSONParser.swift +++ b/Sources/Parsing/ResponseParser/JSONParser.swift @@ -4,15 +4,15 @@ import Foundation /// A `JSONParser` is a `DecodableParser` that works with JSON representation. /// It provides aa `jsonDecoder` to decode a response. -open class JSONParser: ResponseParser { +public struct JSONParser: ResponseParser { public typealias OutputType = T - public required init() {} - - open var jsonDecoder: JSONDecoder { - JSONDecoder() + public var jsonDecoder: JSONDecoder + + public init() { + self.jsonDecoder = JSONDecoder() } - + public func parse(data: Data, encoding _: String.Encoding) throws -> OutputType { try jsonDecoder.decode(OutputType.self, from: data) } diff --git a/Sources/ResponseValidator/ResponseValidator.swift b/Sources/ResponseValidator/ResponseValidator.swift index d4dd1a4..5b11896 100644 --- a/Sources/ResponseValidator/ResponseValidator.swift +++ b/Sources/ResponseValidator/ResponseValidator.swift @@ -4,7 +4,7 @@ import Foundation /// A type responsible for validating the result produced by a /// `URLSession`s `completionHandler` block. -public protocol ResponseValidator { +public protocol ResponseValidator: Sendable { /// Validates the data provided by `URLSession`s `completionHandler` /// block. /// - throws: Any `Error`, if `result` is not valid. diff --git a/Sources/ResponseValidator/StatusCodeValidator.swift b/Sources/ResponseValidator/StatusCodeValidator.swift index 87f133f..d896d0f 100644 --- a/Sources/ResponseValidator/StatusCodeValidator.swift +++ b/Sources/ResponseValidator/StatusCodeValidator.swift @@ -3,7 +3,7 @@ import Foundation /// A type validating the status code of `HTTPURLResponse`. -public class StatusCodeValidator: ResponseValidator { +public final class StatusCodeValidator: ResponseValidator { /// Checks if an HTTP status code is acceptable /// - returns: `true` if `code` is between 200 and 299. public func isAcceptableStatus(code: Int) -> Bool { diff --git a/Sources/Testing/FakeResultProvider.swift b/Sources/Testing/FakeResultProvider.swift index 44629ce..48aae56 100644 --- a/Sources/Testing/FakeResultProvider.swift +++ b/Sources/Testing/FakeResultProvider.swift @@ -1,5 +1,5 @@ import Foundation -public protocol FakeResultProvider { +public protocol FakeResultProvider: Sendable { func data(for call: C) async throws -> (URLResponse, Data) } diff --git a/Sources/Testing/FakeSession.swift b/Sources/Testing/FakeSession.swift index 1de89b8..8af2843 100644 --- a/Sources/Testing/FakeSession.swift +++ b/Sources/Testing/FakeSession.swift @@ -4,57 +4,58 @@ import Foundation #if canImport(OSLog) import OSLog #endif - -public class FakeSession: Session { - var resultProvider: FakeResultProvider - - public init(with client: CL, resultProvider: FakeResultProvider) { - self.resultProvider = resultProvider - - super.init(with: client) - } - - override public func dataTask( - for call: C - ) async throws -> (C.Parser.OutputType, HTTPURLResponse) { - let (response, data) = try await resultProvider.data(for: call) - - guard let response = response as? HTTPURLResponse else { - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { - Logger.default.debug("no response.") - } else { - os_log("no response.", log: .default, type: .debug) - } - - throw EndpointsError( - error: EndpointsParsingError.invalidData( - description: "Response was not a valid HTTPURLResponse" - ), - response: nil - ) - } - - if debug { - var message = "" - message += "\(call.request.cURLRepresentation)\n" - message += "\(response.debugDescription)\n" - message += "\(data.debugDescription(encoding: response.stringEncoding))" - - if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { - Logger.default.debug("\(message, privacy: .private)") - } else { - os_log("%s", log: .default, type: .debug, message) - } - } - - do { - try await call.validate(response: response, data: data) // request-specific validation - try await client.validate(response: response, data: data) // global validation - - let value = try await client.parse(response: response, data: data, for: call) - return (value, response) - } catch { - throw EndpointsError(error: error, response: response) - } - } -} +// +//public final class FakeSession: Session { +// public let client: CL +// public let debug: Bool = false +// public let resultProvider: FakeResultProvider +// +// public init(with client: CL, resultProvider: FakeResultProvider) { +// self.client = client +// self.resultProvider = resultProvider +// } +// +// public func dataTask( +// for call: C +// ) async throws -> (C.Parser.OutputType, HTTPURLResponse) { +// let (response, data) = try await resultProvider.data(for: call) +// +// guard let response = response as? HTTPURLResponse else { +// if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { +// Logger.default.debug("no response.") +// } else { +// os_log("no response.", log: .default, type: .debug) +// } +// +// throw EndpointsError( +// error: EndpointsParsingError.invalidData( +// description: "Response was not a valid HTTPURLResponse" +// ), +// response: nil +// ) +// } +// +// if debug { +// var message = "" +// message += "\(call.request.cURLRepresentation)\n" +// message += "\(response.debugDescription)\n" +// message += "\(data.debugDescription(encoding: response.stringEncoding))" +// +// if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { +// Logger.default.debug("\(message, privacy: .private)") +// } else { +// os_log("%s", log: .default, type: .debug, message) +// } +// } +// +// do { +// try await call.validate(response: response, data: data) // request-specific validation +// try await client.validate(response: response, data: data) // global validation +// +// let value = try await client.parse(response: response, data: data, for: call) +// return (value, response) +// } catch { +// throw EndpointsError(error: error, response: response) +// } +// } +//} From 36ed3defc81be7f4c6cc406f507d49c7af7cab16 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Wed, 1 Oct 2025 13:07:51 +0200 Subject: [PATCH 02/18] Fixed Tests for Swift 6 --- CLAUDE.md | 98 +++++++++++++++++++ .../Networking/HTTPBinClient.swift | 2 +- .../Networking/ManipulatedHTTPBinClient.swift | 2 +- .../Networking/PostmanEchoClient.swift | 2 +- Sources/Async/AnyClient.swift | 2 +- Sources/Parsing/DataParser.swift | 2 +- Tests/ClientTests.swift | 6 +- .../Codable/Endpoints+JSONCodableTests.swift | 11 ++- Tests/PostmanEcho/PostmanEchoClient.swift | 6 +- Tests/Utils/ClientTester.swift | 3 +- 10 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be01827 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the **Endpoints** Swift package - a type-safe network abstraction layer for Web APIs. It provides a clean separation of concerns using protocols and generics, with heavy use of Swift's type system to ensure compile-time safety for network operations. + +### Core Architecture + +The library is built around three main concepts: +- **Call**: Represents a specific API endpoint request and expected response type +- **Client**: Handles encoding requests and parsing responses for a Web API +- **Session**: Manages URLSession and executes calls asynchronously + +Key architectural patterns: +- Protocol-oriented design with extensive use of generics +- Value types (structs) preferred over reference types +- Async/await throughout (no completion handlers) +- Response parsing delegation via `ResponseParser` protocol + +### Directory Structure + +- `Sources/Core/`: Core protocols (`Call`, `Client`, `Session`, `Request`) +- `Sources/Parsing/`: Response parsers (`JSONParser`, `DataResponseParser`, etc.) +- `Sources/Body/`: Request body types (JSON, form-encoded, multipart) +- `Sources/Async/`: Async implementations (`AnyClient`, `Session`) +- `Sources/Convenience/`: Helper types and extensions +- `Sources/Error/`: Error types specific to Endpoints +- `Sources/Testing/`: Testing utilities (`FakeSession`, `FakeResultProvider`) +- `EndpointsTestbed/`: Example iOS app demonstrating usage patterns + +## Development Commands + +### Building and Testing +```bash +# Build the package +swift build + +# Run all tests +swift test + +# Build with verbose output +swift build -v + +# Run tests with verbose output +swift test -v +``` + +### Code Formatting +```bash +# Format code (using SwiftFormat dependency) +swift run swiftformat . + +# Check formatting without changes +swift run swiftformat . --lint +``` + +### Example App (EndpointsTestbed) +The testbed iOS app can be built and run through Xcode by opening `EndpointsTestbed/EndpointsTestbed.xcodeproj`. + +## Key Implementation Patterns + +### Creating Type-Safe Calls +```swift +struct GetUser: Call { + typealias Parser = JSONParser + + let userId: String + + var request: URLRequestEncodable { + Request(.get, "users/\(userId)") + } +} +``` + +### Custom Clients +Subclass `AnyClient` or implement `Client` protocol. Override `encode(call:)` for request manipulation or `parse(response:data:for:)` for response handling. + +### Response Parsers +- `JSONParser` for Codable types +- `DataResponseParser` for raw Data +- `DictionaryParser` for JSON dictionaries +- `StringParser` for string responses +- `NoContentParser` when response content doesn't matter + +## Swift Version and Compatibility + +- Requires Swift 6.2+ (see Package.swift) +- Supports iOS 13+, macOS 10.15+, tvOS 12+, watchOS 6+, visionOS 1+ +- Built for modern Swift concurrency (async/await) + +## Migration Notes + +Major version changes (v2.0.0, v3.0.0) included breaking changes. See `Migration/` directory for detailed upgrade guides. Most notably: +- v3.0.0 moved to native async/await (removed completion handler APIs) +- Response parsing moved from static to instance methods +- `Session.start()` replaced with `Session.dataTask(for:)` \ No newline at end of file diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift index aa1addf..51b492f 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift @@ -6,7 +6,7 @@ struct HTTPBinClient: Client { init() { let url = URL(string: "https://httpbin.org/")! - self.client = DefaultClient(baseURL: url) + self.client = AnyClient(baseURL: url) } struct GetStatusCode: Call { diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift index 4120d0d..273bd5a 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift @@ -6,7 +6,7 @@ struct ManipulatedHTTPBinClient: Client { init() { let url = URL(string: "https://httpbin.org/")! - self.client = DefaultClient(baseURL: url) + self.client = AnyClient(baseURL: url) } func encode(call: some Endpoints.Call) async throws -> URLRequest { diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift index 06e8238..d05bf2f 100644 --- a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift +++ b/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift @@ -6,7 +6,7 @@ struct PostmanEchoClient: Client { init() { let baseURL = URL(string: "https://postman-echo.com")! - self.client = DefaultClient(baseURL: baseURL) + self.client = AnyClient(baseURL: baseURL) } struct ExampleGetCall: Call { diff --git a/Sources/Async/AnyClient.swift b/Sources/Async/AnyClient.swift index ae3d8b0..07e2bad 100644 --- a/Sources/Async/AnyClient.swift +++ b/Sources/Async/AnyClient.swift @@ -2,7 +2,7 @@ import Foundation -public struct DefaultClient: Client { +public struct AnyClient: Client { /// To satisfy conformance only public var client: Client { self } diff --git a/Sources/Parsing/DataParser.swift b/Sources/Parsing/DataParser.swift index 4b7bfe3..6849b28 100644 --- a/Sources/Parsing/DataParser.swift +++ b/Sources/Parsing/DataParser.swift @@ -8,7 +8,7 @@ import Foundation /// request. public protocol DataParser: Sendable { /// The type that can be produced by `self`. - associatedtype OutputType + associatedtype OutputType: Sendable /// Converts a `Data` object with a specified encoding to `OutputType`. /// diff --git a/Tests/ClientTests.swift b/Tests/ClientTests.swift index e28f71e..db768d6 100644 --- a/Tests/ClientTests.swift +++ b/Tests/ClientTests.swift @@ -220,14 +220,16 @@ struct ClientTests { let url = try #require(URL(string: "get?q=a")) let c = AnyCall(URLRequest(url: url)) let (_, response) = try await tester.performTest(call: c) - #expect(response.url == URL(string: url.relativeString, relativeTo: tester.session.client.baseURL)?.absoluteURL) + let expectedUrl = URL(string: url.relativeString, relativeTo: await tester.session.client.baseURL)?.absoluteURL + #expect(response.url == expectedUrl) } @Test func testRedirect() async throws { let req = Request(.get, "relative-redirect/2", header: ["x": "y"]) let c = AnyCall(req) let (_, response) = try await tester.performTest(call: c) - #expect(response.url == URL(string: "get", relativeTo: tester.session.client.baseURL)?.absoluteURL) + let expectedUrl = URL(string: "get", relativeTo: await tester.session.client.baseURL)?.absoluteURL + #expect(response.url == expectedUrl) } @Test func testNoResponseBody() async throws { diff --git a/Tests/Codable/Endpoints+JSONCodableTests.swift b/Tests/Codable/Endpoints+JSONCodableTests.swift index d41a045..e39ef85 100644 --- a/Tests/Codable/Endpoints+JSONCodableTests.swift +++ b/Tests/Codable/Endpoints+JSONCodableTests.swift @@ -127,8 +127,13 @@ extension EndpointsJSONCodableTests.Person { } } -class DateCrashParser: JSONParser { - override var jsonDecoder: JSONDecoder { - EndpointsJSONCodableTests.getDateCrashDecoder() + +struct DateCrashParser: ResponseParser { + public typealias OutputType = T + + let jsonDecoder = EndpointsJSONCodableTests.getDateCrashDecoder() + + func parse(data: Data, encoding: String.Encoding) throws -> T { + try jsonDecoder.decode(OutputType.self, from: data) } } diff --git a/Tests/PostmanEcho/PostmanEchoClient.swift b/Tests/PostmanEcho/PostmanEchoClient.swift index 9d50c4f..5fa6290 100644 --- a/Tests/PostmanEcho/PostmanEchoClient.swift +++ b/Tests/PostmanEcho/PostmanEchoClient.swift @@ -3,10 +3,12 @@ @testable import Endpoints import Foundation -public class PostmanEchoClient: AnyClient { +public struct PostmanEchoClient: Client { + public var client: Client + public init() { let url = URL(string: "https://postman-echo.com")! - super.init(baseURL: url) + client = AnyClient(baseURL: url) } struct MyCall: Call { diff --git a/Tests/Utils/ClientTester.swift b/Tests/Utils/ClientTester.swift index 480d854..e35f685 100644 --- a/Tests/Utils/ClientTester.swift +++ b/Tests/Utils/ClientTester.swift @@ -5,12 +5,11 @@ import Foundation import Testing // Helper struct for running API calls within tests. -struct ClientTester { +struct ClientTester: Sendable { var session: Session init(client: CL) { self.session = Session(with: client) - session.debug = true } func performTest( From 0d528e4a3351721b02c289c389d916e2077ddbea Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 9 Oct 2025 07:27:30 +0200 Subject: [PATCH 03/18] Fixed README.md, updated testbed --- CLAUDE.md | 98 -------------- .../project.pbxproj | 125 +++--------------- LICENSE | 2 +- Package.resolved | 4 +- README.md | 80 ++++++----- 5 files changed, 69 insertions(+), 240 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index be01827..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,98 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the **Endpoints** Swift package - a type-safe network abstraction layer for Web APIs. It provides a clean separation of concerns using protocols and generics, with heavy use of Swift's type system to ensure compile-time safety for network operations. - -### Core Architecture - -The library is built around three main concepts: -- **Call**: Represents a specific API endpoint request and expected response type -- **Client**: Handles encoding requests and parsing responses for a Web API -- **Session**: Manages URLSession and executes calls asynchronously - -Key architectural patterns: -- Protocol-oriented design with extensive use of generics -- Value types (structs) preferred over reference types -- Async/await throughout (no completion handlers) -- Response parsing delegation via `ResponseParser` protocol - -### Directory Structure - -- `Sources/Core/`: Core protocols (`Call`, `Client`, `Session`, `Request`) -- `Sources/Parsing/`: Response parsers (`JSONParser`, `DataResponseParser`, etc.) -- `Sources/Body/`: Request body types (JSON, form-encoded, multipart) -- `Sources/Async/`: Async implementations (`AnyClient`, `Session`) -- `Sources/Convenience/`: Helper types and extensions -- `Sources/Error/`: Error types specific to Endpoints -- `Sources/Testing/`: Testing utilities (`FakeSession`, `FakeResultProvider`) -- `EndpointsTestbed/`: Example iOS app demonstrating usage patterns - -## Development Commands - -### Building and Testing -```bash -# Build the package -swift build - -# Run all tests -swift test - -# Build with verbose output -swift build -v - -# Run tests with verbose output -swift test -v -``` - -### Code Formatting -```bash -# Format code (using SwiftFormat dependency) -swift run swiftformat . - -# Check formatting without changes -swift run swiftformat . --lint -``` - -### Example App (EndpointsTestbed) -The testbed iOS app can be built and run through Xcode by opening `EndpointsTestbed/EndpointsTestbed.xcodeproj`. - -## Key Implementation Patterns - -### Creating Type-Safe Calls -```swift -struct GetUser: Call { - typealias Parser = JSONParser - - let userId: String - - var request: URLRequestEncodable { - Request(.get, "users/\(userId)") - } -} -``` - -### Custom Clients -Subclass `AnyClient` or implement `Client` protocol. Override `encode(call:)` for request manipulation or `parse(response:data:for:)` for response handling. - -### Response Parsers -- `JSONParser` for Codable types -- `DataResponseParser` for raw Data -- `DictionaryParser` for JSON dictionaries -- `StringParser` for string responses -- `NoContentParser` when response content doesn't matter - -## Swift Version and Compatibility - -- Requires Swift 6.2+ (see Package.swift) -- Supports iOS 13+, macOS 10.15+, tvOS 12+, watchOS 6+, visionOS 1+ -- Built for modern Swift concurrency (async/await) - -## Migration Notes - -Major version changes (v2.0.0, v3.0.0) included breaking changes. See `Migration/` directory for detailed upgrade guides. Most notably: -- v3.0.0 moved to native async/await (removed completion handler APIs) -- Response parsing moved from static to instance methods -- `Session.start()` replaced with `Session.dataTask(for:)` \ No newline at end of file diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj index 4ad6830..666a309 100644 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj +++ b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj @@ -8,22 +8,8 @@ /* Begin PBXBuildFile section */ 8211A84D2AFBDBFD00A36244 /* AsyncReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 8211A84C2AFBDBFD00A36244 /* AsyncReactor */; }; - 8211A84F2AFBDC1200A36244 /* ExampleReactorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8211A84E2AFBDC1200A36244 /* ExampleReactorView.swift */; }; - 8211A8512AFBDC2000A36244 /* ExampleReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8211A8502AFBDC2000A36244 /* ExampleReactor.swift */; }; - 82B1AB9E29E3544B007B23C1 /* ExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B1AB9D29E3544B007B23C1 /* ExampleView.swift */; }; - 82B1ABA029E35455007B23C1 /* ExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B1AB9F29E35455007B23C1 /* ExampleViewModel.swift */; }; - 82B1ABA229E35486007B23C1 /* ExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B1ABA129E35486007B23C1 /* ExampleModel.swift */; }; - 82B1ABA529E354C0007B23C1 /* PostmanEchoClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B1ABA429E354C0007B23C1 /* PostmanEchoClient.swift */; }; - 82BA52F129DEE35C00F7726A /* EndpointsTestbedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BA52F029DEE35C00F7726A /* EndpointsTestbedApp.swift */; }; - 82BA52F529DEE35D00F7726A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 82BA52F429DEE35D00F7726A /* Assets.xcassets */; }; - 82BA52F829DEE35D00F7726A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 82BA52F729DEE35D00F7726A /* Preview Assets.xcassets */; }; - 82BA530229DEE35D00F7726A /* EndpointsTestbedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BA530129DEE35D00F7726A /* EndpointsTestbedTests.swift */; }; 82BA531E29DEE73D00F7726A /* Endpoints in Frameworks */ = {isa = PBXBuildFile; productRef = 82BA531D29DEE73D00F7726A /* Endpoints */; }; - 82E74FFC2AADE042001A230C /* HTTPBinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */; }; - 82E74FFE2AADE19A001A230C /* ManipulatedHTTPBinClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */; }; - FA7846C72E72A94500E4B067 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7846C62E72A93900E4B067 /* API.swift */; }; FAD06B6B2E8C1C8C00E87155 /* Injection in Frameworks */ = {isa = PBXBuildFile; productRef = FAD06B6A2E8C1C8C00E87155 /* Injection */; }; - FAD06B6D2E8C1D5800E87155 /* DI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD06B6C2E8C1D5600E87155 /* DI.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,25 +23,24 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 8211A84E2AFBDC1200A36244 /* ExampleReactorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleReactorView.swift; sourceTree = ""; }; - 8211A8502AFBDC2000A36244 /* ExampleReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleReactor.swift; sourceTree = ""; }; - 82B1AB9D29E3544B007B23C1 /* ExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleView.swift; sourceTree = ""; }; - 82B1AB9F29E35455007B23C1 /* ExampleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleViewModel.swift; sourceTree = ""; }; - 82B1ABA129E35486007B23C1 /* ExampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleModel.swift; sourceTree = ""; }; - 82B1ABA429E354C0007B23C1 /* PostmanEchoClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostmanEchoClient.swift; sourceTree = ""; }; 82BA52ED29DEE35C00F7726A /* EndpointsTestbed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EndpointsTestbed.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 82BA52F029DEE35C00F7726A /* EndpointsTestbedApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointsTestbedApp.swift; sourceTree = ""; }; - 82BA52F429DEE35D00F7726A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 82BA52F729DEE35D00F7726A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EndpointsTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 82BA530129DEE35D00F7726A /* EndpointsTestbedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointsTestbedTests.swift; sourceTree = ""; }; 82BA531B29DEE38000F7726A /* Endpoints */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Endpoints; path = ..; sourceTree = ""; }; - 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPBinClient.swift; sourceTree = ""; }; - 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManipulatedHTTPBinClient.swift; sourceTree = ""; }; - FA7846C62E72A93900E4B067 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; - FAD06B6C2E8C1D5600E87155 /* DI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DI.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + FA07EB582E977E6900D9EA84 /* EndpointsTestbed */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EndpointsTestbed; + sourceTree = ""; + }; + FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = EndpointsTestbedTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 82BA52EA29DEE35C00F7726A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -77,42 +62,12 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 8211A84A2AFBDBD200A36244 /* ExampleAsyncReactor */ = { - isa = PBXGroup; - children = ( - 8211A84E2AFBDC1200A36244 /* ExampleReactorView.swift */, - 8211A8502AFBDC2000A36244 /* ExampleReactor.swift */, - ); - path = ExampleAsyncReactor; - sourceTree = ""; - }; - 82B1AB9C29E3543C007B23C1 /* ExampleMVVM */ = { - isa = PBXGroup; - children = ( - 82B1AB9D29E3544B007B23C1 /* ExampleView.swift */, - 82B1AB9F29E35455007B23C1 /* ExampleViewModel.swift */, - 82B1ABA129E35486007B23C1 /* ExampleModel.swift */, - ); - path = ExampleMVVM; - sourceTree = ""; - }; - 82B1ABA329E354B0007B23C1 /* Networking */ = { - isa = PBXGroup; - children = ( - FA7846C62E72A93900E4B067 /* API.swift */, - 82B1ABA429E354C0007B23C1 /* PostmanEchoClient.swift */, - 82E74FFB2AADE042001A230C /* HTTPBinClient.swift */, - 82E74FFD2AADE19A001A230C /* ManipulatedHTTPBinClient.swift */, - ); - path = Networking; - sourceTree = ""; - }; 82BA52E429DEE35C00F7726A = { isa = PBXGroup; children = ( 82BA531A29DEE38000F7726A /* Packages */, - 82BA52EF29DEE35C00F7726A /* EndpointsTestbed */, - 82BA530029DEE35D00F7726A /* EndpointsTestbedTests */, + FA07EB582E977E6900D9EA84 /* EndpointsTestbed */, + FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */, 82BA52EE29DEE35C00F7726A /* Products */, 82BA531C29DEE73D00F7726A /* Frameworks */, ); @@ -127,36 +82,6 @@ name = Products; sourceTree = ""; }; - 82BA52EF29DEE35C00F7726A /* EndpointsTestbed */ = { - isa = PBXGroup; - children = ( - FAD06B6C2E8C1D5600E87155 /* DI.swift */, - 82B1ABA329E354B0007B23C1 /* Networking */, - 8211A84A2AFBDBD200A36244 /* ExampleAsyncReactor */, - 82B1AB9C29E3543C007B23C1 /* ExampleMVVM */, - 82BA52F029DEE35C00F7726A /* EndpointsTestbedApp.swift */, - 82BA52F429DEE35D00F7726A /* Assets.xcassets */, - 82BA52F629DEE35D00F7726A /* Preview Content */, - ); - path = EndpointsTestbed; - sourceTree = ""; - }; - 82BA52F629DEE35D00F7726A /* Preview Content */ = { - isa = PBXGroup; - children = ( - 82BA52F729DEE35D00F7726A /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; - 82BA530029DEE35D00F7726A /* EndpointsTestbedTests */ = { - isa = PBXGroup; - children = ( - 82BA530129DEE35D00F7726A /* EndpointsTestbedTests.swift */, - ); - path = EndpointsTestbedTests; - sourceTree = ""; - }; 82BA531A29DEE38000F7726A /* Packages */ = { isa = PBXGroup; children = ( @@ -187,6 +112,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + FA07EB582E977E6900D9EA84 /* EndpointsTestbed */, + ); name = EndpointsTestbed; packageProductDependencies = ( 82BA531D29DEE73D00F7726A /* Endpoints */, @@ -210,6 +138,9 @@ dependencies = ( 82BA52FF29DEE35D00F7726A /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */, + ); name = EndpointsTestbedTests; productName = EndpointsTestbedTests; productReference = 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */; @@ -262,8 +193,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 82BA52F829DEE35D00F7726A /* Preview Assets.xcassets in Resources */, - 82BA52F529DEE35D00F7726A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -281,17 +210,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 82B1ABA529E354C0007B23C1 /* PostmanEchoClient.swift in Sources */, - 82B1ABA029E35455007B23C1 /* ExampleViewModel.swift in Sources */, - 82E74FFC2AADE042001A230C /* HTTPBinClient.swift in Sources */, - 82B1ABA229E35486007B23C1 /* ExampleModel.swift in Sources */, - FAD06B6D2E8C1D5800E87155 /* DI.swift in Sources */, - FA7846C72E72A94500E4B067 /* API.swift in Sources */, - 8211A84F2AFBDC1200A36244 /* ExampleReactorView.swift in Sources */, - 82BA52F129DEE35C00F7726A /* EndpointsTestbedApp.swift in Sources */, - 82E74FFE2AADE19A001A230C /* ManipulatedHTTPBinClient.swift in Sources */, - 82B1AB9E29E3544B007B23C1 /* ExampleView.swift in Sources */, - 8211A8512AFBDC2000A36244 /* ExampleReactor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -299,7 +217,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 82BA530229DEE35D00F7726A /* EndpointsTestbedTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LICENSE b/LICENSE index 7ed0942..088b025 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2022 Tailored Media GmbH +Copyright (c) 2025 Tailored Media GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.resolved b/Package.resolved index b3a48dc..e534118 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/diamirio/AsyncReactor", "state" : { - "revision" : "23292274e53eedae5c2198536a3a476355fe59d4", - "version" : "1.2.0" + "revision" : "e3fde7f9c1fb4db349039796c5c138571c71dadd", + "version" : "1.2.1" } }, { diff --git a/README.md b/README.md index c947f1c..bd08760 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Endpoints makes it easy to write a type-safe network abstraction layer for any Web-API. -It requires Swift 5, makes heavy use of generics (and generalized existentials) and protocols (and protocol extensions). It also encourages a clean separation of concerns and the use of value types (i.e. structs). +It requires Swift 6.2+, makes heavy use of generics and protocols (with protocol extensions). It also encourages a clean separation of concerns and the use of value types (i.e. structs). Built for modern Swift concurrency with async/await and actor support. ## Usage @@ -24,12 +24,9 @@ let client = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!) // A call encapsulates the request that is sent to the server and the type that is expected in the response. let call = AnyCall(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"])) -// A session wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error) in a completion block. +// A session wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error). let session = Session(with: client) -// enable debug-mode to log network traffic -session.debug = true - // start call let (body, httpResponse) = try await session.dataTask(for: call) ``` @@ -76,8 +73,8 @@ Look up the documentation in the code for further explanations of the types. ##### Decoding -The `ResponseParser` responsible for handling decodable types is the `JSONParser`. -The `JSONParser` uses the default `JSONDecoder()`, however, the `JSONParser` can be subclassed, and the `jsonDecoder` can be overwritten with your configured `JSONDecoder`. +The `ResponseParser` responsible for handling decodable types is the `JSONParser`. +The `JSONParser` uses the default `JSONDecoder()` by default, but you can customize it by creating a custom parser with a configured decoder. ```swift // Decode a type using the default decoder @@ -86,18 +83,26 @@ struct GiphyCall: Call { ... } -// custom decoder +// Custom parser with configured decoder +struct CustomJSONParser: ResponseParser { + typealias OutputType = T + + let jsonDecoder: JSONDecoder -struct GiphyParser: JSONParser { - override public var jsonDecoder: JSONDecoder { + init() { let decoder = JSONDecoder() - // configure... - return decoder + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + self.jsonDecoder = decoder + } + + func parse(data: Data, encoding: String.Encoding) throws -> T { + try jsonDecoder.decode(T.self, from: data) } } struct GiphyCall: Call { - typealias Parser = GiphyParser + typealias Parser = CustomJSONParser ... } ``` @@ -131,29 +136,33 @@ A client is responsible for handling things that are common for all operations o `AnyClient` is the default implementation of the `Client` protocol and can be used as-is or as a starting point for your own dedicated client. -You'll usually need to create your own dedicated client that either subclasses `AnyClient` or delegates the encoding of requests and parsing of responses to an `AnyClient` instance, as done here: +You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to an `AnyClient` instance, as done here: ```swift -class GiphyClient: Client { - private let anyClient = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!) - +struct GiphyClient: Client { + var client: Client var apiKey = "dc6zaTOxFJmzC" - - override func encode(call: C) async throws -> URLRequest { - var request = anyClient.encode(call: call) - + + init() { + let url = URL(string: "https://api.giphy.com/v1/")! + self.client = AnyClient(baseURL: url) + } + + func encode(call: some Call) async throws -> URLRequest { + var request = try await client.encode(call: call) + // Append the API key to every request - request.append(query: ["api_key": apiKey]) - + request.append(query: ["api_key": apiKey]) + return request } - - override func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType + + func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType where C: Call { do { // Use `AnyClient` to parse the response // If this fails, try to read error details from response body - return try await anyClient.parse(sessionTaskResult: result, for: call) + return try await client.parse(response: response, data: data, for: call) } catch { // See if the backend sent detailed error information guard @@ -161,12 +170,12 @@ class GiphyClient: Client { let data, let errorDict = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any], let meta = errorDict?["meta"] as? [String: Any], - let errorCode = meta["error_code"] as? String + let errorCode = meta["error_code"] as? String else { // no error info from backend -> rethrow default error throw error } - + // Propagate error that contains errorCode as reason from backend throw StatusCodeError.unacceptable(code: response.statusCode, reason: errorCode) } @@ -214,8 +223,8 @@ print("image url: \(body.data.url)") **Swift Package Manager:** -```bash -.package(url: "https://github.com/tailoredmedia/Endpoints.git", .upToNextMajor(from: "3.0.0")) +```swift +.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "3.0.0")) ``` ## Example @@ -224,8 +233,9 @@ Example implementation can be found [here](./EndpointsTestbed). ## Requirements -* Swift 5 -* iOS 13 -* tvOS 12 -* macOS 10.15 -* watchOS 6 +* Swift 6.2+ +* iOS 13+ +* tvOS 12+ +* macOS 10.15+ +* watchOS 6+ +* visionOS 1+ From b1d82e78289986f13b31d0a864d8c99da61b6d6c Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 9 Oct 2025 07:33:32 +0200 Subject: [PATCH 04/18] Removed SwiftFormat from Package dependencies --- .github/workflows/swiftformat.yml | 4 +++- Package.resolved | 11 +---------- Package.swift | 4 +--- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/.github/workflows/swiftformat.yml b/.github/workflows/swiftformat.yml index d81f357..da2a2c2 100644 --- a/.github/workflows/swiftformat.yml +++ b/.github/workflows/swiftformat.yml @@ -12,6 +12,8 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '26.0' + - name: Install SwiftFromat + run: brew install swiftformat - name: Check SwiftFromat - run: swift run swiftformat . --lint + run: swiftformat . --lint diff --git a/Package.resolved b/Package.resolved index e534118..0145b00 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8f53f9125394eadf02a9aafd4af63a7efd6c6d7da3c28070ac429a207bf1b97d", + "originHash" : "1859cf73baf45095b1a4fbf166f3ec4d682a8c15e5aacd17d83bb369880481b3", "pins" : [ { "identity" : "asyncreactor", @@ -18,15 +18,6 @@ "revision" : "57a66d3f21ff5564542a6b157838ae97b67447f0", "version" : "1.1.0" } - }, - { - "identity" : "swiftformat", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nicklockwood/SwiftFormat", - "state" : { - "revision" : "ad7707bd34a33fa64a2c593c53deaa7d7469e2f0", - "version" : "0.52.11" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 433c837..b9367d7 100755 --- a/Package.swift +++ b/Package.swift @@ -17,9 +17,7 @@ let package = Package( targets: ["Endpoints"] ) ], - dependencies: [ - .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.4") - ], + dependencies: [], targets: [ .target( name: "Endpoints", From db7c5b2e91a45efdc8db5f9ae5f72851c13beb26 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 9 Oct 2025 07:38:18 +0200 Subject: [PATCH 05/18] Swift 6 format --- Sources/Async/AnyClient.swift | 2 +- Sources/Async/Client.swift | 8 ++-- Sources/Async/Session.swift | 2 +- .../Parsing/ResponseParser/JSONParser.swift | 4 +- Sources/Testing/FakeResultProvider.swift | 2 +- Sources/Testing/FakeSession.swift | 4 +- Tests/ClientTests.swift | 46 +++++++++---------- .../Codable/Endpoints+JSONCodableTests.swift | 7 ++- Tests/PostmanEcho/PostmanEchoClient.swift | 4 +- 9 files changed, 39 insertions(+), 40 deletions(-) diff --git a/Sources/Async/AnyClient.swift b/Sources/Async/AnyClient.swift index 07e2bad..9a72722 100644 --- a/Sources/Async/AnyClient.swift +++ b/Sources/Async/AnyClient.swift @@ -5,7 +5,7 @@ import Foundation public struct AnyClient: Client { /// To satisfy conformance only public var client: Client { self } - + /// The base URL used by `encode` to convert `Call`s into `URLRequest`s. public let baseURL: URL diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index 6d21c9e..da7cd6b 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -6,10 +6,10 @@ import Foundation /// A basic implementation is provided by `AnyClient`. public protocol Client: ResponseValidator, Sendable { var client: Client { get } - + /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. - func encode(call: C) async throws -> URLRequest + func encode(call: some Call) async throws -> URLRequest /// Converts the `URLSession`s result for a `Call` to /// this client's Web API into the expected output type. @@ -25,7 +25,7 @@ public protocol Client: ResponseValidator, Sendable { public extension Client { /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. - func encode(call: C) async throws -> URLRequest { + func encode(call: some Call) async throws -> URLRequest { try await client.encode(call: call) } @@ -40,7 +40,7 @@ public extension Client { ) async throws -> C.Parser.OutputType { try await client.parse(response: response, data: data, for: call) } - + func validate( response: HTTPURLResponse?, data: Data? diff --git a/Sources/Async/Session.swift b/Sources/Async/Session.swift index 0e3062d..2ae8a0a 100644 --- a/Sources/Async/Session.swift +++ b/Sources/Async/Session.swift @@ -2,7 +2,7 @@ import Foundation #if canImport(OSLog) -import OSLog + import OSLog #endif public actor Session { diff --git a/Sources/Parsing/ResponseParser/JSONParser.swift b/Sources/Parsing/ResponseParser/JSONParser.swift index 4e6b413..d306fc7 100644 --- a/Sources/Parsing/ResponseParser/JSONParser.swift +++ b/Sources/Parsing/ResponseParser/JSONParser.swift @@ -8,11 +8,11 @@ public struct JSONParser: ResponseParser { public typealias OutputType = T public var jsonDecoder: JSONDecoder - + public init() { self.jsonDecoder = JSONDecoder() } - + public func parse(data: Data, encoding _: String.Encoding) throws -> OutputType { try jsonDecoder.decode(OutputType.self, from: data) } diff --git a/Sources/Testing/FakeResultProvider.swift b/Sources/Testing/FakeResultProvider.swift index 48aae56..94c00f4 100644 --- a/Sources/Testing/FakeResultProvider.swift +++ b/Sources/Testing/FakeResultProvider.swift @@ -1,5 +1,5 @@ import Foundation public protocol FakeResultProvider: Sendable { - func data(for call: C) async throws -> (URLResponse, Data) + func data(for call: some Call) async throws -> (URLResponse, Data) } diff --git a/Sources/Testing/FakeSession.swift b/Sources/Testing/FakeSession.swift index 8af2843..a50ee19 100644 --- a/Sources/Testing/FakeSession.swift +++ b/Sources/Testing/FakeSession.swift @@ -5,7 +5,7 @@ import Foundation import OSLog #endif // -//public final class FakeSession: Session { +// public final class FakeSession: Session { // public let client: CL // public let debug: Bool = false // public let resultProvider: FakeResultProvider @@ -58,4 +58,4 @@ import Foundation // throw EndpointsError(error: error, response: response) // } // } -//} +// } diff --git a/Tests/ClientTests.swift b/Tests/ClientTests.swift index db768d6..d63fb46 100644 --- a/Tests/ClientTests.swift +++ b/Tests/ClientTests.swift @@ -11,7 +11,7 @@ struct ClientTests { self.tester = ClientTester(client: AnyClient(baseURL: baseURL)) } - @Test func testStatusError() async throws { + @Test func statusError() async throws { do { let call = AnyCall(Request(.get, "status/400")) _ = try await tester.performTest(call: call) @@ -23,14 +23,14 @@ struct ClientTests { } } - @Test func testGetData() async throws { + @Test func getData() async throws { let call = AnyCall(Request(.get, "get")) let (_, response) = try await tester.performTest(call: call) #expect(response.statusCode == 200) } @MainActor - @Test func testGetDataWithCancellation() async throws { + @Test func getDataWithCancellation() async throws { let task = Task { do { let call = AnyCall(Request(.get, "delay/5")) @@ -50,7 +50,7 @@ struct ClientTests { } @MainActor - @Test func testGetDataWithCancellationWhenTaskIsNotStarted() async throws { + @Test func getDataWithCancellationWhenTaskIsNotStarted() async throws { let task = Task { do { let call = AnyCall(Request(.get, "delay/5")) @@ -67,7 +67,7 @@ struct ClientTests { _ = await task.value } - @Test func testPostRawString() async throws { + @Test func postRawString() async throws { let requestBody = "body" let call = AnyCall>(Request( .post, @@ -81,7 +81,7 @@ struct ClientTests { #expect(headers["Content-Type"] == "raw") } - @Test func testPostString() async throws { + @Test func postString() async throws { let requestBody = "key=value" let call = AnyCall>(Request(.post, "post", body: requestBody)) let (body, _) = try await tester.performTest(call: call) @@ -90,7 +90,7 @@ struct ClientTests { #expect(form["key"] == "value") } - @Test func testPostFormEncodedBody() async throws { + @Test func postFormEncodedBody() async throws { let params = ["key": "&=?value+*-:_.😀"] let requestBody = FormEncodedBody(parameters: params) let call = AnyCall>(Request(.post, "post", body: requestBody)) @@ -103,14 +103,14 @@ struct ClientTests { #expect(headers["Content-Type"] == "application/x-www-form-urlencoded") } - @Test func testPostJSONBody() async throws { + @Test func postJSONBody() async throws { let params = ["key": "value"] let body = try JSONEncodedBody(jsonObject: params) let json = try await _testPostJSONBody(body: body) #expect(json == params) } - @Test func testPostJSONBodyEncodable() async throws { + @Test func postJSONBodyEncodable() async throws { let params = ["key": "value"] let json = try await _testPostJSONBody(body: JSONEncodedBody(encodable: params)) #expect(json == params) @@ -128,14 +128,14 @@ struct ClientTests { return json } - @Test func testGetString() async throws { + @Test func getString() async throws { let c = AnyCall(Request(.get, "get", query: ["inputParam": "inputParamValue"])) let (body, response) = try await tester.performTest(call: c) #expect(response.statusCode == 200) #expect(body.contains("inputParamValue")) } - @Test func testGetJSONDictionary() async throws { + @Test func getJSONDictionary() async throws { let c = AnyCall>(Request(.get, "get", query: ["inputParam": "inputParamValue"])) let (body, _) = try await tester.performTest(call: c) @@ -146,7 +146,7 @@ struct ClientTests { #expect(param == "inputParamValue") } - @Test func testParseJSONArray() throws { + @Test func parseJSONArray() throws { let inputArray = ["one", "two", "three"] let arrayData = try JSONSerialization.data(withJSONObject: inputArray) @@ -155,7 +155,7 @@ struct ClientTests { #expect(inputArray == parsedObject) } - @Test func testFailStringParsing() throws { + @Test func failStringParsing() throws { let input = "😜 test" let data = try #require(input.data(using: .utf8)) @@ -164,7 +164,7 @@ struct ClientTests { } } - @Test func testFailJSONParsing() async throws { + @Test func failJSONParsing() async throws { let c = AnyCall>(Request(.get, "xml")) let error = await #expect(throws: EndpointsError.self) { @@ -179,7 +179,7 @@ struct ClientTests { } } - @Test func testTypedRequest() async throws { + @Test func typedRequest() async throws { let value = "value" let c = GetOutput(value: value) let (body, _) = try await tester.performTest(call: c) @@ -190,14 +190,14 @@ struct ClientTests { #expect(param == value) } - @Test func testBasicAuth() async throws { + @Test func basicAuth() async throws { let auth = BasicAuthorization(user: "a", password: "a") let c = AnyCall(Request(.get, "basic-auth/a/a", header: auth.header)) let (_, response) = try await tester.performTest(call: c) #expect(response.statusCode == 200) } - @Test func testBasicAuthFail() async throws { + @Test func basicAuthFail() async throws { let auth = BasicAuthorization(user: "a", password: "b") let c = AnyCall(Request(.get, "basic-auth/a/a", header: auth.header)) @@ -209,30 +209,30 @@ struct ClientTests { #expect(response.statusCode == 401) } - @Test func testSimpleAbsoluteURLCall() async throws { + @Test func simpleAbsoluteURLCall() async throws { let url = try #require(URL(string: "https://httpbin.org/get?q=a")) let c = AnyCall(url) let (_, response) = try await tester.performTest(call: c) #expect(response.url == url) } - @Test func testSimpleRelativeURLRequestCall() async throws { + @Test func simpleRelativeURLRequestCall() async throws { let url = try #require(URL(string: "get?q=a")) let c = AnyCall(URLRequest(url: url)) let (_, response) = try await tester.performTest(call: c) - let expectedUrl = URL(string: url.relativeString, relativeTo: await tester.session.client.baseURL)?.absoluteURL + let expectedUrl = await URL(string: url.relativeString, relativeTo: tester.session.client.baseURL)?.absoluteURL #expect(response.url == expectedUrl) } - @Test func testRedirect() async throws { + @Test func redirect() async throws { let req = Request(.get, "relative-redirect/2", header: ["x": "y"]) let c = AnyCall(req) let (_, response) = try await tester.performTest(call: c) - let expectedUrl = URL(string: "get", relativeTo: await tester.session.client.baseURL)?.absoluteURL + let expectedUrl = await URL(string: "get", relativeTo: tester.session.client.baseURL)?.absoluteURL #expect(response.url == expectedUrl) } - @Test func testNoResponseBody() async throws { + @Test func noResponseBody() async throws { let c = AnyCall(Request(.get, "status/200")) let (_, response) = try await tester.performTest(call: c) #expect(response.statusCode == 200) diff --git a/Tests/Codable/Endpoints+JSONCodableTests.swift b/Tests/Codable/Endpoints+JSONCodableTests.swift index e39ef85..a670659 100644 --- a/Tests/Codable/Endpoints+JSONCodableTests.swift +++ b/Tests/Codable/Endpoints+JSONCodableTests.swift @@ -127,12 +127,11 @@ extension EndpointsJSONCodableTests.Person { } } - struct DateCrashParser: ResponseParser { - public typealias OutputType = T - + typealias OutputType = T + let jsonDecoder = EndpointsJSONCodableTests.getDateCrashDecoder() - + func parse(data: Data, encoding: String.Encoding) throws -> T { try jsonDecoder.decode(OutputType.self, from: data) } diff --git a/Tests/PostmanEcho/PostmanEchoClient.swift b/Tests/PostmanEcho/PostmanEchoClient.swift index 5fa6290..34f0cc3 100644 --- a/Tests/PostmanEcho/PostmanEchoClient.swift +++ b/Tests/PostmanEcho/PostmanEchoClient.swift @@ -5,10 +5,10 @@ import Foundation public struct PostmanEchoClient: Client { public var client: Client - + public init() { let url = URL(string: "https://postman-echo.com")! - client = AnyClient(baseURL: url) + self.client = AnyClient(baseURL: url) } struct MyCall: Call { From f94349bf4489be4b4829c759aed302ed8e308ce0 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 9 Oct 2025 09:41:12 +0200 Subject: [PATCH 06/18] Moved example to another repository --- .../project.pbxproj | 519 ------------------ .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcschemes/EndpointsTestbed.xcscheme | 111 ---- .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 13 - .../Assets.xcassets/Contents.json | 6 - EndpointsTestbed/EndpointsTestbed/DI.swift | 17 - .../EndpointsTestbedApp.swift | 24 - .../ExampleAsyncReactor/ExampleReactor.swift | 41 -- .../ExampleReactorView.swift | 26 - .../ExampleMVVM/ExampleModel.swift | 5 - .../ExampleMVVM/ExampleView.swift | 25 - .../ExampleMVVM/ExampleViewModel.swift | 36 -- .../EndpointsTestbed/Networking/API.swift | 32 -- .../Networking/HTTPBinClient.swift | 21 - .../Networking/ManipulatedHTTPBinClient.swift | 40 -- .../Networking/PostmanEchoClient.swift | 19 - .../Preview Assets.xcassets/Contents.json | 6 - .../EndpointsTestbedTests.swift | 35 -- Package.resolved | 24 - README.md | 63 ++- 22 files changed, 39 insertions(+), 1050 deletions(-) delete mode 100644 EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj delete mode 100644 EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 EndpointsTestbed/EndpointsTestbed.xcodeproj/xcshareddata/xcschemes/EndpointsTestbed.xcscheme delete mode 100644 EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 EndpointsTestbed/EndpointsTestbed/Assets.xcassets/Contents.json delete mode 100644 EndpointsTestbed/EndpointsTestbed/DI.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactorView.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleModel.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleView.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/Networking/API.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift delete mode 100644 EndpointsTestbed/EndpointsTestbed/Preview Content/Preview Assets.xcassets/Contents.json delete mode 100644 EndpointsTestbed/EndpointsTestbedTests/EndpointsTestbedTests.swift delete mode 100644 Package.resolved diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj deleted file mode 100644 index 666a309..0000000 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.pbxproj +++ /dev/null @@ -1,519 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 8211A84D2AFBDBFD00A36244 /* AsyncReactor in Frameworks */ = {isa = PBXBuildFile; productRef = 8211A84C2AFBDBFD00A36244 /* AsyncReactor */; }; - 82BA531E29DEE73D00F7726A /* Endpoints in Frameworks */ = {isa = PBXBuildFile; productRef = 82BA531D29DEE73D00F7726A /* Endpoints */; }; - FAD06B6B2E8C1C8C00E87155 /* Injection in Frameworks */ = {isa = PBXBuildFile; productRef = FAD06B6A2E8C1C8C00E87155 /* Injection */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 82BA52FE29DEE35D00F7726A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 82BA52E529DEE35C00F7726A /* Project object */; - proxyType = 1; - remoteGlobalIDString = 82BA52EC29DEE35C00F7726A; - remoteInfo = EndpointsTestbed; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 82BA52ED29DEE35C00F7726A /* EndpointsTestbed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EndpointsTestbed.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EndpointsTestbedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 82BA531B29DEE38000F7726A /* Endpoints */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Endpoints; path = ..; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - FA07EB582E977E6900D9EA84 /* EndpointsTestbed */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = EndpointsTestbed; - sourceTree = ""; - }; - FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = EndpointsTestbedTests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 82BA52EA29DEE35C00F7726A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - FAD06B6B2E8C1C8C00E87155 /* Injection in Frameworks */, - 8211A84D2AFBDBFD00A36244 /* AsyncReactor in Frameworks */, - 82BA531E29DEE73D00F7726A /* Endpoints in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 82BA52FA29DEE35D00F7726A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 82BA52E429DEE35C00F7726A = { - isa = PBXGroup; - children = ( - 82BA531A29DEE38000F7726A /* Packages */, - FA07EB582E977E6900D9EA84 /* EndpointsTestbed */, - FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */, - 82BA52EE29DEE35C00F7726A /* Products */, - 82BA531C29DEE73D00F7726A /* Frameworks */, - ); - sourceTree = ""; - }; - 82BA52EE29DEE35C00F7726A /* Products */ = { - isa = PBXGroup; - children = ( - 82BA52ED29DEE35C00F7726A /* EndpointsTestbed.app */, - 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 82BA531A29DEE38000F7726A /* Packages */ = { - isa = PBXGroup; - children = ( - 82BA531B29DEE38000F7726A /* Endpoints */, - ); - name = Packages; - sourceTree = ""; - }; - 82BA531C29DEE73D00F7726A /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 82BA52EC29DEE35C00F7726A /* EndpointsTestbed */ = { - isa = PBXNativeTarget; - buildConfigurationList = 82BA531129DEE35D00F7726A /* Build configuration list for PBXNativeTarget "EndpointsTestbed" */; - buildPhases = ( - 82BA52E929DEE35C00F7726A /* Sources */, - 82BA52EA29DEE35C00F7726A /* Frameworks */, - 82BA52EB29DEE35C00F7726A /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - FA07EB582E977E6900D9EA84 /* EndpointsTestbed */, - ); - name = EndpointsTestbed; - packageProductDependencies = ( - 82BA531D29DEE73D00F7726A /* Endpoints */, - 8211A84C2AFBDBFD00A36244 /* AsyncReactor */, - FAD06B6A2E8C1C8C00E87155 /* Injection */, - ); - productName = EndpointsTestbed; - productReference = 82BA52ED29DEE35C00F7726A /* EndpointsTestbed.app */; - productType = "com.apple.product-type.application"; - }; - 82BA52FC29DEE35D00F7726A /* EndpointsTestbedTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 82BA531429DEE35D00F7726A /* Build configuration list for PBXNativeTarget "EndpointsTestbedTests" */; - buildPhases = ( - 82BA52F929DEE35D00F7726A /* Sources */, - 82BA52FA29DEE35D00F7726A /* Frameworks */, - 82BA52FB29DEE35D00F7726A /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 82BA52FF29DEE35D00F7726A /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - FA07EB672E977E6F00D9EA84 /* EndpointsTestbedTests */, - ); - name = EndpointsTestbedTests; - productName = EndpointsTestbedTests; - productReference = 82BA52FD29DEE35D00F7726A /* EndpointsTestbedTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 82BA52E529DEE35C00F7726A /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1500; - TargetAttributes = { - 82BA52EC29DEE35C00F7726A = { - CreatedOnToolsVersion = 14.3; - }; - 82BA52FC29DEE35D00F7726A = { - CreatedOnToolsVersion = 14.3; - TestTargetID = 82BA52EC29DEE35C00F7726A; - }; - }; - }; - buildConfigurationList = 82BA52E829DEE35C00F7726A /* Build configuration list for PBXProject "EndpointsTestbed" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 82BA52E429DEE35C00F7726A; - packageReferences = ( - 8211A84B2AFBDBFD00A36244 /* XCRemoteSwiftPackageReference "AsyncReactor" */, - FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 82BA52EE29DEE35C00F7726A /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 82BA52EC29DEE35C00F7726A /* EndpointsTestbed */, - 82BA52FC29DEE35D00F7726A /* EndpointsTestbedTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 82BA52EB29DEE35C00F7726A /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 82BA52FB29DEE35D00F7726A /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 82BA52E929DEE35C00F7726A /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 82BA52F929DEE35D00F7726A /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 82BA52FF29DEE35D00F7726A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 82BA52EC29DEE35C00F7726A /* EndpointsTestbed */; - targetProxy = 82BA52FE29DEE35D00F7726A /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 82BA530F29DEE35D00F7726A /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 28TM58T3GZ; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = complete; - }; - name = Debug; - }; - 82BA531029DEE35D00F7726A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 28TM58T3GZ; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_STRICT_CONCURRENCY = complete; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 82BA531229DEE35D00F7726A /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"EndpointsTestbed/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.EndpointsTestbed; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 82BA531329DEE35D00F7726A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"EndpointsTestbed/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.EndpointsTestbed; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 82BA531529DEE35D00F7726A /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.EndpointsTestbedTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EndpointsTestbed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EndpointsTestbed"; - }; - name = Debug; - }; - 82BA531629DEE35D00F7726A /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.EndpointsTestbedTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/EndpointsTestbed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/EndpointsTestbed"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 82BA52E829DEE35C00F7726A /* Build configuration list for PBXProject "EndpointsTestbed" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 82BA530F29DEE35D00F7726A /* Debug */, - 82BA531029DEE35D00F7726A /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 82BA531129DEE35D00F7726A /* Build configuration list for PBXNativeTarget "EndpointsTestbed" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 82BA531229DEE35D00F7726A /* Debug */, - 82BA531329DEE35D00F7726A /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 82BA531429DEE35D00F7726A /* Build configuration list for PBXNativeTarget "EndpointsTestbedTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 82BA531529DEE35D00F7726A /* Debug */, - 82BA531629DEE35D00F7726A /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 8211A84B2AFBDBFD00A36244 /* XCRemoteSwiftPackageReference "AsyncReactor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/diamirio/AsyncReactor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; - FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/diamirio/Injection"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 8211A84C2AFBDBFD00A36244 /* AsyncReactor */ = { - isa = XCSwiftPackageProductDependency; - package = 8211A84B2AFBDBFD00A36244 /* XCRemoteSwiftPackageReference "AsyncReactor" */; - productName = AsyncReactor; - }; - 82BA531D29DEE73D00F7726A /* Endpoints */ = { - isa = XCSwiftPackageProductDependency; - productName = Endpoints; - }; - FAD06B6A2E8C1C8C00E87155 /* Injection */ = { - isa = XCSwiftPackageProductDependency; - package = FAD06B692E8C1C8C00E87155 /* XCRemoteSwiftPackageReference "Injection" */; - productName = Injection; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 82BA52E529DEE35C00F7726A /* Project object */; -} diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/EndpointsTestbed/EndpointsTestbed.xcodeproj/xcshareddata/xcschemes/EndpointsTestbed.xcscheme b/EndpointsTestbed/EndpointsTestbed.xcodeproj/xcshareddata/xcschemes/EndpointsTestbed.xcscheme deleted file mode 100644 index 95a0aef..0000000 --- a/EndpointsTestbed/EndpointsTestbed.xcodeproj/xcshareddata/xcschemes/EndpointsTestbed.xcscheme +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AccentColor.colorset/Contents.json b/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AppIcon.appiconset/Contents.json b/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/Contents.json b/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/DI.swift b/EndpointsTestbed/EndpointsTestbed/DI.swift deleted file mode 100644 index 782ccde..0000000 --- a/EndpointsTestbed/EndpointsTestbed/DI.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Endpoints -import Foundation -import Injection - -@MainActor -enum DI { - static func register() { - let postmanSession = Session(with: PostmanEchoClient()) - DependencyInjector.register(postmanSession) - - let httpBinSession = Session(with: HTTPBinClient()) - DependencyInjector.register(httpBinSession) - - let manipulatedHttpBinSession = Session(with: ManipulatedHTTPBinClient()) - DependencyInjector.register(manipulatedHttpBinSession) - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift b/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift deleted file mode 100644 index 310c9a5..0000000 --- a/EndpointsTestbed/EndpointsTestbed/EndpointsTestbedApp.swift +++ /dev/null @@ -1,24 +0,0 @@ -import AsyncReactor -import SwiftUI - -@main -struct EndpointsTestbedApp: App { - init() { - DI.register() - } - - var body: some Scene { - WindowGroup { - NavigationStack { - List { - Section { - NavigationLink("MVVM", destination: ExampleView()) - NavigationLink("AsyncReactor", destination: ReactorView(ExampleReactor()) { - ExampleReactorView() - }) - } - } - } - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift b/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift deleted file mode 100644 index 54307bc..0000000 --- a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactor.swift +++ /dev/null @@ -1,41 +0,0 @@ -import AsyncReactor -import Endpoints -import Foundation -import Injection - -class ExampleReactor: AsyncReactor { - enum Action { - case executeRequests - } - - struct State { - var text = "" - } - - @Published - private(set) var state = State() - - @Inject - var postmanSession: Session - - func action(_ action: Action) async { - switch action { - case .executeRequests: - await executeRequest() - } - } - - private func executeRequest() async { - do { - let (body, response) = try await postmanSession.dataTask( - for: PostmanEchoClient.ExampleGetCall() - ) - - guard response.statusCode == 200 else { return } - state.text = body.url - } catch { - guard let error = error as? EndpointsError else { return } - print(error.response?.statusCode ?? "") - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactorView.swift b/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactorView.swift deleted file mode 100644 index 79be285..0000000 --- a/EndpointsTestbed/EndpointsTestbed/ExampleAsyncReactor/ExampleReactorView.swift +++ /dev/null @@ -1,26 +0,0 @@ -import AsyncReactor -import SwiftUI - -struct ExampleReactorView: View { - @EnvironmentObject var reactor: ExampleReactor - - var body: some View { - VStack { - if reactor.state.text.isEmpty { - ProgressView() - } else { - Text(reactor.state.text) - .font(.headline) - } - } - .onAppear { - reactor.send(.executeRequests) - } - } -} - -#Preview { - ReactorView(ExampleReactor()) { - ExampleReactorView() - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleModel.swift b/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleModel.swift deleted file mode 100644 index 701fa8c..0000000 --- a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleModel.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -struct ExampleModel: Codable { - var url: String -} diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleView.swift b/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleView.swift deleted file mode 100644 index 80186fc..0000000 --- a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleView.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct ExampleView: View { - @StateObject var viewModel = ExampleViewModel() - - var body: some View { - VStack { - if viewModel.text.isEmpty { - ProgressView() - } else { - Text(viewModel.text) - .font(.headline) - } - } - .onAppear { - viewModel.executeRequests() - } - } -} - -struct ExampleView_Previews: PreviewProvider { - static var previews: some View { - ExampleView() - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift b/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift deleted file mode 100644 index b0d50e4..0000000 --- a/EndpointsTestbed/EndpointsTestbed/ExampleMVVM/ExampleViewModel.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Endpoints -import Foundation -import Injection - -@MainActor -class ExampleViewModel: ObservableObject { - @Published - var text: String = "" - - @Inject - var postmanSession: Session - - @Inject - var manipulatedHttpBinSession: Session - - func executeRequests() { - Task { - let (body, response) = try await postmanSession.dataTask( - for: PostmanEchoClient.ExampleGetCall() - ) - guard response.statusCode == 200 else { return } - - await MainActor.run { - self.text = body.url - } - } - - Task { - let (_, response) = try await manipulatedHttpBinSession.dataTask( - for: ManipulatedHTTPBinClient.GetStatusCode(deliveredStatusCode: 220) - ) - guard response.statusCode == 200 else { return } - print("Success") - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/API.swift b/EndpointsTestbed/EndpointsTestbed/Networking/API.swift deleted file mode 100644 index bdd8c75..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Networking/API.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// API.swift -// EndpointsTestbed -// -// Created by Alexander Kauer on 11.09.25. -// - -import Endpoints -import Foundation - -actor API { - var postmanSession: Session - var httpBinSession: Session - var manipulatedHttpBinSession: Session - - init() { - let postmanClient = PostmanEchoClient() - self.postmanSession = Session(with: postmanClient) - - let httpBinClient = HTTPBinClient() - self.httpBinSession = Session(with: httpBinClient) - - let manipulatedHttpBinClient = ManipulatedHTTPBinClient() - self.manipulatedHttpBinSession = Session(with: manipulatedHttpBinClient) - } - - func loadData() async throws -> (ExampleModel, HTTPURLResponse) { - try await postmanSession.dataTask( - for: PostmanEchoClient.ExampleGetCall() - ) - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift deleted file mode 100644 index 51b492f..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Networking/HTTPBinClient.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Endpoints -import Foundation - -struct HTTPBinClient: Client { - var client: Client - - init() { - let url = URL(string: "https://httpbin.org/")! - self.client = AnyClient(baseURL: url) - } - - struct GetStatusCode: Call { - let deliveredStatusCode: Int - - typealias Parser = JSONParser - - var request: URLRequestEncodable { - Request(.get, "/status/\(deliveredStatusCode)") - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift deleted file mode 100644 index 273bd5a..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Networking/ManipulatedHTTPBinClient.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Endpoints -import Foundation - -struct ManipulatedHTTPBinClient: Client { - var client: Client - - init() { - let url = URL(string: "https://httpbin.org/")! - self.client = AnyClient(baseURL: url) - } - - func encode(call: some Endpoints.Call) async throws -> URLRequest { - // Custom manipulation i.e. OAuth implementation - print("- MANIPULATED encode -") - return try await client.encode(call: call) - } - - func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType - where C: Call { - // Custom manipulation i.e. react on error responses or invalid tokens - print("- MANIPULATED parse -") - return try await client.parse(response: response, data: data, for: call) - } - - func validate(response: HTTPURLResponse?, data: Data?) async throws { - // Custom validation if needed - print("- MANIPULATED validate -") - return try await client.validate(response: response, data: data) - } - - struct GetStatusCode: Call { - typealias Parser = JSONParser - - let deliveredStatusCode: Int - - var request: URLRequestEncodable { - Request(.get, "/status/\(deliveredStatusCode)") - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift b/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift deleted file mode 100644 index d05bf2f..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Networking/PostmanEchoClient.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Endpoints -import Foundation - -struct PostmanEchoClient: Client { - let client: Client - - init() { - let baseURL = URL(string: "https://postman-echo.com")! - self.client = AnyClient(baseURL: baseURL) - } - - struct ExampleGetCall: Call { - typealias Parser = JSONParser - - var request: URLRequestEncodable { - Request(.get, "/get") - } - } -} diff --git a/EndpointsTestbed/EndpointsTestbed/Preview Content/Preview Assets.xcassets/Contents.json b/EndpointsTestbed/EndpointsTestbed/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/EndpointsTestbed/EndpointsTestbed/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EndpointsTestbed/EndpointsTestbedTests/EndpointsTestbedTests.swift b/EndpointsTestbed/EndpointsTestbedTests/EndpointsTestbedTests.swift deleted file mode 100644 index 6fd10fd..0000000 --- a/EndpointsTestbed/EndpointsTestbedTests/EndpointsTestbedTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// EndpointsTestbedTests.swift -// EndpointsTestbedTests -// -// Created by Alexander Kauer on 06.04.23. -// - -@testable import EndpointsTestbed -import XCTest - -final class EndpointsTestbedTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions - // afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } -} diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 0145b00..0000000 --- a/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "1859cf73baf45095b1a4fbf166f3ec4d682a8c15e5aacd17d83bb369880481b3", - "pins" : [ - { - "identity" : "asyncreactor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/diamirio/AsyncReactor", - "state" : { - "revision" : "e3fde7f9c1fb4db349039796c5c138571c71dadd", - "version" : "1.2.1" - } - }, - { - "identity" : "injection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/diamirio/Injection", - "state" : { - "revision" : "57a66d3f21ff5564542a6b157838ae97b67447f0", - "version" : "1.1.0" - } - } - ], - "version" : 3 -} diff --git a/README.md b/README.md index bd08760..84d401b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,32 @@ Endpoints makes it easy to write a type-safe network abstraction layer for any W It requires Swift 6.2+, makes heavy use of generics and protocols (with protocol extensions). It also encourages a clean separation of concerns and the use of value types (i.e. structs). Built for modern Swift concurrency with async/await and actor support. +**Key Features:** +- **Type-safe API**: Strongly typed requests and responses +- **Swift 6.2+**: Full support for Swift's strict concurrency model +- **Actor-based Session**: Thread-safe networking with `Session` as an actor +- **Sendable conformance**: All core protocols require `Sendable` conformance for safe concurrent access +- **Async/await**: Native async/await support throughout the API +- **Flexible parsing**: Multiple built-in response parsers with support for custom parsers +- **JSON Codable**: First-class support for `Codable` types + +## Requirements + +* Swift 6.2+ +* iOS 13+ +* tvOS 12+ +* macOS 10.15+ +* watchOS 6+ +* visionOS 1+ + +## Installation + +**Swift Package Manager:** + +```swift +.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "3.0.0")) +``` + ## Usage ### Basics @@ -24,7 +50,8 @@ let client = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!) // A call encapsulates the request that is sent to the server and the type that is expected in the response. let call = AnyCall(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"])) -// A session wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error). +// A session is an actor that wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error). +// Session is an actor, ensuring thread-safe access to URLSession. let session = Session(with: client) // start call @@ -84,7 +111,8 @@ struct GiphyCall: Call { } // Custom parser with configured decoder -struct CustomJSONParser: ResponseParser { +// Note: T must be Sendable for Swift 6.2+ concurrency safety +struct CustomJSONParser: ResponseParser { typealias OutputType = T let jsonDecoder: JSONDecoder @@ -113,14 +141,16 @@ Every encodable is able to provide a `JSONEncoder()` to encode itself via the `t ### Dedicated Calls -`AnyCall` is the default implementation of the `Call` protocol, which you can use as-is. But if you want to make your networking layer really type-safe you'll want to create a dedicated `Call` type for each operation of your Web-API: +`AnyCall` is the default implementation of the `Call` protocol, which you can use as-is. But if you want to make your networking layer really type-safe you'll want to create a dedicated `Call` type for each operation of your Web-API. + +**Note:** All `Call` types must conform to `Sendable` for Swift 6.2+ concurrency safety. Use value types (structs) with sendable properties: ```swift struct GetRandomImage: Call { typealias Parser = DictionaryParser - + var tag: String - + var request: URLRequestEncodable { return Request(.get, "gifs/random", query: [ "tag": tag, "api_key": "dc6zaTOxFJmzC" ]) } @@ -136,7 +166,9 @@ A client is responsible for handling things that are common for all operations o `AnyClient` is the default implementation of the `Client` protocol and can be used as-is or as a starting point for your own dedicated client. -You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to an `AnyClient` instance, as done here: +You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to an `AnyClient` instance, as done here. + +**Note:** All `Client` types must conform to `Sendable`. Use structs with sendable properties to ensure thread-safety: ```swift struct GiphyClient: Client { @@ -219,23 +251,6 @@ let (body, response) = try await session.dataTask(for: call) print("image url: \(body.data.url)") ``` -## Installation - -**Swift Package Manager:** - -```swift -.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "3.0.0")) -``` - ## Example -Example implementation can be found [here](./EndpointsTestbed). - -## Requirements - -* Swift 6.2+ -* iOS 13+ -* tvOS 12+ -* macOS 10.15+ -* watchOS 6+ -* visionOS 1+ +Example implementation can be found [here](https://github.com/diamirio/Endpoints-Example). From 61c7b4475da8cc14f51408b20385ad90076471cf Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 30 Oct 2025 07:55:47 +0100 Subject: [PATCH 07/18] Removed testing utils from main target --- Sources/Testing/FakeResultProvider.swift | 5 -- Sources/Testing/FakeSession.swift | 61 ------------------- .../Utils}/FakeHTTPURLResponse.swift | 2 + 3 files changed, 2 insertions(+), 66 deletions(-) delete mode 100644 Sources/Testing/FakeResultProvider.swift delete mode 100644 Sources/Testing/FakeSession.swift rename {Sources/Testing => Tests/Utils}/FakeHTTPURLResponse.swift (92%) diff --git a/Sources/Testing/FakeResultProvider.swift b/Sources/Testing/FakeResultProvider.swift deleted file mode 100644 index 94c00f4..0000000 --- a/Sources/Testing/FakeResultProvider.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public protocol FakeResultProvider: Sendable { - func data(for call: some Call) async throws -> (URLResponse, Data) -} diff --git a/Sources/Testing/FakeSession.swift b/Sources/Testing/FakeSession.swift deleted file mode 100644 index a50ee19..0000000 --- a/Sources/Testing/FakeSession.swift +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright © 2023 DIAMIR. All Rights Reserved. - -import Foundation -#if canImport(OSLog) - import OSLog -#endif -// -// public final class FakeSession: Session { -// public let client: CL -// public let debug: Bool = false -// public let resultProvider: FakeResultProvider -// -// public init(with client: CL, resultProvider: FakeResultProvider) { -// self.client = client -// self.resultProvider = resultProvider -// } -// -// public func dataTask( -// for call: C -// ) async throws -> (C.Parser.OutputType, HTTPURLResponse) { -// let (response, data) = try await resultProvider.data(for: call) -// -// guard let response = response as? HTTPURLResponse else { -// if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { -// Logger.default.debug("no response.") -// } else { -// os_log("no response.", log: .default, type: .debug) -// } -// -// throw EndpointsError( -// error: EndpointsParsingError.invalidData( -// description: "Response was not a valid HTTPURLResponse" -// ), -// response: nil -// ) -// } -// -// if debug { -// var message = "" -// message += "\(call.request.cURLRepresentation)\n" -// message += "\(response.debugDescription)\n" -// message += "\(data.debugDescription(encoding: response.stringEncoding))" -// -// if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, visionOS 1.0, *) { -// Logger.default.debug("\(message, privacy: .private)") -// } else { -// os_log("%s", log: .default, type: .debug, message) -// } -// } -// -// do { -// try await call.validate(response: response, data: data) // request-specific validation -// try await client.validate(response: response, data: data) // global validation -// -// let value = try await client.parse(response: response, data: data, for: call) -// return (value, response) -// } catch { -// throw EndpointsError(error: error, response: response) -// } -// } -// } diff --git a/Sources/Testing/FakeHTTPURLResponse.swift b/Tests/Utils/FakeHTTPURLResponse.swift similarity index 92% rename from Sources/Testing/FakeHTTPURLResponse.swift rename to Tests/Utils/FakeHTTPURLResponse.swift index 3fd2c42..c2d1d09 100644 --- a/Sources/Testing/FakeHTTPURLResponse.swift +++ b/Tests/Utils/FakeHTTPURLResponse.swift @@ -2,6 +2,8 @@ import Foundation +public typealias Parameters = [String: String] + public class FakeHTTPURLResponse: HTTPURLResponse, @unchecked Sendable { public init( status code: Int = 200, From 221eae9356e9d5d29f4f3fb519658f2be893982a Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 30 Oct 2025 09:12:33 +0100 Subject: [PATCH 08/18] Changed Client protocol for easier usage --- Sources/Async/AnyClient.swift | 3 -- Sources/Async/Client.swift | 54 +++++++++++------------ Tests/PostmanEcho/PostmanEchoClient.swift | 9 +--- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/Sources/Async/AnyClient.swift b/Sources/Async/AnyClient.swift index 9a72722..12f01c3 100644 --- a/Sources/Async/AnyClient.swift +++ b/Sources/Async/AnyClient.swift @@ -3,9 +3,6 @@ import Foundation public struct AnyClient: Client { - /// To satisfy conformance only - public var client: Client { self } - /// The base URL used by `encode` to convert `Call`s into `URLRequest`s. public let baseURL: URL diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index da7cd6b..78fdade 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -5,7 +5,7 @@ import Foundation /// A type responsible for encoding and parsing all calls for a given Web API. /// A basic implementation is provided by `AnyClient`. public protocol Client: ResponseValidator, Sendable { - var client: Client { get } +// var client: Client { get } /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. @@ -22,29 +22,29 @@ public protocol Client: ResponseValidator, Sendable { ) async throws -> C.Parser.OutputType } -public extension Client { - /// Converts a `Call` created for this client's Web API - /// into a `URLRequest`. - func encode(call: some Call) async throws -> URLRequest { - try await client.encode(call: call) - } - - /// Converts the `URLSession`s result for a `Call` to - /// this client's Web API into the expected output type. - /// - /// - throws: Any `Error` if `result` is considered invalid. - func parse( - response: HTTPURLResponse?, - data: Data?, - for call: C - ) async throws -> C.Parser.OutputType { - try await client.parse(response: response, data: data, for: call) - } - - func validate( - response: HTTPURLResponse?, - data: Data? - ) async throws { - try await client.validate(response: response, data: data) - } -} +// public extension Client { +// /// Converts a `Call` created for this client's Web API +// /// into a `URLRequest`. +// func encode(call: some Call) async throws -> URLRequest { +// try await encode(call: call) +// } +// +// /// Converts the `URLSession`s result for a `Call` to +// /// this client's Web API into the expected output type. +// /// +// /// - throws: Any `Error` if `result` is considered invalid. +// func parse( +// response: HTTPURLResponse?, +// data: Data?, +// for call: C +// ) async throws -> C.Parser.OutputType { +// try await client.parse(response: response, data: data, for: call) +// } +// +// func validate( +// response: HTTPURLResponse?, +// data: Data? +// ) async throws { +// try await client.validate(response: response, data: data) +// } +// } diff --git a/Tests/PostmanEcho/PostmanEchoClient.swift b/Tests/PostmanEcho/PostmanEchoClient.swift index 34f0cc3..045e4d0 100644 --- a/Tests/PostmanEcho/PostmanEchoClient.swift +++ b/Tests/PostmanEcho/PostmanEchoClient.swift @@ -3,13 +3,8 @@ @testable import Endpoints import Foundation -public struct PostmanEchoClient: Client { - public var client: Client - - public init() { - let url = URL(string: "https://postman-echo.com")! - self.client = AnyClient(baseURL: url) - } +public struct PostmanEchoClient { + public init() {} struct MyCall: Call { typealias Parser = JSONParser From 121d240d8369fc9f7a414b042af88f9c9ec6c3db Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 30 Oct 2025 11:52:17 +0100 Subject: [PATCH 09/18] Removed commented client protocol extension --- Sources/Async/Client.swift | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index 78fdade..d86a585 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -5,7 +5,6 @@ import Foundation /// A type responsible for encoding and parsing all calls for a given Web API. /// A basic implementation is provided by `AnyClient`. public protocol Client: ResponseValidator, Sendable { -// var client: Client { get } /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. @@ -21,30 +20,3 @@ public protocol Client: ResponseValidator, Sendable { for call: C ) async throws -> C.Parser.OutputType } - -// public extension Client { -// /// Converts a `Call` created for this client's Web API -// /// into a `URLRequest`. -// func encode(call: some Call) async throws -> URLRequest { -// try await encode(call: call) -// } -// -// /// Converts the `URLSession`s result for a `Call` to -// /// this client's Web API into the expected output type. -// /// -// /// - throws: Any `Error` if `result` is considered invalid. -// func parse( -// response: HTTPURLResponse?, -// data: Data?, -// for call: C -// ) async throws -> C.Parser.OutputType { -// try await client.parse(response: response, data: data, for: call) -// } -// -// func validate( -// response: HTTPURLResponse?, -// data: Data? -// ) async throws { -// try await client.validate(response: response, data: data) -// } -// } From faa0efcd18f99ed5ffeccf1ab84fb3bab11609c0 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 30 Oct 2025 11:54:23 +0100 Subject: [PATCH 10/18] Formatting fix --- Sources/Async/Client.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index d86a585..1a7cf86 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -5,7 +5,6 @@ import Foundation /// A type responsible for encoding and parsing all calls for a given Web API. /// A basic implementation is provided by `AnyClient`. public protocol Client: ResponseValidator, Sendable { - /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. func encode(call: some Call) async throws -> URLRequest From c7b65b4350f42807a19f04995c9ba40f058e024b Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Fri, 31 Oct 2025 10:39:17 +0100 Subject: [PATCH 11/18] Renamed `AnyClient` to `DefaultClient` --- Sources/Async/{AnyClient.swift => DefaultClient.swift} | 2 +- Tests/ClientTests.swift | 4 ++-- Tests/Codable/Endpoints+JSONCodableTests.swift | 4 ++-- Tests/MultipartTests.swift | 2 +- Tests/RequestTests.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) rename Sources/Async/{AnyClient.swift => DefaultClient.swift} (97%) diff --git a/Sources/Async/AnyClient.swift b/Sources/Async/DefaultClient.swift similarity index 97% rename from Sources/Async/AnyClient.swift rename to Sources/Async/DefaultClient.swift index 12f01c3..90996d3 100644 --- a/Sources/Async/AnyClient.swift +++ b/Sources/Async/DefaultClient.swift @@ -2,7 +2,7 @@ import Foundation -public struct AnyClient: Client { +public struct DefaultClient: Client { /// The base URL used by `encode` to convert `Call`s into `URLRequest`s. public let baseURL: URL diff --git a/Tests/ClientTests.swift b/Tests/ClientTests.swift index d63fb46..002efc8 100644 --- a/Tests/ClientTests.swift +++ b/Tests/ClientTests.swift @@ -4,11 +4,11 @@ import Testing @Suite("Client Tests") struct ClientTests { - let tester: ClientTester + let tester: ClientTester init() { let baseURL = URL(string: "https://nghttp2.org/httpbin/")! - self.tester = ClientTester(client: AnyClient(baseURL: baseURL)) + self.tester = ClientTester(client: DefaultClient(baseURL: baseURL)) } @Test func statusError() async throws { diff --git a/Tests/Codable/Endpoints+JSONCodableTests.swift b/Tests/Codable/Endpoints+JSONCodableTests.swift index a670659..6251a3f 100644 --- a/Tests/Codable/Endpoints+JSONCodableTests.swift +++ b/Tests/Codable/Endpoints+JSONCodableTests.swift @@ -13,7 +13,7 @@ class EndpointsJSONCodableTests: XCTestCase { // this test case is relevant, as there is a difference between using [City].parse // and parse on the same type, but with the heavy generics use of Endpoints func testDecodingArrayViaResponse() async throws { - let client = AnyClient(baseURL: URL(string: "www.tailored-apps.com")!) + let client = DefaultClient(baseURL: URL(string: "www.tailored-apps.com")!) let call = CitiesCall() let cities = try await client.parse( @@ -44,7 +44,7 @@ class EndpointsJSONCodableTests: XCTestCase { } func testUsingCustomDecoderAndAnyClient() async throws { - let client = AnyClient(baseURL: URL(string: "www.tailored-apps.com")!) + let client = DefaultClient(baseURL: URL(string: "www.tailored-apps.com")!) let call = PersonCall() do { diff --git a/Tests/MultipartTests.swift b/Tests/MultipartTests.swift index c5c92cd..400a599 100644 --- a/Tests/MultipartTests.swift +++ b/Tests/MultipartTests.swift @@ -91,7 +91,7 @@ class MultipartTests: XCTestCase { let multipartBody: MultipartBody } - let client = AnyClient(baseURL: URL(string: "https://httpbin.org")!) + let client = DefaultClient(baseURL: URL(string: "https://httpbin.org")!) let session = Session(with: client) let call = PostCall(multipartBody: multipartBody) diff --git a/Tests/RequestTests.swift b/Tests/RequestTests.swift index 5c9a867..5717c00 100644 --- a/Tests/RequestTests.swift +++ b/Tests/RequestTests.swift @@ -40,7 +40,7 @@ class RequestTests: XCTestCase { req.url = absoluteURL let c = AnyCall(req) - let urlReq = try await AnyClient(baseURL: URL(string: "http://google.com")!).encode(call: c) + let urlReq = try await DefaultClient(baseURL: URL(string: "http://google.com")!).encode(call: c) XCTAssertEqual(urlReq.url, absoluteURL) XCTAssertEqual(urlReq.httpBody, body.requestData) @@ -101,7 +101,7 @@ extension RequestTests { ) async throws -> URLRequest { let request = Request(.get, path, query: queryParams) let call = AnyCall(request) - let client = AnyClient(baseURL: URL(string: baseUrl)!) + let client = DefaultClient(baseURL: URL(string: baseUrl)!) let urlRequest = try await client.encode(call: call) let (data, response) = try await URLSession.shared.data(for: urlRequest) From 82d7773d760288135e6cb82d91e19e85e905bdc0 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Tue, 4 Nov 2025 11:25:22 +0100 Subject: [PATCH 12/18] Renamed `baseURL` to `url`, fixed debug option --- Sources/Async/DefaultClient.swift | 10 +++++----- Sources/Async/Session.swift | 8 +++++--- Sources/Parsing/ResponseParser/JSONParser.swift | 2 +- Tests/ClientTests.swift | 6 +++--- Tests/Codable/Endpoints+JSONCodableTests.swift | 4 ++-- Tests/MultipartTests.swift | 2 +- Tests/RequestTests.swift | 4 ++-- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Sources/Async/DefaultClient.swift b/Sources/Async/DefaultClient.swift index 90996d3..6c5bc42 100644 --- a/Sources/Async/DefaultClient.swift +++ b/Sources/Async/DefaultClient.swift @@ -4,14 +4,14 @@ import Foundation public struct DefaultClient: Client { /// The base URL used by `encode` to convert `Call`s into `URLRequest`s. - public let baseURL: URL + public let url: URL /// Used by `validate` to check if the status code of a response is valid. public let statusCodeValidator = StatusCodeValidator() /// Creates a client with a base URL. - public init(baseURL: URL) { - self.baseURL = baseURL + public init(url: URL) { + self.url = url } public func encode( @@ -19,8 +19,8 @@ public struct DefaultClient: Client { ) async throws -> URLRequest { var urlRequest = call.request.urlRequest - if let url = urlRequest.url, url.isRelative { - urlRequest.url = URL(string: url.relativeString, relativeTo: baseURL) + if let requestUrl = urlRequest.url, requestUrl.isRelative { + urlRequest.url = URL(string: requestUrl.relativeString, relativeTo: url) } return urlRequest diff --git a/Sources/Async/Session.swift b/Sources/Async/Session.swift index 2ae8a0a..5ea7eec 100644 --- a/Sources/Async/Session.swift +++ b/Sources/Async/Session.swift @@ -6,9 +6,9 @@ import Foundation #endif public actor Session { - public let debug = false - public let urlSession: URLSession public let client: CL + public let urlSession: URLSession + public let debug: Bool public init( with client: CL, @@ -16,10 +16,12 @@ public actor Session { configuration: .default, delegate: URLSessionDelegateHandler(), delegateQueue: nil - ) + ), + debug: Bool = false ) { self.client = client self.urlSession = urlSession + self.debug = debug } @discardableResult diff --git a/Sources/Parsing/ResponseParser/JSONParser.swift b/Sources/Parsing/ResponseParser/JSONParser.swift index d306fc7..44f995f 100644 --- a/Sources/Parsing/ResponseParser/JSONParser.swift +++ b/Sources/Parsing/ResponseParser/JSONParser.swift @@ -7,7 +7,7 @@ import Foundation public struct JSONParser: ResponseParser { public typealias OutputType = T - public var jsonDecoder: JSONDecoder + public let jsonDecoder: JSONDecoder public init() { self.jsonDecoder = JSONDecoder() diff --git a/Tests/ClientTests.swift b/Tests/ClientTests.swift index 002efc8..20a1061 100644 --- a/Tests/ClientTests.swift +++ b/Tests/ClientTests.swift @@ -8,7 +8,7 @@ struct ClientTests { init() { let baseURL = URL(string: "https://nghttp2.org/httpbin/")! - self.tester = ClientTester(client: DefaultClient(baseURL: baseURL)) + self.tester = ClientTester(client: DefaultClient(url: baseURL)) } @Test func statusError() async throws { @@ -220,7 +220,7 @@ struct ClientTests { let url = try #require(URL(string: "get?q=a")) let c = AnyCall(URLRequest(url: url)) let (_, response) = try await tester.performTest(call: c) - let expectedUrl = await URL(string: url.relativeString, relativeTo: tester.session.client.baseURL)?.absoluteURL + let expectedUrl = await URL(string: url.relativeString, relativeTo: tester.session.client.url)?.absoluteURL #expect(response.url == expectedUrl) } @@ -228,7 +228,7 @@ struct ClientTests { let req = Request(.get, "relative-redirect/2", header: ["x": "y"]) let c = AnyCall(req) let (_, response) = try await tester.performTest(call: c) - let expectedUrl = await URL(string: "get", relativeTo: tester.session.client.baseURL)?.absoluteURL + let expectedUrl = await URL(string: "get", relativeTo: tester.session.client.url)?.absoluteURL #expect(response.url == expectedUrl) } diff --git a/Tests/Codable/Endpoints+JSONCodableTests.swift b/Tests/Codable/Endpoints+JSONCodableTests.swift index 6251a3f..b5c9693 100644 --- a/Tests/Codable/Endpoints+JSONCodableTests.swift +++ b/Tests/Codable/Endpoints+JSONCodableTests.swift @@ -13,7 +13,7 @@ class EndpointsJSONCodableTests: XCTestCase { // this test case is relevant, as there is a difference between using [City].parse // and parse on the same type, but with the heavy generics use of Endpoints func testDecodingArrayViaResponse() async throws { - let client = DefaultClient(baseURL: URL(string: "www.tailored-apps.com")!) + let client = DefaultClient(url: URL(string: "www.tailored-apps.com")!) let call = CitiesCall() let cities = try await client.parse( @@ -44,7 +44,7 @@ class EndpointsJSONCodableTests: XCTestCase { } func testUsingCustomDecoderAndAnyClient() async throws { - let client = DefaultClient(baseURL: URL(string: "www.tailored-apps.com")!) + let client = DefaultClient(url: URL(string: "www.tailored-apps.com")!) let call = PersonCall() do { diff --git a/Tests/MultipartTests.swift b/Tests/MultipartTests.swift index 400a599..af18b9a 100644 --- a/Tests/MultipartTests.swift +++ b/Tests/MultipartTests.swift @@ -91,7 +91,7 @@ class MultipartTests: XCTestCase { let multipartBody: MultipartBody } - let client = DefaultClient(baseURL: URL(string: "https://httpbin.org")!) + let client = DefaultClient(url: URL(string: "https://httpbin.org")!) let session = Session(with: client) let call = PostCall(multipartBody: multipartBody) diff --git a/Tests/RequestTests.swift b/Tests/RequestTests.swift index 5717c00..7dd182b 100644 --- a/Tests/RequestTests.swift +++ b/Tests/RequestTests.swift @@ -40,7 +40,7 @@ class RequestTests: XCTestCase { req.url = absoluteURL let c = AnyCall(req) - let urlReq = try await DefaultClient(baseURL: URL(string: "http://google.com")!).encode(call: c) + let urlReq = try await DefaultClient(url: URL(string: "http://google.com")!).encode(call: c) XCTAssertEqual(urlReq.url, absoluteURL) XCTAssertEqual(urlReq.httpBody, body.requestData) @@ -101,7 +101,7 @@ extension RequestTests { ) async throws -> URLRequest { let request = Request(.get, path, query: queryParams) let call = AnyCall(request) - let client = DefaultClient(baseURL: URL(string: baseUrl)!) + let client = DefaultClient(url: URL(string: baseUrl)!) let urlRequest = try await client.encode(call: call) let (data, response) = try await URLSession.shared.data(for: urlRequest) From 9809e085a8f16c6c6bdc194df3f40f91889f099d Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 6 Nov 2025 15:14:31 +0100 Subject: [PATCH 13/18] Default Options for JSONParser --- Sources/Parsing/ResponseParser/JSONParser.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Parsing/ResponseParser/JSONParser.swift b/Sources/Parsing/ResponseParser/JSONParser.swift index 44f995f..19449c9 100644 --- a/Sources/Parsing/ResponseParser/JSONParser.swift +++ b/Sources/Parsing/ResponseParser/JSONParser.swift @@ -11,6 +11,8 @@ public struct JSONParser: ResponseParser { public init() { self.jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase } public func parse(data: Data, encoding _: String.Encoding) throws -> OutputType { From 8eb0a0726dd89e5c617ec200a3bdb2fe7cf85c6d Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Tue, 25 Nov 2025 14:19:19 +0100 Subject: [PATCH 14/18] Fixed README and documentation --- Migration/V4_0_0.md | 250 ++++++++++++++++++ README.md | 191 +++++++++++-- Sources/Async/Client.swift | 2 +- Sources/Core/Call.swift | 2 +- Sources/Core/Request.swift | 4 +- Sources/Error/EndpointsParsingError.swift | 2 +- .../Codable/Endpoints+JSONCodableTests.swift | 2 +- 7 files changed, 422 insertions(+), 31 deletions(-) create mode 100644 Migration/V4_0_0.md diff --git a/Migration/V4_0_0.md b/Migration/V4_0_0.md new file mode 100644 index 0000000..0c7afe7 --- /dev/null +++ b/Migration/V4_0_0.md @@ -0,0 +1,250 @@ +# v4.0.0 - Changelog & Migration Guide + +Endpoints 4.0.0 brings full Swift 6.2+ support with strict concurrency compliance, requiring `Sendable` conformance throughout the library and introducing the `Session` actor for thread-safe networking. + +## Changelog + +### Developer Facing + +- **Swift 6.2+ Strict Concurrency** + - All core protocols now require `Sendable` conformance + - `Session` is now an actor, ensuring thread-safe access to URLSession + - All `Call`, `Client`, and `ResponseParser` types must be `Sendable` + - `ResponseParser.OutputType` must be `Sendable` for safe concurrent access + +- **AnyClient Renamed to DefaultClient** + - The `AnyClient` class has been renamed to `DefaultClient` and changed from a class to a struct + - `DefaultClient` is now a value type (struct) conforming to `Sendable` + - The `open class` pattern for subclassing is no longer supported + +- **Client Protocol Changes** + - Client protocol now requires `Sendable` conformance + - Removed default protocol extensions that previously allowed delegation to an internal `client` property + - Custom clients must now implement all methods directly (typically by delegating to a `DefaultClient` instance) + - Changed from `func encode(call: C)` to `func encode(call: some Call)` for improved ergonomics + +- **Parameter Naming Updates** + - `DefaultClient` initializer: `baseURL` parameter renamed to `url` + - Example: `DefaultClient(url: myURL)` instead of `DefaultClient(baseURL: myURL)` + +- **JSONParser Improvements** + - Default `JSONParser` now includes standard configuration: + - `dateDecodingStrategy = .iso8601` + - `keyDecodingStrategy = .convertFromSnakeCase` + - Custom decoder configuration can still be achieved by creating a custom parser + +- **Session Changes** + - `Session` is now an actor for thread-safe networking + - All `Session` methods must be called with `await` from outside the actor context + - `debug` parameter added to Session initializer for request/response logging + +### Internal + +- Updated minimum Swift version requirement to 6.2+ +- Updated CI workflows to use Xcode 26.0 and macOS 15 +- Applied SwiftFormat across the codebase +- Moved example project to separate repository (Endpoints-Example) + +## Migration Guide + +### AnyClient → DefaultClient + +**Before (3.x):** +```swift +let client = AnyClient(baseURL: URL(string: "https://api.example.com")!) +``` + +**After (4.x):** +```swift +let client = DefaultClient(url: URL(string: "https://api.example.com")!) +``` + +### Custom Client Implementation + +In 3.x, you could subclass `AnyClient` using the `open class` pattern. In 4.x, you must use composition with a struct. + +**Before (3.x):** +```swift +class MyAPIClient: AnyClient { + var apiKey = "secret" + + override func encode(call: C) async throws -> URLRequest { + var request = try await super.encode(call: call) + request.addValue(apiKey, forHTTPHeaderField: "API-Key") + return request + } +} +``` + +**After (4.x):** +```swift +struct MyAPIClient: Client { + private let client: Client + let apiKey = "secret" + + init() { + let url = URL(string: "https://api.example.com")! + self.client = DefaultClient(url: url) + } + + func encode(call: some Call) async throws -> URLRequest { + var request = try await client.encode(call: call) + request.addValue(apiKey, forHTTPHeaderField: "API-Key") + return request + } + + func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType + where C: Call { + try await client.parse(response: response, data: data, for: call) + } + + func validate(response: HTTPURLResponse?, data: Data?) async throws { + try await client.validate(response: response, data: data) + } +} +``` + +### Session is Now an Actor + +**Before (3.x):** +```swift +let session = Session(with: client) +let (body, response) = try await session.dataTask(for: call) +``` + +**After (4.x):** +```swift +// Session is now an actor - same usage pattern but with actor isolation +let session = Session(with: client) +let (body, response) = try await session.dataTask(for: call) + +// If you need debug logging: +let session = Session(with: client, debug: true) +``` + +The syntax remains the same, but `Session` is now actor-isolated. This means: +- All access is automatically serialized and thread-safe +- You must use `await` when calling session methods from outside the actor +- You cannot directly access session properties without `await` + +### Sendable Conformance for Custom Types + +All custom `Call`, `Client`, and response types must now be `Sendable`. + +**Before (3.x):** +```swift +struct GetUser: Call { + typealias Parser = JSONParser + let userId: String + + var request: URLRequestEncodable { + Request(.get, "users/\(userId)") + } +} + +struct User: Codable { + let id: String + let name: String +} +``` + +**After (4.x):** +```swift +// Call types must be Sendable (structs with Sendable properties are automatically Sendable) +struct GetUser: Call { + typealias Parser = JSONParser + let userId: String // String is Sendable + + var request: URLRequestEncodable { + Request(.get, "users/\(userId)") + } +} + +// Response types must be Sendable +struct User: Codable, Sendable { // Add explicit Sendable conformance + let id: String + let name: String +} +``` + +**Important:** If your custom types contain non-Sendable properties (like closures, class instances, or other reference types), you'll need to refactor them to use value types or actor-isolated references. + +### Custom ResponseParser + +**Before (3.x):** +```swift +struct CustomParser: ResponseParser { + typealias OutputType = T + + func parse(data: Data, encoding: String.Encoding) throws -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: data) + } +} +``` + +**After (4.x):** +```swift +struct CustomParser: ResponseParser { // T must be Sendable + typealias OutputType = T // OutputType is automatically Sendable + + func parse(data: Data, encoding: String.Encoding) throws -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: data) + } +} +``` + +### AnyCall ValidationBlock + +The `ValidationBlock` closure type is now marked as `@Sendable`. + +**Before (3.x):** +```swift +public typealias ValidationBlock = (HTTPURLResponse?, Data?) throws -> Void +``` + +**After (4.x):** +```swift +public typealias ValidationBlock = @Sendable (HTTPURLResponse?, Data?) throws -> Void +``` + +This means any closures passed to `AnyCall` for validation must be `@Sendable`, which requires them to only capture `Sendable` values. + +## Common Migration Issues + +### Issue: "Type 'MyClient' does not conform to protocol 'Sendable'" + +**Solution:** Ensure your client is a struct (value type) and all its stored properties are `Sendable`. If you have reference types, consider using actors or refactoring to value types. + +### Issue: "Stored property 'client' of 'Sendable'-conforming struct has non-sendable type" + +**Solution:** Make sure any stored clients are themselves `Sendable`. `DefaultClient` is `Sendable`, so storing it works. If you're storing a custom client, ensure it also conforms to `Sendable`. + +### Issue: "Cannot pass argument of non-sendable type 'MyModel' to parameter of type 'some Sendable'" + +**Solution:** Add `Sendable` conformance to your model types: +```swift +struct MyModel: Codable, Sendable { + // ... +} +``` + +### Issue: "Expression is 'async' but is not marked with 'await'" + +**Solution:** Since `Session` is now an actor, all calls to its methods require `await`: +```swift +let (body, response) = try await session.dataTask(for: call) +``` + +## Benefits of 4.x + +- **Thread Safety:** The actor-based `Session` ensures safe concurrent access to networking +- **Data Race Prevention:** `Sendable` conformance prevents data races at compile time +- **Swift 6 Future-Proofing:** Full compatibility with Swift's modern concurrency model +- **Better Composition:** Struct-based clients encourage better composition patterns over inheritance +- **Type Safety:** Improved type inference with `some Call` parameter + +For more examples, see the [Endpoints-Example](https://github.com/diamirio/Endpoints-Example) repository. diff --git a/README.md b/README.md index 84d401b..b9b84ff 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ It requires Swift 6.2+, makes heavy use of generics and protocols (with protocol **Swift Package Manager:** ```swift -.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "3.0.0")) +.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "4.0.0")) ``` ## Usage @@ -45,7 +45,7 @@ Here's how to load a random image from Giphy. ```swift // A client is responsible for encoding and parsing all calls for a given Web-API. -let client = AnyClient(baseURL: URL(string: "https://api.giphy.com/v1/")!) +let client = DefaultClient(url: URL(string: "https://api.giphy.com/v1/")!) // A call encapsulates the request that is sent to the server and the type that is expected in the response. let call = AnyCall(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"])) @@ -54,7 +54,7 @@ let call = AnyCall(Request(.get, "gifs/random", query: ["tag // Session is an actor, ensuring thread-safe access to URLSession. let session = Session(with: client) -// start call +// Start call - returns the parsed body and HTTPURLResponse let (body, httpResponse) = try await session.dataTask(for: call) ``` @@ -96,21 +96,27 @@ Look up the documentation in the code for further explanations of the types. #### JSON Codable Integration -`Endpoints` has a built in JSON Codable support. +`Endpoints` has built-in JSON Codable support. ##### Decoding The `ResponseParser` responsible for handling decodable types is the `JSONParser`. -The `JSONParser` uses the default `JSONDecoder()` by default, but you can customize it by creating a custom parser with a configured decoder. + +The default `JSONParser` comes pre-configured with: +- `dateDecodingStrategy = .iso8601` +- `keyDecodingStrategy = .convertFromSnakeCase` ```swift -// Decode a type using the default decoder +// Decode a type using the default decoder (with iso8601 dates and snake_case conversion) struct GiphyCall: Call { typealias Parser = JSONParser - ... + + var request: URLRequestEncodable { + Request(.get, "gifs/random", query: ["tag": "cat"]) + } } -// Custom parser with configured decoder +// If you need different decoder settings, create a custom parser // Note: T must be Sendable for Swift 6.2+ concurrency safety struct CustomJSONParser: ResponseParser { typealias OutputType = T @@ -119,8 +125,8 @@ struct CustomJSONParser: ResponseParser { init() { let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .secondsSince1970 + decoder.keyDecodingStrategy = .useDefaultKeys self.jsonDecoder = decoder } @@ -131,7 +137,10 @@ struct CustomJSONParser: ResponseParser { struct GiphyCall: Call { typealias Parser = CustomJSONParser - ... + + var request: URLRequestEncodable { + Request(.get, "gifs/random", query: ["tag": "cat"]) + } } ``` @@ -164,27 +173,33 @@ let call = GetRandomImage(tag: "cat") A client is responsible for handling things that are common for all operations of a given Web-API. Typically this includes appending API tokens or authentication tokens to a request or validating responses and handling errors. -`AnyClient` is the default implementation of the `Client` protocol and can be used as-is or as a starting point for your own dedicated client. +`DefaultClient` is the default implementation of the `Client` protocol and can be used as-is or as a starting point for your own dedicated client. -You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to an `AnyClient` instance, as done here. +You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to a `DefaultClient` instance, as done here. **Note:** All `Client` types must conform to `Sendable`. Use structs with sendable properties to ensure thread-safety: ```swift struct GiphyClient: Client { - var client: Client - var apiKey = "dc6zaTOxFJmzC" + private let client: Client + let apiKey = "dc6zaTOxFJmzC" init() { let url = URL(string: "https://api.giphy.com/v1/")! - self.client = AnyClient(baseURL: url) + self.client = DefaultClient(url: url) } func encode(call: some Call) async throws -> URLRequest { var request = try await client.encode(call: call) - // Append the API key to every request - request.append(query: ["api_key": apiKey]) + // Append the API key to every request's URL + if let url = request.url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) { + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "api_key", value: apiKey)) + components.queryItems = queryItems + request.url = components.url + } return request } @@ -192,7 +207,7 @@ struct GiphyClient: Client { func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType where C: Call { do { - // Use `AnyClient` to parse the response + // Use `DefaultClient` to parse the response // If this fails, try to read error details from response body return try await client.parse(response: response, data: data, for: call) } catch { @@ -212,29 +227,41 @@ struct GiphyClient: Client { throw StatusCodeError.unacceptable(code: response.statusCode, reason: errorCode) } } + + func validate(response: HTTPURLResponse?, data: Data?) async throws { + // Delegate to the default client's validation + try await client.validate(response: response, data: data) + } } ``` ### Dedicated Response Types -You usually want your networking layer to provide a dedicated response type for every supported call. In our example this could look like this: +You usually want your networking layer to provide a dedicated response type for every supported call. In our example this could look like this: + +**Note:** Response types must conform to `Sendable` for Swift 6.2+ concurrency safety: ```swift -struct RandomImage: Decodable { - struct Data: Decodable { +struct RandomImage: Decodable, Sendable { + struct Data: Decodable, Sendable { let url: URL - + private enum CodingKeys: String, CodingKey { case url = "image_url" } } - + let data: Data } struct GetRandomImage: Call { typealias Parser = JSONParser - ... + + var tag: String + + var request: URLRequestEncodable { + Request(.get, "gifs/random", query: ["tag": tag]) + } } ``` @@ -254,3 +281,117 @@ print("image url: \(body.data.url)") ## Example Example implementation can be found [here](https://github.com/diamirio/Endpoints-Example). + +## Migration Guides + +If you're upgrading from a previous version, please refer to the migration guides: + +- [Migrating from 3.x to 4.x](Migration/V4_0_0.md) - Swift 6.2+ strict concurrency, `AnyClient` → `DefaultClient`, and more +- [Migrating from 2.x to 3.x](Migration/V3_0_0.md) - Native async/await APIs +- [Migrating from 1.x to 2.x](Migration/V2_0_0.md) + +## Advanced Features + +### Debug Logging + +Enable debug logging to see detailed request and response information: + +```swift +let session = Session(with: client, debug: true) +``` + +This will log: +- cURL representation of the request +- Response status and headers +- Response body data + +### Request Body Encoding + +Endpoints supports multiple body encoding strategies: + +```swift +// JSON encoded body +let jsonBody = try JSONEncodedBody(encodable: myModel) +let request = Request(.post, "users", body: jsonBody) + +// Form-urlencoded body +let formBody = FormEncodedBody(parameters: ["username": "john", "password": "secret"]) +let request = Request(.post, "login", body: formBody) + +// Multipart form data (for file uploads) +let multipartBody = MultipartBody(parts: [ + MultipartBody.Part(name: "avatar", data: imageData, filename: "profile.jpg", mimeType: "image/jpeg"), + MultipartBody.Part(name: "name", data: "John Doe".data(using: .utf8)!) +]) +let request = Request(.post, "upload", body: multipartBody) +``` + +### Custom Validation + +Both `Call` and `Client` can implement custom validation logic: + +```swift +struct MyCall: Call { + typealias Parser = JSONParser + + var request: URLRequestEncodable { + Request(.get, "data") + } + + // Custom validation for this specific call + func validate(response: HTTPURLResponse?, data: Data?) throws { + guard let response = response else { return } + + // Require a specific header for this call + guard response.value(forHTTPHeaderField: "X-Custom-Header") != nil else { + throw MyError.missingHeader + } + } +} + +struct MyClient: Client { + private let client: Client + + init() { + self.client = DefaultClient(url: URL(string: "https://api.example.com")!) + } + + // ... encode and parse implementations ... + + // Custom validation for all calls using this client + func validate(response: HTTPURLResponse?, data: Data?) async throws { + // First, do the default validation + try await client.validate(response: response, data: data) + + // Then add custom validation + guard let response = response else { return } + + // Example: Check for maintenance mode + if response.statusCode == 503 { + throw MaintenanceError() + } + } +} +``` + +### Error Handling + +Endpoints wraps all errors in `EndpointsError`, which includes the `HTTPURLResponse` if available: + +```swift +do { + let (body, response) = try await session.dataTask(for: call) + // Handle success +} catch let error as EndpointsError { + // Access the underlying error + print("Error: \(error.error)") + + // Access the HTTP response if available + if let response = error.response { + print("Status code: \(response.statusCode)") + } +} catch { + // Handle other errors + print("Unexpected error: \(error)") +} +``` diff --git a/Sources/Async/Client.swift b/Sources/Async/Client.swift index 1a7cf86..8478a05 100644 --- a/Sources/Async/Client.swift +++ b/Sources/Async/Client.swift @@ -3,7 +3,7 @@ import Foundation /// A type responsible for encoding and parsing all calls for a given Web API. -/// A basic implementation is provided by `AnyClient`. +/// A basic implementation is provided by `DefaultClient`. public protocol Client: ResponseValidator, Sendable { /// Converts a `Call` created for this client's Web API /// into a `URLRequest`. diff --git a/Sources/Core/Call.swift b/Sources/Core/Call.swift index 6832af8..e8dbbb9 100644 --- a/Sources/Core/Call.swift +++ b/Sources/Core/Call.swift @@ -35,7 +35,7 @@ import Foundation /// /// Adopts `ResponseValidator`, so you can override `validate` if /// you want to validate the response for a specific `Call` type. -/// `AnyClient` will use this method to validate the response of the calls +/// `Session` will use this method to validate the response of the calls /// request before using its `Parser` to parse it. /// /// - seealso: `Client`, `Session`, `DataParser`, `Request` diff --git a/Sources/Core/Request.swift b/Sources/Core/Request.swift index b6dba0c..ea5fb3f 100644 --- a/Sources/Core/Request.swift +++ b/Sources/Core/Request.swift @@ -51,9 +51,9 @@ public struct Request: URLRequestEncodable { public extension URL { /// `true` if `self` has no scheme. /// - /// - note: Used by `AnyClient.encode` to determine if a `URLRequest` should be + /// - note: Used by `DefaultClient.encode` to determine if a `URLRequest` should be /// encoded using `self` alone (when `false`) or in combination with - /// its `baseURL` (when `true`). + /// its base URL (when `true`). var isRelative: Bool { scheme == nil } diff --git a/Sources/Error/EndpointsParsingError.swift b/Sources/Error/EndpointsParsingError.swift index 53ebf6c..5c7af10 100644 --- a/Sources/Error/EndpointsParsingError.swift +++ b/Sources/Error/EndpointsParsingError.swift @@ -6,7 +6,7 @@ import Foundation public enum EndpointsParsingError: LocalizedError { /// `Data` is missing. /// - /// Thrown by `AnyClient.parse` when the response data is `nil`. + /// Thrown by `DefaultClient.parse` when the response data is `nil`. case missingData /// `Data` is in an invalid format. diff --git a/Tests/Codable/Endpoints+JSONCodableTests.swift b/Tests/Codable/Endpoints+JSONCodableTests.swift index b5c9693..1357330 100644 --- a/Tests/Codable/Endpoints+JSONCodableTests.swift +++ b/Tests/Codable/Endpoints+JSONCodableTests.swift @@ -43,7 +43,7 @@ class EndpointsJSONCodableTests: XCTestCase { } } - func testUsingCustomDecoderAndAnyClient() async throws { + func testUsingCustomDecoderAndDefaultClient() async throws { let client = DefaultClient(url: URL(string: "www.tailored-apps.com")!) let call = PersonCall() From f825dcab6363728a2fbba30d56b4671164c13289 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Tue, 25 Nov 2025 14:36:05 +0100 Subject: [PATCH 15/18] Clarified documentation and fixed async for validation documentation --- README.md | 2 +- Sources/Core/Call.swift | 2 +- Sources/ResponseValidator/StatusCodeValidator.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b9b84ff..38ab8bc 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ struct MyCall: Call { } // Custom validation for this specific call - func validate(response: HTTPURLResponse?, data: Data?) throws { + func validate(response: HTTPURLResponse?, data: Data?) async throws { guard let response = response else { return } // Require a specific header for this call diff --git a/Sources/Core/Call.swift b/Sources/Core/Call.swift index e8dbbb9..eadd64a 100644 --- a/Sources/Core/Call.swift +++ b/Sources/Core/Call.swift @@ -50,5 +50,5 @@ public extension Call { func validate( response _: HTTPURLResponse?, data _: Data? - ) throws { /* no validation by default */ } + ) async throws { /* no validation by default */ } } diff --git a/Sources/ResponseValidator/StatusCodeValidator.swift b/Sources/ResponseValidator/StatusCodeValidator.swift index d896d0f..82bd756 100644 --- a/Sources/ResponseValidator/StatusCodeValidator.swift +++ b/Sources/ResponseValidator/StatusCodeValidator.swift @@ -14,7 +14,7 @@ public final class StatusCodeValidator: ResponseValidator { public func validate( response: HTTPURLResponse?, data _: Data? - ) throws { + ) async throws { if let code = response?.statusCode, !isAcceptableStatus(code: code) { throw StatusCodeError.unacceptable(code: code, reason: nil) From 72ef311be40f1b8cb6b95d755df5dba70610e38d Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Thu, 27 Nov 2025 09:53:10 +0100 Subject: [PATCH 16/18] Fixed missing await statement --- Sources/Async/DefaultClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Async/DefaultClient.swift b/Sources/Async/DefaultClient.swift index 6c5bc42..3a62d14 100644 --- a/Sources/Async/DefaultClient.swift +++ b/Sources/Async/DefaultClient.swift @@ -42,6 +42,6 @@ public struct DefaultClient: Client { response: HTTPURLResponse?, data: Data? ) async throws { - try statusCodeValidator.validate(response: response, data: data) + try await statusCodeValidator.validate(response: response, data: data) } } From d69d9b4d6c739707d8eb7ef3a2122ad4eada8085 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Mon, 1 Dec 2025 09:16:02 +0100 Subject: [PATCH 17/18] make convenience utils only internally available --- Sources/Core/Request.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Core/Request.swift b/Sources/Core/Request.swift index ea5fb3f..9489078 100644 --- a/Sources/Core/Request.swift +++ b/Sources/Core/Request.swift @@ -48,7 +48,7 @@ public struct Request: URLRequestEncodable { } } -public extension URL { +extension URL { /// `true` if `self` has no scheme. /// /// - note: Used by `DefaultClient.encode` to determine if a `URLRequest` should be @@ -57,7 +57,9 @@ public extension URL { var isRelative: Bool { scheme == nil } - +} + +public extension URL { /// Creates a relative URL with a given `path` and `query` Dictionary. init(path: String?, query: Parameters?) { var components = URLComponents() From 71592bf59e1ed9d499f500603b01d809aacdc887 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Mon, 1 Dec 2025 09:16:48 +0100 Subject: [PATCH 18/18] Applied format --- Sources/Core/Request.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Core/Request.swift b/Sources/Core/Request.swift index 9489078..a927f0e 100644 --- a/Sources/Core/Request.swift +++ b/Sources/Core/Request.swift @@ -58,7 +58,7 @@ extension URL { scheme == nil } } - + public extension URL { /// Creates a relative URL with a given `path` and `query` Dictionary. init(path: String?, query: Parameters?) {