From 7e0577ba00a07603bda564fe2b6191eff51f3616 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 25 Nov 2024 16:58:08 -0800 Subject: [PATCH 01/24] Implemented new Server type to definition --- Sources/Endpoints/Endpoint+URLRequest.swift | 30 ++++- Sources/Endpoints/Endpoint.swift | 12 +- Sources/Endpoints/EnvironmentType.swift | 120 +++++++++++++++++- .../Extensions/URLSession+Async.swift | 12 +- .../Extensions/URLSession+Combine.swift | 12 +- .../Extensions/URLSession+Endpoints.swift | 16 +-- .../Endpoints/CustomEncodingEndpoint.swift | 3 +- .../Endpoints/Environment.swift | 24 +++- .../Endpoints/InvalidEndpoint.swift | 5 +- .../Endpoints/JSONProviderEndpoint.swift | 3 +- .../Endpoints/PostEndpoint1.swift | 3 +- .../Endpoints/PostEndpoint2.swift | 3 +- .../Endpoints/SimpleEndpoint.swift | 3 +- .../EndpointsTests/Endpoints/TestServer.swift | 29 +++++ .../Endpoints/UserEndpoint.swift | 3 +- Tests/EndpointsTests/EndpointsTests.swift | 34 ++++- .../URLSessionExtensionTests.swift | 2 - 17 files changed, 264 insertions(+), 50 deletions(-) create mode 100644 Tests/EndpointsTests/Endpoints/TestServer.swift diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index c5f7ac6..275f093 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -12,13 +12,34 @@ import Foundation import FoundationNetworking #endif +enum Storage { + static var environments: [ObjectIdentifier: Any] = [:] +} + +extension Server { + public static var environment: Self.Environments { + get { + let typeKey = ObjectIdentifier(Self.Environments.self) + return Storage.environments[typeKey] as? Self.Environments ?? Self.defaultEnvironment + } + set { + let typeKey = ObjectIdentifier(Self.Environments.self) + Storage.environments[typeKey] = newValue + } + } + + public var baseUrl: URL? { + baseUrls[Self.environment] + } +} + extension Endpoint { /// Generates a `URLRequest` given the associated request value. /// - Parameter environment: The environment in which to create the request /// - Throws: An ``EndpointError`` which describes the error filling in data to the associated ``Definition``. /// - Returns: A `URLRequest` ready for requesting with all values from `self` filled in according to the associated ``Endpoint``. - public func urlRequest(in environment: EnvironmentType) throws -> URLRequest { + public func urlRequest() throws -> URLRequest { var components = URLComponents() components.path = Self.definition.path.path(with: pathComponents) @@ -92,7 +113,10 @@ extension Endpoint { .joined(separator: "&") } - let baseUrl = environment.baseUrl + guard let baseUrl = Self.definition.server.baseUrl else { + throw EndpointError.misconfiguredServer(server: Self.definition.server) + } + guard let url = components.url(relativeTo: baseUrl) else { throw EndpointError.invalid(components: components, relativeTo: baseUrl) } @@ -148,7 +172,7 @@ extension Endpoint { urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: Header.contentType.name) } - urlRequest = environment.requestProcessor(urlRequest) + urlRequest = Self.definition.server.requestProcessor(urlRequest) return urlRequest } diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index b8021f2..027bb03 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -18,6 +18,7 @@ public enum EndpointError: Error { case invalidForm(named: String, type: Any.Type) case invalidHeader(named: String, type: Any.Type) case invalidBody(Error) + case misconfiguredServer(server: any Server) } public enum Parameter { @@ -58,6 +59,8 @@ extension JSONDecoder: DecoderType { } public protocol Endpoint { + associatedtype S: Server + /// The response type received from the server. /// /// This conveys type information which helpers, such as the built-in ``Foundation/URLSession`` extensions, @@ -125,7 +128,7 @@ public protocol Endpoint { associatedtype ResponseDecoder: DecoderType = JSONDecoder /// A ``Definition`` which pieces together all the components defined in the endpoint. - static var definition: Definition { get } + static var definition: Definition { get } /// The instance of the associated `Body` type. Must be `Encodable`. var body: Body { get } @@ -197,8 +200,9 @@ public enum QueryEncodingStrategy { case custom((URLQueryItem) -> (String, String?)?) } -public struct Definition { +public struct Definition { + public let server: S /// The HTTP method of the ``Endpoint`` public let method: Method /// A template including all elements that appear in the path @@ -214,10 +218,12 @@ public struct Definition { /// - path: The path template representing the path and all path-related parameters /// - parameters: The parameters passed to the endpoint. Either through query or form body. /// - headerValues: The headers associated with this request - public init(method: Method, + public init(server: S, + method: Method, path: PathTemplate, parameters: [Parameter] = [], headers: [Header: HeaderField] = [:]) { + self.server = server self.method = method self.path = path self.parameters = parameters diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 226e7f7..619051b 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,13 +12,123 @@ import Foundation import FoundationNetworking #endif -public protocol EnvironmentType { - /// The baseUrl of the Environment - var baseUrl: URL { get } - /// Processes the built URLRequest right before sending in order to attach any Environment related authentication or data to the outbound request +public protocol Server { + associatedtype Environments: CaseIterable & Hashable + var baseUrls: [Environments: URL] { get } var requestProcessor: (URLRequest) -> URLRequest { get } + + static var defaultEnvironment: Environments { get } } -public extension EnvironmentType { +public extension Server { var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } + + static var defaultEnvironment: Environments { return Environments.allCases.first! } +} + +struct ApiServer: Server { + enum Environments: String, CaseIterable { + case local + case staging + case production + } + + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://api.velos.com")!, + .staging: URL(string: "https://api.velos.com")!, + .production: URL(string: "https://api.velos.com")! + ] + } + + static let api = Self() +} + +//@Server(MyEnvironments.self) +//struct ApiServer { +// var baseUrls: [MyEnvironments: URL] { +// return [ +// .local: URL(string: "https://local-api.velosmobile.com")!, +// .staging: URL(string: "https://staging-api.velosmobile.com")!, +// .production: URL(string: "https://api.velosmobile.com")! +// ] +// } +//} + +/* +extension ServerEnvironments { + static var localApi = ServerEnvironment(URL(string: "https://local-api.velosmobile.com")!) + static var stagingApi = ServerEnvironment(URL(string: "https://staging-api.velosmobile.com")!) + static var prodApi = ServerEnvironment(URL(string: "https://api.velosmobile.com")!) +} + +extension ServerEnvironments { + static var localAnalytics = ServerEnvironment(URL(string: "https://local-analytics.velosmobile.com")!) + static var stagingAnalytics = ServerEnvironment(URL(string: "https://staging-analytics.velosmobile.com")!) + static var prodAnalytics = ServerEnvironment(URL(string: "https://analytics.velosmobile.com")!) +} + +extension Servers { + static var api = Server(localApi, stagingApi, prodApi) + static var analytics = Server(localAnalytics, stagingAnalytics, prodAnalytics) +} + +Servers.setDefault(.api) +Servers.setEnvironment(.local) + +@Endpoint(.get, path: "user/\(path: \.name)/\(path: \.id)/profile", server: .analytics) +struct TestEndpoint { + struct PathComponents: Codable { + let name: String + let id: String + } + struct Response { + let id: String + } +} + +import EndpointsTesting + +Endpoints.respondTo(TestEndpoint.self, after: .seconds(1), with: { path in + if path.contains("123") { + return TestEndpoint.Response(id: "123") + } + throw TestErrors.unknownPath +}) + +Endpoints.respondTo(PostEndpoint.self, with: { path, body in + if body.id == "123" { + return .init(id: "123") + } + throw TestErrors.unknownBody +}) + + */ + +//@Endpoint(.get, path: "user/\(path: \.name)/\(path: \.id)/profile") +//struct TestEndpoint { +// struct PathComponents: Codable { +// let name: String +// let id: String +// } +//} + +struct TestEndpoint: Endpoint { + typealias Response = Void + static let definition: Definition = .init(server: ApiServer.api, method: .get, path: "/") } + +struct Testing { + +} + +//public protocol EnvironmentType { +// /// The baseUrl of the Environment +// var baseUrl: URL { get } +// /// Processes the built URLRequest right before sending in order to attach any Environment related authentication or data to the outbound request +// var requestProcessor: (URLRequest) -> URLRequest { get } +//} +// +//public extension EnvironmentType { +// var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } +//} diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 1509a0b..e301c22 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -17,8 +17,8 @@ public extension URLSession { /// - Parameters: /// - environment: The environment in which to make the request /// - endpoint: The endpoint instance to be used to make the request - func response(in environment: EnvironmentType, with endpoint: T) async throws where T.Response == Void { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws where T.Response == Void { + let urlRequest = try createUrlRequest(for: endpoint) let result: (data: Data, response: URLResponse) do { @@ -34,8 +34,8 @@ public extension URLSession { _ = try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(in environment: EnvironmentType, with endpoint: T) async throws -> T.Response where T.Response == Data { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws -> T.Response where T.Response == Data { + let urlRequest = try createUrlRequest(for: endpoint) let result: (data: Data, response: URLResponse) do { @@ -51,8 +51,8 @@ public extension URLSession { return try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(in environment: EnvironmentType, with endpoint: T) async throws -> T.Response where T.Response: Decodable { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { + let urlRequest = try createUrlRequest(for: endpoint) let result: (data: Data, response: URLResponse) do { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 55be5af..5456b72 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -19,10 +19,10 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response == Void { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() @@ -51,11 +51,11 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response == Data { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() @@ -84,11 +84,11 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response: Decodable { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 0ede3cd..bf242de 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -42,9 +42,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { + func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) return dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error).map { _ in }) @@ -60,9 +60,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { + func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) return dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error)) @@ -78,9 +78,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { + func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) return dataTask(with: urlRequest) { (data, response, error) in let response = T.definition.response(data: data, response: response, error: error) @@ -100,11 +100,11 @@ public extension URLSession { } } - func createUrlRequest(in environment: EnvironmentType, for endpoint: T) throws -> URLRequest { + func createUrlRequest(for endpoint: T) throws -> URLRequest { let urlRequest: URLRequest do { - urlRequest = try endpoint.urlRequest(in: environment) + urlRequest = try endpoint.urlRequest() } catch { guard let endpointError = error as? EndpointError else { fatalError("Unhandled endpoint error: \(error)") diff --git a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift index ce6e8c7..d1de895 100644 --- a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift @@ -10,7 +10,8 @@ import Endpoints import Foundation struct CustomEncodingEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( + server: .test, method: .get, path: "/", parameters: [ diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index 762ed95..5864f54 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -9,8 +9,26 @@ import Foundation @testable import Endpoints -struct Environment: EnvironmentType { - let baseUrl: URL +struct MyServer: Server { + enum Environments: String, CaseIterable { + case local + case staging + case production + } - static let test = Environment(baseUrl: URL(string: "https://velosmobile.com")!) + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://api.velos.com")!, + .staging: URL(string: "https://api.velos.com")!, + .production: URL(string: "https://api.velos.com")! + ] + } + + static let server = Self() } + +//struct Environment: EnvironmentType { +// let baseUrl: URL +// +// static let test = Environment(baseUrl: URL(string: "https://velosmobile.com")!) +//} diff --git a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift index d8d3bcb..6e045d1 100644 --- a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift @@ -9,7 +9,8 @@ import Endpoints struct InvalidEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( + server: .test, method: .get, path: "/", parameters: [ @@ -17,6 +18,8 @@ struct InvalidEndpoint: Endpoint { ] ) + static let server = TestServer.test + struct ParameterComponents { enum MyEnum { case value } let nonEncodable: MyEnum diff --git a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift index 63017d2..501cfb9 100644 --- a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift @@ -11,7 +11,8 @@ import Foundation struct JSONProviderEndpoint: Endpoint { - static var definition: Definition = Definition( + static var definition: Definition = Definition( + server: .test, method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift index b43e029..9120463 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift @@ -10,7 +10,8 @@ import Foundation @testable import Endpoints struct PostEndpoint1: Endpoint { - static var definition: Definition = Definition( + static var definition: Definition = Definition( + server: .test, method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift index 269fdf7..dee36f7 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift @@ -10,7 +10,8 @@ import Foundation @testable import Endpoints struct PostEndpoint2: Endpoint { - static var definition: Definition = Definition( + static var definition: Definition = Definition( + server: .test, method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift index 657b8a2..b58c4a6 100644 --- a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift @@ -10,7 +10,8 @@ import Foundation @testable import Endpoints struct SimpleEndpoint: Endpoint { - static var definition: Definition = Definition( + static var definition: Definition = Definition( + server: .test, method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/TestServer.swift b/Tests/EndpointsTests/Endpoints/TestServer.swift new file mode 100644 index 0000000..6af8f7c --- /dev/null +++ b/Tests/EndpointsTests/Endpoints/TestServer.swift @@ -0,0 +1,29 @@ +// +// TestServer.swift +// Endpoints +// +// Created by Zac White on 11/1/24. +// + +import Endpoints +import Foundation + +struct TestServer: Server { + enum Environments: String, CaseIterable { + case local + case staging + case production + } + + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://local-api.velosmobile.com")!, + .staging: URL(string: "https://staging-api.velosmobile.com")!, + .production: URL(string: "https://api.velosmobile.com")! + ] + } + + var defaultEnvironment: Environments { .staging } + + static let test = Self() +} diff --git a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift index 5ed5c0d..9862d2c 100644 --- a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift @@ -10,7 +10,8 @@ import Foundation @testable import Endpoints struct UserEndpoint: Endpoint { - static var definition: Definition = Definition( + static var definition: Definition = Definition( + server: .test, method: .get, path: "hey" + \UserEndpoint.PathComponents.userId, parameters: [ diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index e0e18ab..4a15144 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -14,7 +14,7 @@ class EndpointsTests: XCTestCase { func testBasicEndpoint() throws { let request = try SimpleEndpoint( pathComponents: .init(name: "zac", id: "42") - ).urlRequest(in: Environment.test) + ).urlRequest() XCTAssertEqual(request.url?.path, "/user/zac/42/profile") @@ -28,7 +28,7 @@ class EndpointsTests: XCTestCase { let request = try JSONProviderEndpoint( body: .init(bodyValueOne: "value"), pathComponents: .init(name: "zac", id: "42") - ).urlRequest(in: Environment.test) + ).urlRequest() XCTAssertEqual(request.url?.path, "/user/zac/42/profile") @@ -45,7 +45,7 @@ class EndpointsTests: XCTestCase { let date = Date() let request = try PostEndpoint1( body: .init(property1: date, property2: nil) - ).urlRequest(in: Environment.test) + ).urlRequest() let encodedDate = ISO8601DateFormatter().string(from: date) let bodyData = "{\"property1\":\"\(encodedDate)\"}".data(using: .utf8)! @@ -55,7 +55,7 @@ class EndpointsTests: XCTestCase { func testPostEndpoint() throws { let request = try PostEndpoint2( body: .init(property1: "test", property2: nil) - ).urlRequest(in: Environment.test) + ).urlRequest() XCTAssertEqual(request.url?.path, "/path") XCTAssertEqual(request.httpMethod, "POST") @@ -104,7 +104,7 @@ class EndpointsTests: XCTestCase { func testCustomParameterEncoding() throws { let request = try CustomEncodingEndpoint( parameterComponents: .init(needsCustomEncoding: "++++") - ).urlRequest(in: Environment.test) + ).urlRequest() XCTAssertEqual(request.url?.query, "key=%2B%2B%2B%2B") } @@ -125,7 +125,7 @@ class EndpointsTests: XCTestCase { optionalDate: nil ), headerComponents: .init(headerValue: "test") - ).urlRequest(in: Environment.test) + ).urlRequest() XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/hey/3") @@ -152,7 +152,7 @@ class EndpointsTests: XCTestCase { XCTAssertThrowsError( try InvalidEndpoint( parameterComponents: .init(nonEncodable: .value) - ).urlRequest(in: Environment.test) + ).urlRequest() ) { error in XCTAssertTrue(error is EndpointError, "error is \(type(of: error)) and not an EndpointError") } @@ -230,4 +230,24 @@ class EndpointsTests: XCTestCase { XCTAssertEqual(response.statusCode, 404) XCTAssertEqual(decoded, errorResponse) } + + @available(iOS 16.0, *) + func testEnvironmentsChange() throws { + let existing = TestServer.environment + + let endpoint = SimpleEndpoint( + pathComponents: .init(name: "zac", id: "42") + ) + + TestServer.environment = .local + XCTAssertEqual(try endpoint.urlRequest().url?.host(), "local-api.velosmobile.com") + + TestServer.environment = .staging + XCTAssertEqual(try endpoint.urlRequest().url?.host(), "staging-api.velosmobile.com") + + TestServer.environment = .production + XCTAssertEqual(try endpoint.urlRequest().url?.host(), "api.velosmobile.com") + + TestServer.environment = existing + } } diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index 98e82c5..ddce9ca 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -17,7 +17,6 @@ class URLSessionExtensionTests: XCTestCase { func testTaskCreationFailure() { XCTAssertThrowsError( try URLSession.shared.endpointTask( - in: Environment.test, with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)), completion: { _ in } ) @@ -29,7 +28,6 @@ class URLSessionExtensionTests: XCTestCase { func testPublisherCreationFailure() { let publisherExpectation = expectation(description: "publisher creation failure") URLSession.shared.endpointPublisher( - in: Environment.test, with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)) ) .sink { completion in From 728734e52aeff20d435e8156099d1680798909a5 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 25 Nov 2024 17:16:48 -0800 Subject: [PATCH 02/24] Cleaner declarations --- Sources/Endpoints/Endpoint.swift | 2 +- Sources/Endpoints/EnvironmentType.swift | 9 ++++-- .../Endpoints/CustomEncodingEndpoint.swift | 3 +- .../Endpoints/Environment.swift | 12 ++------ .../Endpoints/InvalidEndpoint.swift | 5 +--- .../Endpoints/JSONProviderEndpoint.swift | 1 - .../Endpoints/PostEndpoint1.swift | 1 - .../Endpoints/PostEndpoint2.swift | 1 - .../Endpoints/SimpleEndpoint.swift | 1 - .../EndpointsTests/Endpoints/TestServer.swift | 4 +-- .../Endpoints/UserEndpoint.swift | 29 +++++++++---------- 11 files changed, 28 insertions(+), 40 deletions(-) diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 027bb03..3bb6eee 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -218,7 +218,7 @@ public struct Definition { /// - path: The path template representing the path and all path-related parameters /// - parameters: The parameters passed to the endpoint. Either through query or form body. /// - headerValues: The headers associated with this request - public init(server: S, + public init(server: S = .server, method: Method, path: PathTemplate, parameters: [Parameter] = [], diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 619051b..37a6669 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -13,7 +13,9 @@ import FoundationNetworking #endif public protocol Server { - associatedtype Environments: CaseIterable & Hashable + associatedtype Environments: Hashable + + init() var baseUrls: [Environments: URL] { get } var requestProcessor: (URLRequest) -> URLRequest { get } @@ -22,8 +24,10 @@ public protocol Server { public extension Server { var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } +} - static var defaultEnvironment: Environments { return Environments.allCases.first! } +public extension Server { + static var server: Self { Self() } } struct ApiServer: Server { @@ -41,6 +45,7 @@ struct ApiServer: Server { ] } + static var defaultEnvironment: Environments { .production } static let api = Self() } diff --git a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift index d1de895..69db76a 100644 --- a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift @@ -11,11 +11,10 @@ import Foundation struct CustomEncodingEndpoint: Endpoint { static let definition: Definition = Definition( - server: .test, method: .get, path: "/", parameters: [ - .query("key", path: \ParameterComponents.needsCustomEncoding) + .query("key", path: \.needsCustomEncoding) ] ) diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index 5864f54..bfb93ef 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -15,7 +15,7 @@ struct MyServer: Server { case staging case production } - + var baseUrls: [Environments: URL] { return [ .local: URL(string: "https://api.velos.com")!, @@ -23,12 +23,6 @@ struct MyServer: Server { .production: URL(string: "https://api.velos.com")! ] } - - static let server = Self() + + static var defaultEnvironment: Environments { .production } } - -//struct Environment: EnvironmentType { -// let baseUrl: URL -// -// static let test = Environment(baseUrl: URL(string: "https://velosmobile.com")!) -//} diff --git a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift index 6e045d1..01289ef 100644 --- a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift @@ -10,16 +10,13 @@ import Endpoints struct InvalidEndpoint: Endpoint { static let definition: Definition = Definition( - server: .test, method: .get, path: "/", parameters: [ - .query("path", path: \ParameterComponents.nonEncodable) + .query("path", path: \.nonEncodable) ] ) - static let server = TestServer.test - struct ParameterComponents { enum MyEnum { case value } let nonEncodable: MyEnum diff --git a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift index 501cfb9..71a1ff8 100644 --- a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift @@ -12,7 +12,6 @@ import Foundation struct JSONProviderEndpoint: Endpoint { static var definition: Definition = Definition( - server: .test, method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift index 9120463..b8c93c7 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift @@ -11,7 +11,6 @@ import Foundation struct PostEndpoint1: Endpoint { static var definition: Definition = Definition( - server: .test, method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift index dee36f7..f29277d 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift @@ -11,7 +11,6 @@ import Foundation struct PostEndpoint2: Endpoint { static var definition: Definition = Definition( - server: .test, method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift index b58c4a6..999b1a3 100644 --- a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift @@ -11,7 +11,6 @@ import Foundation struct SimpleEndpoint: Endpoint { static var definition: Definition = Definition( - server: .test, method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/TestServer.swift b/Tests/EndpointsTests/Endpoints/TestServer.swift index 6af8f7c..b91ddd5 100644 --- a/Tests/EndpointsTests/Endpoints/TestServer.swift +++ b/Tests/EndpointsTests/Endpoints/TestServer.swift @@ -23,7 +23,5 @@ struct TestServer: Server { ] } - var defaultEnvironment: Environments { .staging } - - static let test = Self() + static var defaultEnvironment: Environments { .production } } diff --git a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift index 9862d2c..005789d 100644 --- a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift @@ -11,27 +11,26 @@ import Foundation struct UserEndpoint: Endpoint { static var definition: Definition = Definition( - server: .test, method: .get, - path: "hey" + \UserEndpoint.PathComponents.userId, + path: "hey" + \.userId, parameters: [ - .form("string", path: \UserEndpoint.ParameterComponents.string), - .form("date", path: \UserEndpoint.ParameterComponents.date), - .form("double", path: \UserEndpoint.ParameterComponents.double), - .form("int", path: \UserEndpoint.ParameterComponents.int), - .form("bool_true", path: \UserEndpoint.ParameterComponents.boolTrue), - .form("bool_false", path: \UserEndpoint.ParameterComponents.boolFalse), - .form("time_zone", path: \UserEndpoint.ParameterComponents.timeZone), - .form("optional_string", path: \UserEndpoint.ParameterComponents.optionalString), - .form("optional_date", path: \UserEndpoint.ParameterComponents.optionalDate), + .form("string", path: \.string), + .form("date", path: \.date), + .form("double", path: \.double), + .form("int", path: \.int), + .form("bool_true", path: \.boolTrue), + .form("bool_false", path: \.boolFalse), + .form("time_zone", path: \.timeZone), + .form("optional_string", path: \.optionalString), + .form("optional_date", path: \.optionalDate), .formValue("hard_coded_form", value: "true"), - .query("string", path: \UserEndpoint.ParameterComponents.string), - .query("optional_string", path: \UserEndpoint.ParameterComponents.optionalString), - .query("optional_date", path: \UserEndpoint.ParameterComponents.optionalDate), + .query("string", path: \.string), + .query("optional_string", path: \.optionalString), + .query("optional_date", path: \.optionalDate), .queryValue("hard_coded_query", value: "true") ], headers: [ - "HEADER_TYPE": .field(path: \UserEndpoint.HeaderComponents.headerValue), + "HEADER_TYPE": .field(path: \.headerValue), "HARD_CODED_HEADER": .fieldValue(value: "test2"), .keepAlive: .fieldValue(value: "timeout=5, max=1000") ] From 6ed416ba59bb1bf742f85887424753c9d8f4deba Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 26 Nov 2024 08:57:15 -0800 Subject: [PATCH 03/24] Updated naming --- Sources/Endpoints/Endpoint+URLRequest.swift | 2 +- Sources/Endpoints/Endpoint.swift | 8 ++++---- Sources/Endpoints/EnvironmentType.swift | 8 ++++---- Tests/EndpointsTests/Endpoints/Environment.swift | 2 +- Tests/EndpointsTests/Endpoints/TestServer.swift | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 275f093..75b72a7 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -16,7 +16,7 @@ enum Storage { static var environments: [ObjectIdentifier: Any] = [:] } -extension Server { +extension ServerDefinition { public static var environment: Self.Environments { get { let typeKey = ObjectIdentifier(Self.Environments.self) diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 3bb6eee..c43f750 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -18,7 +18,7 @@ public enum EndpointError: Error { case invalidForm(named: String, type: Any.Type) case invalidHeader(named: String, type: Any.Type) case invalidBody(Error) - case misconfiguredServer(server: any Server) + case misconfiguredServer(server: any ServerDefinition) } public enum Parameter { @@ -59,7 +59,7 @@ extension JSONDecoder: DecoderType { } public protocol Endpoint { - associatedtype S: Server + associatedtype Server: ServerDefinition /// The response type received from the server. /// @@ -128,7 +128,7 @@ public protocol Endpoint { associatedtype ResponseDecoder: DecoderType = JSONDecoder /// A ``Definition`` which pieces together all the components defined in the endpoint. - static var definition: Definition { get } + static var definition: Definition { get } /// The instance of the associated `Body` type. Must be `Encodable`. var body: Body { get } @@ -200,7 +200,7 @@ public enum QueryEncodingStrategy { case custom((URLQueryItem) -> (String, String?)?) } -public struct Definition { +public struct Definition { public let server: S /// The HTTP method of the ``Endpoint`` diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 37a6669..8e3705a 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,7 +12,7 @@ import Foundation import FoundationNetworking #endif -public protocol Server { +public protocol ServerDefinition { associatedtype Environments: Hashable init() @@ -22,15 +22,15 @@ public protocol Server { static var defaultEnvironment: Environments { get } } -public extension Server { +public extension ServerDefinition { var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } } -public extension Server { +public extension ServerDefinition { static var server: Self { Self() } } -struct ApiServer: Server { +struct ApiServer: ServerDefinition { enum Environments: String, CaseIterable { case local case staging diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index bfb93ef..f304681 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -9,7 +9,7 @@ import Foundation @testable import Endpoints -struct MyServer: Server { +struct MyServer: ServerDefinition { enum Environments: String, CaseIterable { case local case staging diff --git a/Tests/EndpointsTests/Endpoints/TestServer.swift b/Tests/EndpointsTests/Endpoints/TestServer.swift index b91ddd5..50d2454 100644 --- a/Tests/EndpointsTests/Endpoints/TestServer.swift +++ b/Tests/EndpointsTests/Endpoints/TestServer.swift @@ -8,7 +8,7 @@ import Endpoints import Foundation -struct TestServer: Server { +struct TestServer: ServerDefinition { enum Environments: String, CaseIterable { case local case staging From d9bcf428daf81c07fcdf4777ce08f1b0b8f82c35 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 27 Nov 2024 15:29:12 -0800 Subject: [PATCH 04/24] Added concurrency annotations --- Package.swift | 2 +- Sources/Endpoints/Endpoint+URLRequest.swift | 26 ++--------- Sources/Endpoints/Endpoint.swift | 33 +++++++------- Sources/Endpoints/EnvironmentType.swift | 45 ++++++++++++------- .../Extensions/URLSession+Endpoints.swift | 8 ++-- Sources/Endpoints/Header.swift | 4 +- Sources/Endpoints/Method.swift | 2 +- Sources/Endpoints/PathTemplate.swift | 18 ++++---- Sources/Endpoints/Server.swift | 38 ++++++++++++++++ .../Endpoints/JSONProviderEndpoint.swift | 2 +- .../Endpoints/PostEndpoint1.swift | 2 +- .../Endpoints/PostEndpoint2.swift | 2 +- .../Endpoints/SimpleEndpoint.swift | 2 +- .../Endpoints/UserEndpoint.swift | 2 +- .../URLSessionExtensionTests.swift | 1 + 15 files changed, 112 insertions(+), 75 deletions(-) create mode 100644 Sources/Endpoints/Server.swift diff --git a/Package.swift b/Package.swift index d741c3f..9f2ded6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 75b72a7..4e39d69 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -12,27 +12,6 @@ import Foundation import FoundationNetworking #endif -enum Storage { - static var environments: [ObjectIdentifier: Any] = [:] -} - -extension ServerDefinition { - public static var environment: Self.Environments { - get { - let typeKey = ObjectIdentifier(Self.Environments.self) - return Storage.environments[typeKey] as? Self.Environments ?? Self.defaultEnvironment - } - set { - let typeKey = ObjectIdentifier(Self.Environments.self) - Storage.environments[typeKey] = newValue - } - } - - public var baseUrl: URL? { - baseUrls[Self.environment] - } -} - extension Endpoint { /// Generates a `URLRequest` given the associated request value. @@ -113,7 +92,10 @@ extension Endpoint { .joined(separator: "&") } - guard let baseUrl = Self.definition.server.baseUrl else { + let server = Self.definition.server + let baseUrl = server.baseUrls[type(of: server).environment] + + guard let baseUrl else { throw EndpointError.misconfiguredServer(server: Self.definition.server) } diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index c43f750..4e79dd4 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -12,29 +12,29 @@ import Foundation import FoundationNetworking #endif -public enum EndpointError: Error { +public enum EndpointError: Error, Sendable { case invalid(components: URLComponents, relativeTo: URL) case invalidQuery(named: String, type: Any.Type) case invalidForm(named: String, type: Any.Type) case invalidHeader(named: String, type: Any.Type) case invalidBody(Error) - case misconfiguredServer(server: any ServerDefinition) + case misconfiguredServer(server: any (ServerDefinition & Sendable)) } -public enum Parameter { - case form(String, path: PartialKeyPath) +public enum Parameter: Sendable { + case form(String, path: PartialKeyPath & Sendable) case formValue(String, value: PathRepresentable) - case query(String, path: PartialKeyPath) + case query(String, path: PartialKeyPath & Sendable) case queryValue(String, value: PathRepresentable) } -public enum HeaderField { - case field(path: PartialKeyPath) - case fieldValue(value: CustomStringConvertible) +public enum HeaderField: Sendable { + case field(path: PartialKeyPath & Sendable) + case fieldValue(value: CustomStringConvertible & Sendable) } /// A placeholder type for representing empty encodable or decodable Body values and ErrorResponse values. -public struct EmptyCodable: Codable { } +public struct EmptyCodable: Codable, Sendable { } public protocol EncoderType { static var contentType: String? { get } @@ -73,7 +73,7 @@ public protocol Endpoint { /// /// This can be useful if your server returns a different JSON structure when there's an error versus a success. Often in a project, this can be defined globally /// and `typealias` can be used to associate this global type on all ``Endpoint``s. - associatedtype ErrorResponse: Decodable = EmptyCodable + associatedtype ErrorResponse: Decodable & Sendable = EmptyCodable /// The body type conforming to `Encodable`. Defaults to ``EmptyCodable``. associatedtype Body: Encodable = EmptyCodable @@ -99,7 +99,7 @@ public protocol Endpoint { /// let pathComponents: PathComponents /// } /// ``` - associatedtype PathComponents = Void + associatedtype PathComponents: Sendable = Void /// The values needed to fill the ``Definition``'s parameters. /// @@ -115,10 +115,10 @@ public protocol Endpoint { /// ``` /// /// With this enum, either hard-coded values can be injected into the ``Endpoint`` (with ``Parameter/formValue(_:value:)`` or ``Parameter/queryValue(_:value:)``) or key paths can define which reference properties in the ``Endpoint/ParameterComponents`` associated type to define a form or query parameter that is needed at the time of the request. - associatedtype ParameterComponents = Void + associatedtype ParameterComponents: Sendable = Void /// The values needed to fill the ``Definition``'s headers. - associatedtype HeaderComponents = Void + associatedtype HeaderComponents: Sendable = Void /// The ``EncoderType`` to use when encoding the body of the request. Defaults to `JSONEncoder`. associatedtype BodyEncoder: EncoderType = JSONEncoder @@ -200,9 +200,10 @@ public enum QueryEncodingStrategy { case custom((URLQueryItem) -> (String, String?)?) } -public struct Definition { +public struct Definition: Sendable { - public let server: S + /// The server this endpoints will use + public let server: Server /// The HTTP method of the ``Endpoint`` public let method: Method /// A template including all elements that appear in the path @@ -218,7 +219,7 @@ public struct Definition { /// - path: The path template representing the path and all path-related parameters /// - parameters: The parameters passed to the endpoint. Either through query or form body. /// - headerValues: The headers associated with this request - public init(server: S = .server, + public init(server: Server = Server(), method: Method, path: PathTemplate, parameters: [Parameter] = [], diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 8e3705a..797b34b 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,8 +12,15 @@ import Foundation import FoundationNetworking #endif -public protocol ServerDefinition { - associatedtype Environments: Hashable +public enum TypicalEnvironments: String, CaseIterable { + case local + case development + case staging + case production +} + +public protocol ServerDefinition: Sendable { + associatedtype Environments: Hashable = TypicalEnvironments init() var baseUrls: [Environments: URL] { get } @@ -26,26 +33,23 @@ public extension ServerDefinition { var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } } -public extension ServerDefinition { - static var server: Self { Self() } -} - struct ApiServer: ServerDefinition { - enum Environments: String, CaseIterable { - case local - case staging - case production - } - var baseUrls: [Environments: URL] { return [ - .local: URL(string: "https://api.velos.com")!, - .staging: URL(string: "https://api.velos.com")!, + .local: URL(string: "https://local-api.velos.com")!, + .staging: URL(string: "https://staging-api.velos.com")!, .production: URL(string: "https://api.velos.com")! ] } - static var defaultEnvironment: Environments { .production } + static var defaultEnvironment: Environments { + #if DEBUG + .staging + #else + .production + #endif + } + static let api = Self() } @@ -53,6 +57,17 @@ struct ApiServer: ServerDefinition { //struct ApiServer { // var baseUrls: [MyEnvironments: URL] { // return [ +// .blueSteel: URL(string: "https://bluesteel-api.velosmobile.com")!, +// .redStone: URL(string: "https://redstone-api.velosmobile.com")!, +// .production: URL(string: "https://api.velosmobile.com")! +// ] +// } +//} + +//@Server +//struct ApiServer { +// var baseUrls: [Environments: URL] { +// return [ // .local: URL(string: "https://local-api.velosmobile.com")!, // .staging: URL(string: "https://staging-api.velosmobile.com")!, // .production: URL(string: "https://api.velosmobile.com")! diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index bf242de..fdc983a 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -13,7 +13,7 @@ import FoundationNetworking #endif /// A error when creating or requesting an Endpoint -public enum EndpointTaskError: Error { +public enum EndpointTaskError: Error, Sendable { case endpointError(EndpointError) case responseParseError(data: Data, error: Error) @@ -42,7 +42,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { let urlRequest = try createUrlRequest(for: endpoint) @@ -60,7 +60,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { let urlRequest = try createUrlRequest(for: endpoint) @@ -78,7 +78,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { let urlRequest = try createUrlRequest(for: endpoint) diff --git a/Sources/Endpoints/Header.swift b/Sources/Endpoints/Header.swift index aee332c..95f763d 100644 --- a/Sources/Endpoints/Header.swift +++ b/Sources/Endpoints/Header.swift @@ -25,7 +25,7 @@ import Foundation /// ``` /// /// Custom keys in the headers dictionary can be defined ad-hoc using a String, or by extending the encapsulating type `Header`. Basic named headers, such as `.keepAlive`, `.accept`, etc., are already defined as part of the library. -public struct Header: Hashable, ExpressibleByStringLiteral { +public struct Header: Hashable, ExpressibleByStringLiteral, Sendable { /// The name of the header. Example: "Accept-Language" public let name: String @@ -53,7 +53,7 @@ public struct Header: Hashable, ExpressibleByStringLiteral { /// The Header category. /// See: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 -public enum HeaderCategory { +public enum HeaderCategory: Sendable { case general case request case response diff --git a/Sources/Endpoints/Method.swift b/Sources/Endpoints/Method.swift index a73560a..f0aa351 100644 --- a/Sources/Endpoints/Method.swift +++ b/Sources/Endpoints/Method.swift @@ -9,7 +9,7 @@ import Foundation /// The HTTP Method -public enum Method { +public enum Method: Sendable { case options case get case head diff --git a/Sources/Endpoints/PathTemplate.swift b/Sources/Endpoints/PathTemplate.swift index f71575e..c71d5e7 100644 --- a/Sources/Endpoints/PathTemplate.swift +++ b/Sources/Endpoints/PathTemplate.swift @@ -8,7 +8,7 @@ import Foundation -public protocol PathRepresentable { +public protocol PathRepresentable: Sendable{ /// A path-safe version of the value, suitable for a URL path var pathSafe: String { get } } @@ -35,9 +35,9 @@ extension Int: PathRepresentable { } /// A template representing a URL path -public struct PathTemplate { +public struct PathTemplate: Sendable { - private struct RepresentableInfo: Equatable { + private struct RepresentableInfo: Equatable, Sendable { static func == (lhs: RepresentableInfo, rhs: RepresentableInfo) -> Bool { return lhs.index == rhs.index && lhs.includesSlash == rhs.includesSlash && @@ -50,7 +50,7 @@ public struct PathTemplate { } private var pathComponents: [RepresentableInfo] = [] - private var keyPathComponents: [(Int, PartialKeyPath, Bool)] = [] + private var keyPathComponents: [(Int, PartialKeyPath & Sendable, Bool)] = [] private var currentIndex: Int = 0 @@ -66,7 +66,7 @@ public struct PathTemplate { } } - mutating func append(keyPath: PartialKeyPath, indexOverride: Int? = nil, includesSlash: Bool = true) { + mutating func append(keyPath: PartialKeyPath & Sendable, indexOverride: Int? = nil, includesSlash: Bool = true) { if let index = indexOverride { keyPathComponents.append((index, keyPath, includesSlash)) currentIndex = index @@ -160,14 +160,14 @@ extension PathTemplate: ExpressibleByStringInterpolation { path.append(path: literal) } - mutating public func appendInterpolation(path value: KeyPath, includesSlash: Bool = true) { + mutating public func appendInterpolation(path value: KeyPath & Sendable, includesSlash: Bool = true) { path.append(keyPath: value, includesSlash: includesSlash) } } } // PathRepresentable + KeyPath -public func +(lhs: U, rhs: KeyPath) -> PathTemplate { +public func +(lhs: U, rhs: KeyPath & Sendable) -> PathTemplate { var template = PathTemplate() template.append(path: lhs) template.append(keyPath: rhs) @@ -175,7 +175,7 @@ public func +(lhs: U, rhs: KeyPat } // KeyPath + PathRepresentable -public func +(lhs: KeyPath, rhs: U) -> PathTemplate { +public func +(lhs: KeyPath & Sendable, rhs: U) -> PathTemplate { var template = PathTemplate() template.append(keyPath: lhs) template.append(path: rhs) @@ -183,7 +183,7 @@ public func +(lhs: KeyPath, } // Template + KeyPath -public func +(lhs: PathTemplate, rhs: KeyPath) -> PathTemplate { +public func +(lhs: PathTemplate, rhs: KeyPath & Sendable) -> PathTemplate { var template = lhs template.append(keyPath: rhs) return template diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift new file mode 100644 index 0000000..0f25c7b --- /dev/null +++ b/Sources/Endpoints/Server.swift @@ -0,0 +1,38 @@ +// +// Server.swift +// Endpoints +// +// Created by Zac White on 11/27/24. +// + +import Foundation + +enum EnvironmentStorage { + private static let lock = NSLock() + nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] + + static func getEnvironment(for type: T.Type) -> T? { + lock.lock() + defer { lock.unlock() } + let typeKey = ObjectIdentifier(type) + return environments[typeKey] as? T + } + + static func setEnvironment(_ environment: T, for type: T.Type) { + lock.lock() + defer { lock.unlock() } + let typeKey = ObjectIdentifier(type) + environments[typeKey] = environment + } +} + +extension ServerDefinition { + public static var environment: Self.Environments { + get { + EnvironmentStorage.getEnvironment(for: Self.Environments.self) ?? Self.defaultEnvironment + } + set { + EnvironmentStorage.setEnvironment(newValue, for: Self.Environments.self) + } + } +} diff --git a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift index 71a1ff8..58a84ee 100644 --- a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift @@ -11,7 +11,7 @@ import Foundation struct JSONProviderEndpoint: Endpoint { - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift index b8c93c7..64cdf47 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift @@ -10,7 +10,7 @@ import Foundation @testable import Endpoints struct PostEndpoint1: Endpoint { - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift index f29277d..a86537a 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift @@ -10,7 +10,7 @@ import Foundation @testable import Endpoints struct PostEndpoint2: Endpoint { - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift index 999b1a3..0ad6d8b 100644 --- a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift @@ -10,7 +10,7 @@ import Foundation @testable import Endpoints struct SimpleEndpoint: Endpoint { - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift index 005789d..cfde321 100644 --- a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift @@ -10,7 +10,7 @@ import Foundation @testable import Endpoints struct UserEndpoint: Endpoint { - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "hey" + \.userId, parameters: [ diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index ddce9ca..a6420fc 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -25,6 +25,7 @@ class URLSessionExtensionTests: XCTestCase { } } + @MainActor func testPublisherCreationFailure() { let publisherExpectation = expectation(description: "publisher creation failure") URLSession.shared.endpointPublisher( From 127010f71c81d87c5892db822d4a05c8d9bf2853 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 27 Nov 2024 15:45:01 -0800 Subject: [PATCH 05/24] Updated to Swift Testing --- Sources/Endpoints/EnvironmentType.swift | 4 - Tests/EndpointsTests/EndpointsTests.swift | 121 +++++++++--------- Tests/EndpointsTests/PathTemplateTests.swift | 33 +++-- .../URLSessionExtensionTests.swift | 40 +++--- 4 files changed, 98 insertions(+), 100 deletions(-) diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 797b34b..7919689 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -138,10 +138,6 @@ struct TestEndpoint: Endpoint { static let definition: Definition = .init(server: ApiServer.api, method: .get, path: "/") } -struct Testing { - -} - //public protocol EnvironmentType { // /// The baseUrl of the Environment // var baseUrl: URL { get } diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 4a15144..9b4bfa5 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -6,42 +6,47 @@ // Copyright © 2019 Velos Mobile LLC. All rights reserved. // -import XCTest +import Foundation +import Testing @testable import Endpoints -class EndpointsTests: XCTestCase { +@Suite +struct EndpointsTests { - func testBasicEndpoint() throws { + @Test + func basicEndpoint() throws { let request = try SimpleEndpoint( pathComponents: .init(name: "zac", id: "42") ).urlRequest() - XCTAssertEqual(request.url?.path, "/user/zac/42/profile") + #expect(request.url?.path == "/user/zac/42/profile") let responseData = #"{"response1": "testing"}"#.data(using: .utf8)! let response = try SimpleEndpoint.responseDecoder.decode(SimpleEndpoint.Response.self, from: responseData) - XCTAssertEqual(response.response1, "testing") + #expect(response.response1 == "testing") } - func testBasicEndpointWithCustomDecoder() throws { + @Test + func basicEndpointWithCustomDecoder() throws { let request = try JSONProviderEndpoint( body: .init(bodyValueOne: "value"), pathComponents: .init(name: "zac", id: "42") ).urlRequest() - XCTAssertEqual(request.url?.path, "/user/zac/42/profile") + #expect(request.url?.path == "/user/zac/42/profile") let bodyData = #"{"body_value_one":"value"}"#.data(using: .utf8)! - XCTAssertEqual(request.httpBody, bodyData) + #expect(request.httpBody == bodyData) let responseData = #"{"response_one": "testing"}"#.data(using: .utf8)! let response = try JSONProviderEndpoint.responseDecoder.decode(JSONProviderEndpoint.Response.self, from: responseData) - XCTAssertEqual(response.responseOne, "testing") + #expect(response.responseOne == "testing") } - func testPostEndpointWithEncoder() throws { + @Test + func postEndpointWithEncoder() throws { let date = Date() let request = try PostEndpoint1( body: .init(property1: date, property2: nil) @@ -49,16 +54,17 @@ class EndpointsTests: XCTestCase { let encodedDate = ISO8601DateFormatter().string(from: date) let bodyData = "{\"property1\":\"\(encodedDate)\"}".data(using: .utf8)! - XCTAssertEqual(request.httpBody, bodyData) + #expect(request.httpBody == bodyData) } - func testPostEndpoint() throws { + @Test + func postEndpoint() throws { let request = try PostEndpoint2( body: .init(property1: "test", property2: nil) ).urlRequest() - XCTAssertEqual(request.url?.path, "/path") - XCTAssertEqual(request.httpMethod, "POST") + #expect(request.url?.path == "/path") + #expect(request.httpMethod == "POST") } func testMultipartBodyEncoding() throws { @@ -101,16 +107,17 @@ class EndpointsTests: XCTestCase { XCTAssertTrue(bodyString.hasSuffix("--\(boundary)--\r\n")) } - func testCustomParameterEncoding() throws { + @Test + func customParameterEncoding() throws { let request = try CustomEncodingEndpoint( parameterComponents: .init(needsCustomEncoding: "++++") ).urlRequest() - XCTAssertEqual(request.url?.query, "key=%2B%2B%2B%2B") + #expect(request.url?.query == "key=%2B%2B%2B%2B") } - func testParameterEndpoint() throws { - + @Test + func parameterEndpoint() throws { let request = try UserEndpoint( pathComponents: .init(userId: "3"), parameterComponents: .init( @@ -127,39 +134,36 @@ class EndpointsTests: XCTestCase { headerComponents: .init(headerValue: "test") ).urlRequest() - XCTAssertEqual(request.httpMethod, "GET") - XCTAssertEqual(request.url?.path, "/hey/3") - XCTAssertEqual(request.url?.query, "string=test:of:%2Bthing%25asdf&hard_coded_query=true") + #expect(request.httpMethod == "GET") + #expect(request.url?.path == "/hey/3") + #expect(request.url?.query == "string=test:of:%2Bthing%25asdf&hard_coded_query=true") - XCTAssertEqual(request.value(forHTTPHeaderField: "HEADER_TYPE"), "test") - XCTAssertEqual(request.value(forHTTPHeaderField: "HARD_CODED_HEADER"), "test2") - XCTAssertEqual(request.value(forHTTPHeaderField: "Keep-Alive"), "timeout=5, max=1000") - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded") + #expect(request.value(forHTTPHeaderField: "HEADER_TYPE") == "test") + #expect(request.value(forHTTPHeaderField: "HARD_CODED_HEADER") == "test2") + #expect(request.value(forHTTPHeaderField: "Keep-Alive") == "timeout=5, max=1000") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") - XCTAssertNotNil(request.httpBody) - XCTAssertTrue( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("string=test%3Aof%3A+thing%25asdf") ?? false - ) - XCTAssertFalse( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("optional_string") ?? true - ) - XCTAssertTrue( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("double=2.3&int=42&bool_true=true&bool_false=false&time_zone=America/Los_Angeles&hard_coded_form=true") ?? false + #expect(request.httpBody != nil) + let body = String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" + + #expect(body.contains("string=test%3Aof%3A+thing%25asdf")) + #expect(!body.contains("optional_string")) + #expect( + body.contains("double=2.3&int=42&bool_true=true&bool_false=false&time_zone=America/Los_Angeles&hard_coded_form=true") ) } - func testInvalidParameter() { - XCTAssertThrowsError( + @Test + func invalidParameter() { + #expect(throws: EndpointError.self) { try InvalidEndpoint( parameterComponents: .init(nonEncodable: .value) ).urlRequest() - ) { error in - XCTAssertTrue(error is EndpointError, "error is \(type(of: error)) and not an EndpointError") } } - func testResponseSuccess() throws { - + @Test + func responseSuccess() throws { let successResponse = HTTPURLResponse(url: URL(fileURLWithPath: ""), statusCode: 200, httpVersion: nil, headerFields: nil) let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( @@ -169,15 +173,15 @@ class EndpointsTests: XCTestCase { ) guard case .success(let data) = result else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } - XCTAssertEqual(data, jsonData) + #expect(data == jsonData) } - func testResponseNetworkError() throws { - + @Test + func responseNetworkError() throws { let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( data: jsonData, @@ -186,13 +190,13 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let taskError) = result, case .internetConnectionOffline = taskError else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } } - func testResponseURLLoadError() throws { - + @Test + func responseURLLoadError() throws { let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( data: jsonData, @@ -201,13 +205,13 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let taskError) = result, case .urlLoadError = taskError else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } } - func testResponseErrorParsing() throws { - + @Test + func responseErrorParsing() throws { let failureResponse = HTTPURLResponse(url: URL(fileURLWithPath: ""), statusCode: 404, httpVersion: nil, headerFields: nil) let errorResponse = SimpleEndpoint.ErrorResponse(errorDescription: "testing") let jsonData = try JSONEncoder().encode(errorResponse) @@ -218,21 +222,22 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let error) = result else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } guard case .errorResponse(let response, let decoded) = error else { - XCTFail("Unexpected error case") + Issue.record("Unexpected error case") return } - XCTAssertEqual(response.statusCode, 404) - XCTAssertEqual(decoded, errorResponse) + #expect(response.statusCode == 404) + #expect(decoded == errorResponse) } + @Test @available(iOS 16.0, *) - func testEnvironmentsChange() throws { + func environmentsChange() throws { let existing = TestServer.environment let endpoint = SimpleEndpoint( @@ -240,13 +245,13 @@ class EndpointsTests: XCTestCase { ) TestServer.environment = .local - XCTAssertEqual(try endpoint.urlRequest().url?.host(), "local-api.velosmobile.com") + #expect(try endpoint.urlRequest().url?.host() == "local-api.velosmobile.com") TestServer.environment = .staging - XCTAssertEqual(try endpoint.urlRequest().url?.host(), "staging-api.velosmobile.com") + #expect(try endpoint.urlRequest().url?.host() == "staging-api.velosmobile.com") TestServer.environment = .production - XCTAssertEqual(try endpoint.urlRequest().url?.host(), "api.velosmobile.com") + #expect(try endpoint.urlRequest().url?.host() == "api.velosmobile.com") TestServer.environment = existing } diff --git a/Tests/EndpointsTests/PathTemplateTests.swift b/Tests/EndpointsTests/PathTemplateTests.swift index d0db458..981c1ce 100644 --- a/Tests/EndpointsTests/PathTemplateTests.swift +++ b/Tests/EndpointsTests/PathTemplateTests.swift @@ -6,7 +6,7 @@ // Copyright © 2019 Velos Mobile LLC. All rights reserved. // -import XCTest +import Testing @testable import Endpoints struct Test { @@ -19,45 +19,50 @@ struct TestOptional { let integer: Int? } -class PathTemplateTests: XCTestCase { +@Suite +struct PathTemplateTests { - func testStringInterpolation() { + @Test + func stringInterpolation() { let template1: PathTemplate = "testing/\(path: \.string)/\(path: \.integer)/other" let path = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path, "testing/first/2/other") + #expect(path == "testing/first/2/other") } - func testStringConcatenation() { + @Test + func stringConcatenation() { let template1: PathTemplate = "testing/" + \.string + \.integer let path1 = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path1, "testing/first/2") + #expect(path1 == "testing/first/2") let template2: PathTemplate = \.integer + "testing" let path2 = template2.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path2, "2/testing") + #expect(path2 == "2/testing") let template3: PathTemplate = "testing" + 3 let path3 = template3.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path3, "testing/3") + #expect(path3 == "testing/3") } - func testNoSlash() { + @Test + func noSlash() { let template1: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')" let path1 = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path1, "testing/testPath(Thing='first')") + #expect(path1 == "testing/testPath(Thing='first')") let template2: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')\(path: \.integer)" let path2 = template2.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path2, "testing/testPath(Thing='first')/2") + #expect(path2 == "testing/testPath(Thing='first')/2") let template3: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')\(path: \.integer)" let path3 = template3.path(with: TestOptional(string: "first", integer: nil)) - XCTAssertEqual(path3, "testing/testPath(Thing='first')") + #expect(path3 == "testing/testPath(Thing='first')") } - func testStringLiteral() { + @Test + func stringLiteral() { let template: PathTemplate = "testing" let path = template.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path, "testing") + #expect(path == "testing") } } diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index a6420fc..d64a403 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -6,42 +6,34 @@ // Copyright © 2021 Velos Mobile LLC. All rights reserved. // -import XCTest +import Testing +import Foundation import Combine @testable import Endpoints -class URLSessionExtensionTests: XCTestCase { - - var cancellables: Set = Set() - - func testTaskCreationFailure() { - XCTAssertThrowsError( +@Suite +struct URLSessionExtensionTests { + @Test + func taskCreationFailure() { + #expect(throws: InvalidEndpoint.TaskError.self) { try URLSession.shared.endpointTask( with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)), completion: { _ in } ) - ) { error in - XCTAssertTrue(error is InvalidEndpoint.TaskError, "error is \(type(of: error)) and not an EndpointTaskError") } } - @MainActor - func testPublisherCreationFailure() { - let publisherExpectation = expectation(description: "publisher creation failure") - URLSession.shared.endpointPublisher( + @Test + @available(iOS 15.0, *) + func publisherCreationFailure() async { + let values = URLSession.shared.endpointPublisher( with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)) - ) - .sink { completion in - guard case .failure(let error) = completion, case .endpointError = error else { - return - } + ).values - publisherExpectation.fulfill() - } receiveValue: { _ in - XCTFail() - } - .store(in: &cancellables) + await #expect(throws: EndpointTaskError.self) { + for try await _ in values { - waitForExpectations(timeout: 1) + } + } } } From 8b1da5d24d3a8d965ad179b297424701b2d19af2 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 27 Nov 2024 16:00:33 -0800 Subject: [PATCH 06/24] Added simple default environment test --- Tests/EndpointsTests/EndpointsTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 9b4bfa5..d8b63be 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -255,4 +255,10 @@ struct EndpointsTests { TestServer.environment = existing } + + @Test + @available(iOS 16.0, *) + func defaultEnvironment() throws { + #expect(TestServer.defaultEnvironment == .production) + } } From d7996b3bb9576b9408efe1ae65ebdebd8893764f Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 2 Dec 2024 16:23:34 -0800 Subject: [PATCH 07/24] Started implementing mock framework --- .../xcshareddata/xcschemes/Endpoints.xcscheme | 10 +++ Package.swift | 9 +++ Sources/Endpoints/Endpoint.swift | 2 +- .../Extensions/URLSession+Async.swift | 5 ++ .../Endpoints/Mocking/MockContinuation.swift | 35 +++++++++ Sources/Endpoints/Mocking/MockingActor.swift | 53 +++++++++++++ .../EndpointsMocking/EndpointsMocking.swift | 29 +++++++ .../EndpointsMockingTests/BasicMocking.swift | 76 +++++++++++++++++++ 8 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 Sources/Endpoints/Mocking/MockContinuation.swift create mode 100644 Sources/Endpoints/Mocking/MockingActor.swift create mode 100644 Sources/EndpointsMocking/EndpointsMocking.swift create mode 100644 Tests/EndpointsMockingTests/BasicMocking.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme index 14214dc..088a7f3 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme @@ -62,6 +62,16 @@ ReferencedContainer = "container:"> + + + + (with endpoint: T) async throws -> T.Response where T.Response: Decodable { + + if let mockResponse = try await MockingActor.shared.handlMock(for: T.self) { + return mockResponse + } + let urlRequest = try createUrlRequest(for: endpoint) let result: (data: Data, response: URLResponse) diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift new file mode 100644 index 0000000..a27187c --- /dev/null +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -0,0 +1,35 @@ +// +// MockContinuation.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Foundation + +public enum MockAction: Sendable { + case none + case `return`(Value) + case fail(ErrorResponse) + case `throw`(EndpointTaskError) +} + +public class MockContinuation where T.Response: Sendable { + var action: MockAction + + init(action: MockAction = .none) { + self.action = action + } + + public func resume(returning value: T.Response) { + action = .return(value) + } + + public func resume(failingWith error: T.ErrorResponse) { + action = .fail(error) + } + + public func resume(throwing error: EndpointTaskError) where T.ErrorResponse: Sendable { + action = .throw(error) + } +} diff --git a/Sources/Endpoints/Mocking/MockingActor.swift b/Sources/Endpoints/Mocking/MockingActor.swift new file mode 100644 index 0000000..c7b4da5 --- /dev/null +++ b/Sources/Endpoints/Mocking/MockingActor.swift @@ -0,0 +1,53 @@ +// +// MockingActor.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Foundation + +struct ToReturnWrapper: Sendable { + private let toReturn: @Sendable (Any) async -> Void + init(_ toReturn: @Sendable @escaping (MockContinuation) async -> Void) { + self.toReturn = { @Sendable (value) in + await toReturn(value as! MockContinuation) + } + } + + func toReturn(for: T.Type) -> ((MockContinuation) async -> Void) { + return { continuation in + await toReturn(continuation) + } + } +} + +actor MockingActor: Sendable { + + static let shared = MockingActor() + + @TaskLocal + static private var current: ToReturnWrapper? + + func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { + guard let current = Self.current else { return .none } + let continuation = MockContinuation() + await current.toReturn(for: T.self)(continuation) + switch continuation.action { + case .none: + return nil + case .return(let value): + return value + case .fail(let errorResponse): + throw T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse) + case .throw(let error): + throw error + } + } + + func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @escaping () async throws -> R) async rethrows -> R { + try await Self.$current.withValue(ToReturnWrapper(body)) { + try await test() + } + } +} diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift new file mode 100644 index 0000000..4d7432f --- /dev/null +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -0,0 +1,29 @@ +// +// File.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Foundation +import XCTest +@testable import Endpoints + +public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { + return try await MockingActor.shared.withMock(T.self, body, test: test) +} + +public func withMock(_ ofType: T.Type, action: MockAction, test: @Sendable @escaping () async throws -> R) async rethrows -> R { + return try await MockingActor.shared.withMock(T.self, { continuation in + switch action { + case .none: + return + case .fail(let errorResponse): + continuation.resume(failingWith: errorResponse) + case .return(let value): + continuation.resume(returning: value) + case .throw(let error): + continuation.resume(throwing: error) + } + }, test: test) +} diff --git a/Tests/EndpointsMockingTests/BasicMocking.swift b/Tests/EndpointsMockingTests/BasicMocking.swift new file mode 100644 index 0000000..b0444f6 --- /dev/null +++ b/Tests/EndpointsMockingTests/BasicMocking.swift @@ -0,0 +1,76 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking + +struct TestServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .production: URL(string: "https://api.velosmobile.com")! + ] + } + + static var defaultEnvironment: Environments { .production } +} + +struct SimpleEndpoint: Endpoint { + static let definition: Definition = Definition( + method: .get, + path: "user/\(path: \.name)/\(path: \.id)/profile" + ) + + struct Response: Codable { + let response1: String + } + + struct ErrorResponse: Codable, Equatable { + let errorDescription: String + } + + struct PathComponents { + let name: String + let id: String + } + + let pathComponents: PathComponents +} + +@Suite("Basic Mocking") +struct BasicMocking { + @Test func basic() async throws { + await #expect(throws: SimpleEndpoint.TaskError.self) { + try await withMock(SimpleEndpoint.self) { continuation in + continuation.resume(throwing: .internetConnectionOffline) + } test: { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + _ = try await URLSession.shared.response(with: simple) + } + } + } + + @Test func basicResponse() async throws { + try await withMock(SimpleEndpoint.self) { continuation in + // possibly load mocks async from json + continuation.resume(returning: .init(response1: "test")) + } test: { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await URLSession.shared.response(with: simple) + #expect(response.response1 == "test") + } + } + + @Test func basicResponseInline() async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await URLSession.shared.response(with: simple) + #expect(response.response1 == "test") + } + } +} From e20a9d566b8c6e71db3ef8c879d1405216f37f18 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 3 Dec 2024 09:48:15 -0800 Subject: [PATCH 08/24] Implementation for Combine --- .../Extensions/URLSession+Async.swift | 19 +++++- .../Extensions/URLSession+Combine.swift | 62 +++++++++++++++++-- .../{MockingActor.swift => Mocking.swift} | 39 +++++++++++- .../EndpointsMocking/EndpointsMocking.swift | 4 +- .../CombineMocking.swift | 36 +++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) rename Sources/Endpoints/Mocking/{MockingActor.swift => Mocking.swift} (53%) create mode 100644 Tests/EndpointsMockingTests/CombineMocking.swift diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 5c390d4..0ec0ee3 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -20,6 +20,12 @@ public extension URLSession { func response(with endpoint: T) async throws where T.Response == Void { let urlRequest = try createUrlRequest(for: endpoint) + #if DEBUG + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif + let result: (data: Data, response: URLResponse) do { result = try await data(for: urlRequest) @@ -37,6 +43,12 @@ public extension URLSession { func response(with endpoint: T) async throws -> T.Response where T.Response == Data { let urlRequest = try createUrlRequest(for: endpoint) + #if DEBUG + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif + let result: (data: Data, response: URLResponse) do { result = try await data(for: urlRequest) @@ -52,12 +64,13 @@ public extension URLSession { } func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { + let urlRequest = try createUrlRequest(for: endpoint) - if let mockResponse = try await MockingActor.shared.handlMock(for: T.self) { + #if DEBUG + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { return mockResponse } - - let urlRequest = try createUrlRequest(for: endpoint) + #endif let result: (data: Data, response: URLResponse) do { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 5456b72..d5ceb17 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -28,7 +28,7 @@ public extension URLSession { .eraseToAnyPublisher() } - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -43,7 +43,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - .eraseToAnyPublisher() + + #if DEBUG + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load + .eraseToAnyPublisher() + #endif } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value of `Data`. @@ -61,7 +78,7 @@ public extension URLSession { .eraseToAnyPublisher() } - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -76,7 +93,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - .eraseToAnyPublisher() + + #if DEBUG + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load + .eraseToAnyPublisher() + #endif } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value which is `Decodable`. @@ -93,8 +127,9 @@ public extension URLSession { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() } + - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -114,7 +149,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } + + #if DEBUG + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load .eraseToAnyPublisher() + #endif } } diff --git a/Sources/Endpoints/Mocking/MockingActor.swift b/Sources/Endpoints/Mocking/Mocking.swift similarity index 53% rename from Sources/Endpoints/Mocking/MockingActor.swift rename to Sources/Endpoints/Mocking/Mocking.swift index c7b4da5..1eff87b 100644 --- a/Sources/Endpoints/Mocking/MockingActor.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -1,5 +1,5 @@ // -// MockingActor.swift +// Mocking.swift // Endpoints // // Created by Zac White on 11/30/24. @@ -22,9 +22,9 @@ struct ToReturnWrapper: Sendable { } } -actor MockingActor: Sendable { +struct Mocking { - static let shared = MockingActor() + static let shared = Mocking() @TaskLocal static private var current: ToReturnWrapper? @@ -51,3 +51,36 @@ actor MockingActor: Sendable { } } } + +@preconcurrency import Combine +extension Mocking { + func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { + guard let current = Self.current else { + return Just(nil) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } + + let passthrough = PassthroughSubject() + + Task.detached { @Sendable () async -> Void in + let continuation = MockContinuation() + await current.toReturn(for: T.self)(continuation) + switch continuation.action { + case .none: + passthrough.send(nil) + passthrough.send(completion: .finished) + case .return(let value): + passthrough.send(value) + passthrough.send(completion: .finished) + case .fail(let errorResponse): + passthrough.send(completion: .failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + passthrough.send(completion: .failure(error)) + } + } + + return passthrough + .eraseToAnyPublisher() + } +} diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index 4d7432f..35d6c07 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -10,11 +10,11 @@ import XCTest @testable import Endpoints public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { - return try await MockingActor.shared.withMock(T.self, body, test: test) + return try await Mocking.shared.withMock(T.self, body, test: test) } public func withMock(_ ofType: T.Type, action: MockAction, test: @Sendable @escaping () async throws -> R) async rethrows -> R { - return try await MockingActor.shared.withMock(T.self, { continuation in + return try await Mocking.shared.withMock(T.self, { continuation in switch action { case .none: return diff --git a/Tests/EndpointsMockingTests/CombineMocking.swift b/Tests/EndpointsMockingTests/CombineMocking.swift new file mode 100644 index 0000000..b2b36ca --- /dev/null +++ b/Tests/EndpointsMockingTests/CombineMocking.swift @@ -0,0 +1,36 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking +import Combine + +@Suite("Combine Mocking") +struct CombineMocking { + + @available(iOS 15.0, *) + @Test func basicCombineInline() async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + for try await response in URLSession.shared.endpointPublisher(with: simple).values { + #expect(response.response1 == "test") + } + } + } + + @available(iOS 15.0, *) + @Test func basicCombineInline2() async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test2"))) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + for try await response in URLSession.shared.endpointPublisher(with: simple).values { + #expect(response.response1 == "test2") + } + } + } +} From 9afc4eaaf302bc519aa572293b76553f6f4d12b4 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 3 Oct 2025 08:10:44 -0700 Subject: [PATCH 09/24] Added URLSession data task mocking support --- Sources/Endpoints/Endpoints.docc/Examples.md | 23 +++-- .../Extensions/URLSession+Endpoints.swift | 68 +++++++++++++- .../Endpoints/Mocking/MockContinuation.swift | 4 + Sources/Endpoints/Mocking/Mocking.swift | 22 ++++- .../Mocking/URLSessionTask+Swizzling.swift | 36 +++++++ ...ing.swift => AsyncURLSessionMocking.swift} | 51 +++++++++- .../ClosureURLSessionMocking.swift | 48 ++++++++++ .../CombineMocking.swift | 36 ------- .../CombineURLSessionMocking.swift | 94 +++++++++++++++++++ .../Testing+Helpers.swift | 11 +++ .../Endpoints/Environment.swift | 6 +- 11 files changed, 340 insertions(+), 59 deletions(-) create mode 100644 Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift rename Tests/EndpointsMockingTests/{BasicMocking.swift => AsyncURLSessionMocking.swift} (51%) create mode 100644 Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift delete mode 100644 Tests/EndpointsMockingTests/CombineMocking.swift create mode 100644 Tests/EndpointsMockingTests/CombineURLSessionMocking.swift create mode 100644 Tests/EndpointsMockingTests/Testing+Helpers.swift diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 5851df7..48d32ae 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -5,7 +5,7 @@ #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -34,7 +34,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.userId)/resource" ) @@ -74,7 +74,7 @@ extension Header { } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "path/to/resource", headers: [ @@ -114,7 +114,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(headerValu #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path/to/resource" ) @@ -148,7 +148,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .ini #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path/to/resource", parameters: [ @@ -189,7 +189,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path/to/resource", parameters: [ @@ -236,7 +236,7 @@ https://production.mydomain.com/path/to/resource?keyString=value&keyInt=42&key=h #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .delete, path: "path/to/resource" ) @@ -262,7 +262,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -298,7 +298,7 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "path/to/resource" ) @@ -337,14 +337,13 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .ini #### Endpoint and Definition ```swift - struct ServerError: Decodable { let code: Int let message: String } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -384,7 +383,7 @@ struct ServerError: Decodable { } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index fdc983a..4cf074f 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -46,9 +46,31 @@ public extension URLSession { let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error).map { _ in }) } + + #if DEBUG + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + + return task } /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. @@ -64,9 +86,29 @@ public extension URLSession { let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error)) } + #if DEBUG + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + return task } /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. @@ -82,7 +124,7 @@ public extension URLSession { let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in let response = T.definition.response(data: data, response: response, error: error) switch response { case .success(let data): @@ -98,6 +140,26 @@ public extension URLSession { completion(.failure(failure)) } } + #if DEBUG + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + return task } func createUrlRequest(for endpoint: T) throws -> URLRequest { diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift index a27187c..648ada9 100644 --- a/Sources/Endpoints/Mocking/MockContinuation.swift +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -17,6 +17,10 @@ public enum MockAction: Sendable { public class MockContinuation where T.Response: Sendable { var action: MockAction + init(_ type: T.Type) { + self.action = .none + } + init(action: MockAction = .none) { self.action = action } diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 1eff87b..4b451e4 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -29,6 +29,10 @@ struct Mocking { @TaskLocal static private var current: ToReturnWrapper? + init() { + URLSessionTask.classInit + } + func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { guard let current = Self.current else { return .none } let continuation = MockContinuation() @@ -52,6 +56,22 @@ struct Mocking { } } +extension Mocking { + func shouldHandleMock(for endpointsOfType: T.Type) -> Bool { + Self.current != nil + } + + func actionForMock(for endpointsOfType: T.Type) async -> MockAction? { + guard let current = Self.current else { + return nil + } + + let continuation = MockContinuation() + await current.toReturn(for: T.self)(continuation) + return continuation.action + } +} + @preconcurrency import Combine extension Mocking { func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { @@ -63,7 +83,7 @@ extension Mocking { let passthrough = PassthroughSubject() - Task.detached { @Sendable () async -> Void in + Task { @Sendable () async -> Void in let continuation = MockContinuation() await current.toReturn(for: T.self)(continuation) switch continuation.action { diff --git a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift new file mode 100644 index 0000000..9139928 --- /dev/null +++ b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift @@ -0,0 +1,36 @@ +// +// URLSessionTask+Swizzling.swift +// Endpoints +// +// Created by Zac White on 12/4/24. +// + +import Foundation + +#if DEBUG +nonisolated(unsafe) private var resumeOverrideKey: UInt8 = 0 +extension URLSessionTask { + var resumeOverride: (() -> Void)? { + get { + return (objc_getAssociatedObject(self, &resumeOverrideKey) as? () -> Void) ?? nil + } + set { + objc_setAssociatedObject(self, &resumeOverrideKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + static let classInit: Void = { + guard let originalMethod = class_getInstanceMethod(URLSessionTask.self, #selector(resume)), + let swizzledMethod = class_getInstanceMethod(URLSessionTask.self, #selector(swizzled_resume)) else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_resume() { + if let resumeOverride { + resumeOverride() + } else { + swizzled_resume() + } + } +} +#endif diff --git a/Tests/EndpointsMockingTests/BasicMocking.swift b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift similarity index 51% rename from Tests/EndpointsMockingTests/BasicMocking.swift rename to Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift index b0444f6..8801343 100644 --- a/Tests/EndpointsMockingTests/BasicMocking.swift +++ b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift @@ -1,5 +1,5 @@ // -// BasicMocking.swift +// AsyncURLSessionMocking.swift // Endpoints // // Created by Zac White on 11/30/24. @@ -42,9 +42,9 @@ struct SimpleEndpoint: Endpoint { let pathComponents: PathComponents } -@Suite("Basic Mocking") -struct BasicMocking { - @Test func basic() async throws { +@Suite("Async URLSession Mocking") +struct AsyncURLSessionMocking { + @Test func basicThrow() async throws { await #expect(throws: SimpleEndpoint.TaskError.self) { try await withMock(SimpleEndpoint.self) { continuation in continuation.resume(throwing: .internetConnectionOffline) @@ -55,6 +55,49 @@ struct BasicMocking { } } + @Test func basicThrowInline() async throws { + await #expect(throws: SimpleEndpoint.TaskError.self) { + try await withMock(SimpleEndpoint.self, action: .throw(.internetConnectionOffline)) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + _ = try await URLSession.shared.response(with: simple) + } + } + } + + @Test func basicFail() async throws { + try await withMock(SimpleEndpoint.self) { continuation in + continuation.resume(failingWith: .init(errorDescription: "error")) + } test: { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + do { + _ = try await URLSession.shared.response(with: simple) + } catch { + let error = try #require(error as? SimpleEndpoint.TaskError) + if case .errorResponse(_, let response) = error { + #expect(response.errorDescription == "error") + } else { + #expect(Bool(false), "unexpected error \(error)") + } + } + } + } + + @Test func basicFailInline() async throws { + try await withMock(SimpleEndpoint.self, action: .fail(.init(errorDescription: "error"))) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + do { + _ = try await URLSession.shared.response(with: simple) + } catch { + let error = try #require(error as? SimpleEndpoint.TaskError) + if case .errorResponse(_, let response) = error { + #expect(response.errorDescription == "error") + } else { + #expect(Bool(false), "unexpected error \(error)") + } + } + } + } + @Test func basicResponse() async throws { try await withMock(SimpleEndpoint.self) { continuation in // possibly load mocks async from json diff --git a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift new file mode 100644 index 0000000..db1bee4 --- /dev/null +++ b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift @@ -0,0 +1,48 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking +import Combine + +@Suite("Closure URLSession Mocking") +struct ClosureURLSessionMocking { + + @Test func inline() async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { + try await wait { continuation in + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let task = try URLSession.shared.endpointTask(with: simple) { result in + #expect(throws: Never.self) { + let response = try result.get() + #expect(response.response1 == "test") + } + continuation.resume() + } + task.resume() + } + } + } + + @Test func inline2() async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test2"))) { + try await wait { continuation in + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let task = try URLSession.shared.endpointTask(with: simple) { result in + #expect(throws: Never.self) { + let response = try result.get() + #expect(response.response1 == "test2") + } + continuation.resume() + } + task.resume() + } + } + } +} diff --git a/Tests/EndpointsMockingTests/CombineMocking.swift b/Tests/EndpointsMockingTests/CombineMocking.swift deleted file mode 100644 index b2b36ca..0000000 --- a/Tests/EndpointsMockingTests/CombineMocking.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// BasicMocking.swift -// Endpoints -// -// Created by Zac White on 11/30/24. -// - -import Testing -import Endpoints -import Foundation -@testable import EndpointsMocking -import Combine - -@Suite("Combine Mocking") -struct CombineMocking { - - @available(iOS 15.0, *) - @Test func basicCombineInline() async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) - for try await response in URLSession.shared.endpointPublisher(with: simple).values { - #expect(response.response1 == "test") - } - } - } - - @available(iOS 15.0, *) - @Test func basicCombineInline2() async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test2"))) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) - for try await response in URLSession.shared.endpointPublisher(with: simple).values { - #expect(response.response1 == "test2") - } - } - } -} diff --git a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift new file mode 100644 index 0000000..620c644 --- /dev/null +++ b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift @@ -0,0 +1,94 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking +@preconcurrency import Combine + +//extension AnyPublisher where Output: Sendable { +// var awaitFirst: Output { +// get async throws { +// try await withCheckedThrowingContinuation { continuation in +// Task { +// let cancellable = first() +// .print("first") +// .sink { completion in +// switch completion { +// case .failure(let error): +// continuation.resume(throwing: error) +// case .finished: +// continuation.resume(throwing: CancellationError()) +// } +// } receiveValue: { value in +// continuation.resume(returning: value) +// } +// +// // Hold reference to cancellable until task is cancelled or value received +// await Task.yield() +// _ = cancellable +// } +// } +// } +// } +//} + +@available(iOS 15.0, *) +extension AnyPublisher where Output: Sendable { + var awaitFirst: Output { + get async throws { + let waiter = PublisherWaiter(publisher: self) + return try await waiter.wait() + } + } +} + + +enum WaiterError: Error { + case noElement +} + +import SwiftUI +final class PublisherWaiter { + let publisher: P + @Published var value: P.Output? + + init(publisher: P) { + self.publisher = publisher + } + + @available(iOS 15.0, *) + func wait() async throws -> P.Output { + publisher + .assertNoFailure() + .first() + .map { $0 } + .print("asdf") + .assign(to: &$value) + + for await value in $value.values.dropFirst() { + return value! + } + + throw WaiterError.noElement + } +} + +@Suite("Combine URLSession Mocking") +struct CombineURLSessionMocking { + + @available(iOS 15.0, *) + @Test(arguments: ["test", "test2"]) + func combineInline(response: String) async throws { + try await withMock(SimpleEndpoint.self, action: .return(.init(response1: response))) { + let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await URLSession.shared.endpointPublisher(with: simple).awaitFirst + #expect(response.response1 == "test") + } + } +} diff --git a/Tests/EndpointsMockingTests/Testing+Helpers.swift b/Tests/EndpointsMockingTests/Testing+Helpers.swift new file mode 100644 index 0000000..92f1bd8 --- /dev/null +++ b/Tests/EndpointsMockingTests/Testing+Helpers.swift @@ -0,0 +1,11 @@ +import Foundation + +public func wait(_ body: (CheckedContinuation) throws -> Void) async throws -> R { + return try await withCheckedThrowingContinuation { continuation in + do { + try body(continuation) + } catch { + continuation.resume(throwing: error) + } + } +} diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index f304681..5ae219e 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -18,9 +18,9 @@ struct MyServer: ServerDefinition { var baseUrls: [Environments: URL] { return [ - .local: URL(string: "https://api.velos.com")!, - .staging: URL(string: "https://api.velos.com")!, - .production: URL(string: "https://api.velos.com")! + .local: URL(string: "https://api.velos.me")!, + .staging: URL(string: "https://api.velos.me")!, + .production: URL(string: "https://api.velos.me")! ] } From 2a252caed82b9f941513719a8ce04c2870f06802 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 17 Oct 2025 13:31:51 -0700 Subject: [PATCH 10/24] Updated Endpoint to conform to Sendable --- Sources/Endpoints/Endpoint.swift | 2 +- Sources/Endpoints/Mocking/Mocking.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index ebb9d04..5dd5024 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -57,7 +57,7 @@ public protocol DecoderType { extension JSONDecoder: DecoderType { } -public protocol Endpoint { +public protocol Endpoint: Sendable { associatedtype Server: ServerDefinition diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 4b451e4..4a7e77e 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -10,7 +10,7 @@ import Foundation struct ToReturnWrapper: Sendable { private let toReturn: @Sendable (Any) async -> Void init(_ toReturn: @Sendable @escaping (MockContinuation) async -> Void) { - self.toReturn = { @Sendable (value) in + self.toReturn = { value in await toReturn(value as! MockContinuation) } } From 588e08d362387e073f2f37e41a5ff86e528e1285 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 14:50:00 -0800 Subject: [PATCH 11/24] Followup improvements after rebase --- Sources/Endpoints/MultipartFormEncoder.swift | 16 ++--- .../EndpointsMocking/EndpointsMocking.swift | 1 - .../CombineURLSessionMocking.swift | 46 +++---------- .../Endpoints/MultipartUploadEndpoint.swift | 2 +- Tests/EndpointsTests/EndpointsTests.swift | 43 ++++++------ .../MultipartFormEncoderTests.swift | 66 ++++++++++--------- 6 files changed, 71 insertions(+), 103 deletions(-) diff --git a/Sources/Endpoints/MultipartFormEncoder.swift b/Sources/Endpoints/MultipartFormEncoder.swift index 0418caa..3c7dfe6 100644 --- a/Sources/Endpoints/MultipartFormEncoder.swift +++ b/Sources/Endpoints/MultipartFormEncoder.swift @@ -91,7 +91,7 @@ public final class MultipartFormEncoder: EncoderType { } /// Represents a binary field in a multipart payload. -public struct MultipartFormFile: Encodable { +public struct MultipartFormFile: Encodable, Sendable { public let data: Data public let fileName: String public let contentType: String @@ -123,7 +123,7 @@ fileprivate protocol MultipartFormJSONProtocol { } /// Wraps an ``Encodable`` value so it is embedded as a JSON part within a multipart payload. -public struct MultipartFormJSON: Encodable { +public struct MultipartFormJSON: Encodable, Sendable { public let value: Value fileprivate let jsonEncoder: JSONEncoder public let fileName: String? @@ -326,7 +326,9 @@ final class _MultipartFormDataEncoder: Encoder { case .millisecondsSince1970: return String(Int(date.timeIntervalSince1970 * 1000)) case .iso8601: - return iso8601Formatter.string(from: date) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) case .formatted(let formatter): return formatter.string(from: date) case .custom(let block): @@ -584,11 +586,3 @@ final class _MultipartSuperEncoder: Encoder { parent.singleValueContainer(at: codingPath) } } - -// MARK: - Date helpers - -private let iso8601Formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter -}() diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index 35d6c07..ea0a493 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -6,7 +6,6 @@ // import Foundation -import XCTest @testable import Endpoints public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { diff --git a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift index 620c644..7388673 100644 --- a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift @@ -11,33 +11,6 @@ import Foundation @testable import EndpointsMocking @preconcurrency import Combine -//extension AnyPublisher where Output: Sendable { -// var awaitFirst: Output { -// get async throws { -// try await withCheckedThrowingContinuation { continuation in -// Task { -// let cancellable = first() -// .print("first") -// .sink { completion in -// switch completion { -// case .failure(let error): -// continuation.resume(throwing: error) -// case .finished: -// continuation.resume(throwing: CancellationError()) -// } -// } receiveValue: { value in -// continuation.resume(returning: value) -// } -// -// // Hold reference to cancellable until task is cancelled or value received -// await Task.yield() -// _ = cancellable -// } -// } -// } -// } -//} - @available(iOS 15.0, *) extension AnyPublisher where Output: Sendable { var awaitFirst: Output { @@ -53,10 +26,8 @@ enum WaiterError: Error { case noElement } -import SwiftUI final class PublisherWaiter { let publisher: P - @Published var value: P.Output? init(publisher: P) { self.publisher = publisher @@ -64,18 +35,17 @@ final class PublisherWaiter { @available(iOS 15.0, *) func wait() async throws -> P.Output { - publisher + var iterator = publisher .assertNoFailure() .first() - .map { $0 } - .print("asdf") - .assign(to: &$value) + .values + .makeAsyncIterator() - for await value in $value.values.dropFirst() { - return value! + guard let value = await iterator.next() else { + throw WaiterError.noElement } - throw WaiterError.noElement + return value } } @@ -87,8 +57,8 @@ struct CombineURLSessionMocking { func combineInline(response: String) async throws { try await withMock(SimpleEndpoint.self, action: .return(.init(response1: response))) { let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) - let response = try await URLSession.shared.endpointPublisher(with: simple).awaitFirst - #expect(response.response1 == "test") + let endpointResponse = try await URLSession.shared.endpointPublisher(with: simple).awaitFirst + #expect(endpointResponse.response1 == response) } } } diff --git a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift index af2daf6..1bb4742 100644 --- a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift @@ -2,7 +2,7 @@ import Foundation @testable import Endpoints struct MultipartUploadEndpoint: Endpoint { - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .post, path: "upload" ) diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index d8b63be..db99736 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -67,7 +67,8 @@ struct EndpointsTests { #expect(request.httpMethod == "POST") } - func testMultipartBodyEncoding() throws { + @Test + func multipartBodyEncoding() throws { let fileData = Data("hello world".utf8) let endpoint = MultipartUploadEndpoint( body: .init( @@ -80,31 +81,31 @@ struct EndpointsTests { ) ) - let request = try endpoint.urlRequest(in: Environment.test) + let request = try endpoint.urlRequest() - let contentType = try XCTUnwrap(request.value(forHTTPHeaderField: Header.contentType.name)) - XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary=")) + let contentType = try #require(request.value(forHTTPHeaderField: Header.contentType.name)) + #expect(contentType.hasPrefix("multipart/form-data; boundary=")) let boundaryComponents = contentType.components(separatedBy: "boundary=") - XCTAssertEqual(boundaryComponents.count, 2) + #expect(boundaryComponents.count == 2) let boundary = boundaryComponents[1] - let bodyData = try XCTUnwrap(request.httpBody) - let bodyString = try XCTUnwrap(String(data: bodyData, encoding: .utf8)) - - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"description\"")) - XCTAssertTrue(bodyString.contains("Test description")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"tags[0]\"")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"tags[1]\"")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"greeting.txt\"")) - XCTAssertTrue(bodyString.contains("Content-Type: text/plain")) - XCTAssertTrue(bodyString.contains("hello world")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"metadata\"")) - XCTAssertFalse(bodyString.contains("name=\"metadata\"; filename=")) - XCTAssertTrue(bodyString.contains("Content-Type: application/json")) - XCTAssertTrue(bodyString.contains("\"owner\":\"zac\"")) - XCTAssertTrue(bodyString.contains("\"priority\":1")) - XCTAssertTrue(bodyString.hasSuffix("--\(boundary)--\r\n")) + let bodyData = try #require(request.httpBody) + let bodyString = try #require(String(data: bodyData, encoding: .utf8)) + + #expect(bodyString.contains("Content-Disposition: form-data; name=\"description\"")) + #expect(bodyString.contains("Test description")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"tags[0]\"")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"tags[1]\"")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"greeting.txt\"")) + #expect(bodyString.contains("Content-Type: text/plain")) + #expect(bodyString.contains("hello world")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"metadata\"")) + #expect(!bodyString.contains("name=\"metadata\"; filename=")) + #expect(bodyString.contains("Content-Type: application/json")) + #expect(bodyString.contains("\"owner\":\"zac\"")) + #expect(bodyString.contains("\"priority\":1")) + #expect(bodyString.hasSuffix("--\(boundary)--\r\n")) } @Test diff --git a/Tests/EndpointsTests/MultipartFormEncoderTests.swift b/Tests/EndpointsTests/MultipartFormEncoderTests.swift index a783ca3..c7f94c6 100644 --- a/Tests/EndpointsTests/MultipartFormEncoderTests.swift +++ b/Tests/EndpointsTests/MultipartFormEncoderTests.swift @@ -1,9 +1,12 @@ -import XCTest +import Foundation +import Testing @testable import Endpoints -final class MultipartFormEncoderTests: XCTestCase { +@Suite +struct MultipartFormEncoderTests { - func testEncodesMixedValues() throws { + @Test + func multipartEncodesMixedValues() throws { struct Nested: Encodable { let flag: Bool let count: Int @@ -48,7 +51,7 @@ final class MultipartFormEncoderTests: XCTestCase { ) let data = try encoder.encode(payload) - let body = try XCTUnwrap(String(data: data, encoding: .utf8)) + let body = try #require(String(data: data, encoding: .utf8)) func part(named name: String) -> String? { let marker = "Content-Disposition: form-data; name=\"\(name)\"" @@ -61,43 +64,44 @@ final class MultipartFormEncoderTests: XCTestCase { return String(body[partStart...]) } - XCTAssertTrue(body.contains("--Boundary-123--"), "missing closing boundary") + #expect(body.contains("--Boundary-123--"), "missing closing boundary") - let titlePart = try XCTUnwrap(part(named: "title")) - XCTAssertTrue(titlePart.contains("Example"), "missing title value") + let titlePart = try #require(part(named: "title")) + #expect(titlePart.contains("Example"), "missing title value") - let nestedFlagPart = try XCTUnwrap(part(named: "nested[flag]")) - XCTAssertTrue(nestedFlagPart.contains("true"), "missing nested flag value") + let nestedFlagPart = try #require(part(named: "nested[flag]")) + #expect(nestedFlagPart.contains("true"), "missing nested flag value") - let nestedCountPart = try XCTUnwrap(part(named: "nested[count]")) - XCTAssertTrue(nestedCountPart.contains("7"), "missing nested count value") + let nestedCountPart = try #require(part(named: "nested[count]")) + #expect(nestedCountPart.contains("7"), "missing nested count value") - let list0Part = try XCTUnwrap(part(named: "list[0]")) - XCTAssertTrue(list0Part.contains("first"), "missing list[0] value") + let list0Part = try #require(part(named: "list[0]")) + #expect(list0Part.contains("first"), "missing list[0] value") - let list1Part = try XCTUnwrap(part(named: "list[1]")) - XCTAssertTrue(list1Part.contains("second"), "missing list[1] value") + let list1Part = try #require(part(named: "list[1]")) + #expect(list1Part.contains("second"), "missing list[1] value") - let filePart = try XCTUnwrap(part(named: "file")) - XCTAssertTrue(filePart.contains("filename=\"binary.dat\""), "missing file filename") - XCTAssertTrue(filePart.contains("Content-Type: application/octet-stream"), "missing file content type") - XCTAssertNotNil(data.range(of: Data([0x01, 0x02, 0x03])), "missing file payload") + let filePart = try #require(part(named: "file")) + #expect(filePart.contains("filename=\"binary.dat\""), "missing file filename") + #expect(filePart.contains("Content-Type: application/octet-stream"), "missing file content type") + #expect(data.range(of: Data([0x01, 0x02, 0x03])) != nil, "missing file payload") - let metadataPart = try XCTUnwrap(part(named: "metadata")) - XCTAssertFalse(metadataPart.contains("filename="), "metadata unexpectedly has filename") - XCTAssertTrue(metadataPart.contains("Content-Type: application/json"), "metadata missing content type") - XCTAssertTrue(metadataPart.contains("\"author\":\"zac\""), "metadata missing author") - XCTAssertTrue(metadataPart.contains("\"version\":2"), "metadata missing version") + let metadataPart = try #require(part(named: "metadata")) + #expect(!metadataPart.contains("filename="), "metadata unexpectedly has filename") + #expect(metadataPart.contains("Content-Type: application/json"), "metadata missing content type") + #expect(metadataPart.contains("\"author\":\"zac\""), "metadata missing author") + #expect(metadataPart.contains("\"version\":2"), "metadata missing version") - let configPart = try XCTUnwrap(part(named: "config")) - XCTAssertTrue(configPart.contains("filename=\"config.json\""), "config missing filename") - XCTAssertTrue(configPart.contains("Content-Type: application/json"), "config missing content type") - XCTAssertTrue(configPart.contains("\"mode\":\"debug\""), "config missing payload") + let configPart = try #require(part(named: "config")) + #expect(configPart.contains("filename=\"config.json\""), "config missing filename") + #expect(configPart.contains("Content-Type: application/json"), "config missing content type") + #expect(configPart.contains("\"mode\":\"debug\""), "config missing payload") } - func testContentTypeProvidesBoundary() { + @Test + func multipartContentTypeProvidesBoundary() { let encoder = MultipartFormEncoder(boundary: "Boundary-XYZ") - XCTAssertEqual(type(of: encoder).contentType, "multipart/form-data") - XCTAssertEqual(encoder.contentType, "multipart/form-data; boundary=Boundary-XYZ") + #expect(type(of: encoder).contentType == "multipart/form-data") + #expect(encoder.contentType == "multipart/form-data; boundary=Boundary-XYZ") } } From e7e5e9aac85e6fa62e173453081a4bb9839261dc Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 15:41:31 -0800 Subject: [PATCH 12/24] Mocking fixes --- Sources/Endpoints/Mocking/Mocking.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 4a7e77e..48f1a0c 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -73,6 +73,7 @@ extension Mocking { } @preconcurrency import Combine + extension Mocking { func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { guard let current = Self.current else { @@ -81,26 +82,24 @@ extension Mocking { .eraseToAnyPublisher() } - let passthrough = PassthroughSubject() + let subject = CurrentValueSubject(nil) - Task { @Sendable () async -> Void in + Task { let continuation = MockContinuation() await current.toReturn(for: T.self)(continuation) switch continuation.action { case .none: - passthrough.send(nil) - passthrough.send(completion: .finished) + subject.send(nil) case .return(let value): - passthrough.send(value) - passthrough.send(completion: .finished) + subject.send(value) case .fail(let errorResponse): - passthrough.send(completion: .failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + subject.send(completion: .failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) case .throw(let error): - passthrough.send(completion: .failure(error)) + subject.send(completion: .failure(error)) } } - return passthrough + return subject .eraseToAnyPublisher() } } From 9a79da7a67a1848f4834ec565998255e60439518 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 16:03:54 -0800 Subject: [PATCH 13/24] Tweaked environment/server setup --- Sources/Endpoints/Endpoint.swift | 13 ++-- Sources/Endpoints/EnvironmentType.swift | 59 ++++++++++++++++++- .../AsyncURLSessionMocking.swift | 40 +++++++------ .../ClosureURLSessionMocking.swift | 8 +-- .../CombineURLSessionMocking.swift | 4 +- .../Endpoints/CustomEncodingEndpoint.swift | 4 +- .../Endpoints/InvalidEndpoint.swift | 4 +- .../Endpoints/JSONProviderEndpoint.swift | 3 +- .../Endpoints/MultipartUploadEndpoint.swift | 4 +- .../Endpoints/PostEndpoint1.swift | 4 +- .../Endpoints/PostEndpoint2.swift | 4 +- .../Endpoints/SimpleEndpoint.swift | 4 +- .../Endpoints/UserEndpoint.swift | 4 +- 13 files changed, 115 insertions(+), 40 deletions(-) diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 5dd5024..c7e6883 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -59,7 +59,7 @@ extension JSONDecoder: DecoderType { } public protocol Endpoint: Sendable { - associatedtype Server: ServerDefinition + associatedtype Server: ServerDefinition = GenericServer /// The response type received from the server. /// @@ -128,7 +128,7 @@ public protocol Endpoint: Sendable { associatedtype ResponseDecoder: DecoderType = JSONDecoder /// A ``Definition`` which pieces together all the components defined in the endpoint. - static var definition: Definition { get } + static var definition: Definition { get } /// The instance of the associated `Body` type. Must be `Encodable`. var body: Body { get } @@ -200,10 +200,10 @@ public enum QueryEncodingStrategy { case custom((URLQueryItem) -> (String, String?)?) } -public struct Definition: Sendable { +public struct Definition: Sendable { /// The server this endpoints will use - public let server: Server + public let server: T.Server /// The HTTP method of the ``Endpoint`` public let method: Method /// A template including all elements that appear in the path @@ -215,11 +215,12 @@ public struct Definition: Sendable { /// Initializes a ``Definition`` with the given properties, defining all dynamic pieces as type-safe parameters. /// - Parameters: + /// - server: The server to use for this endpoint. Defaults to a new instance of T.Server. /// - method: The HTTP method to use when fetching the owning ``Endpoint`` /// - path: The path template representing the path and all path-related parameters /// - parameters: The parameters passed to the endpoint. Either through query or form body. - /// - headerValues: The headers associated with this request - public init(server: Server = Server(), + /// - headers: The headers associated with this request + public init(server: T.Server = T.Server(), method: Method, path: PathTemplate, parameters: [Parameter] = [], diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 7919689..eced25b 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -134,8 +134,65 @@ Endpoints.respondTo(PostEndpoint.self, with: { path, body in //} struct TestEndpoint: Endpoint { + typealias Server = ApiServer typealias Response = Void - static let definition: Definition = .init(server: ApiServer.api, method: .get, path: "/") + static let definition: Definition = Definition(server: ApiServer.api, method: .get, path: "/") +} + +/// A generic server implementation that can be used as a default for simple endpoints. +/// Supports multiple environments (development, staging, production) with configurable base URLs. +public struct GenericServer: ServerDefinition { + public enum Environments: String, CaseIterable, Hashable, Sendable { + case development + case staging + case production + } + + public let baseUrls: [Environments: URL] + public let requestProcessor: @Sendable (URLRequest) -> URLRequest + + /// Creates a GenericServer with the given base URLs for different environments. + /// - Parameters: + /// - development: URL for development environment (optional) + /// - staging: URL for staging environment (optional) + /// - production: URL for production environment (optional) + /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + public init( + development: URL? = nil, + staging: URL? = nil, + production: URL? = nil, + requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 } + ) { + var urls: [Environments: URL] = [:] + if let dev = development { urls[.development] = dev } + if let stg = staging { urls[.staging] = stg } + if let prod = production { urls[.production] = prod } + self.baseUrls = urls + self.requestProcessor = requestProcessor + } + + /// Creates a GenericServer with a single base URL used for all environments. + /// - Parameters: + /// - baseUrl: The base URL to use for all environments + /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + public init(baseUrl: URL, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 }) { + self.baseUrls = [ + .development: baseUrl, + .staging: baseUrl, + .production: baseUrl + ] + self.requestProcessor = requestProcessor + } + + /// Required parameterless initializer for ServerDefinition conformance. + /// Creates a GenericServer with no base URLs configured. + /// Note: You must set base URLs using the `baseUrls` property or use a different initializer. + public init() { + self.baseUrls = [:] + self.requestProcessor = { $0 } + } + + public static var defaultEnvironment: Environments { .production } } //public protocol EnvironmentType { diff --git a/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift index 8801343..a84999f 100644 --- a/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift @@ -10,7 +10,7 @@ import Endpoints import Foundation @testable import EndpointsMocking -struct TestServer: ServerDefinition { +struct MockTestServer: ServerDefinition { var baseUrls: [Environments: URL] { return [ .production: URL(string: "https://api.velosmobile.com")! @@ -20,8 +20,10 @@ struct TestServer: ServerDefinition { static var defaultEnvironment: Environments { .production } } -struct SimpleEndpoint: Endpoint { - static let definition: Definition = Definition( +struct MockSimpleEndpoint: Endpoint { + typealias Server = MockTestServer + + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) @@ -45,34 +47,34 @@ struct SimpleEndpoint: Endpoint { @Suite("Async URLSession Mocking") struct AsyncURLSessionMocking { @Test func basicThrow() async throws { - await #expect(throws: SimpleEndpoint.TaskError.self) { - try await withMock(SimpleEndpoint.self) { continuation in + await #expect(throws: MockSimpleEndpoint.TaskError.self) { + try await withMock(MockSimpleEndpoint.self) { continuation in continuation.resume(throwing: .internetConnectionOffline) } test: { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) _ = try await URLSession.shared.response(with: simple) } } } @Test func basicThrowInline() async throws { - await #expect(throws: SimpleEndpoint.TaskError.self) { - try await withMock(SimpleEndpoint.self, action: .throw(.internetConnectionOffline)) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + await #expect(throws: MockSimpleEndpoint.TaskError.self) { + try await withMock(MockSimpleEndpoint.self, action: .throw(.internetConnectionOffline)) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) _ = try await URLSession.shared.response(with: simple) } } } @Test func basicFail() async throws { - try await withMock(SimpleEndpoint.self) { continuation in + try await withMock(MockSimpleEndpoint.self) { continuation in continuation.resume(failingWith: .init(errorDescription: "error")) } test: { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) do { _ = try await URLSession.shared.response(with: simple) } catch { - let error = try #require(error as? SimpleEndpoint.TaskError) + let error = try #require(error as? MockSimpleEndpoint.TaskError) if case .errorResponse(_, let response) = error { #expect(response.errorDescription == "error") } else { @@ -83,12 +85,12 @@ struct AsyncURLSessionMocking { } @Test func basicFailInline() async throws { - try await withMock(SimpleEndpoint.self, action: .fail(.init(errorDescription: "error"))) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + try await withMock(MockSimpleEndpoint.self, action: .fail(.init(errorDescription: "error"))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) do { _ = try await URLSession.shared.response(with: simple) } catch { - let error = try #require(error as? SimpleEndpoint.TaskError) + let error = try #require(error as? MockSimpleEndpoint.TaskError) if case .errorResponse(_, let response) = error { #expect(response.errorDescription == "error") } else { @@ -99,19 +101,19 @@ struct AsyncURLSessionMocking { } @Test func basicResponse() async throws { - try await withMock(SimpleEndpoint.self) { continuation in + try await withMock(MockSimpleEndpoint.self) { continuation in // possibly load mocks async from json continuation.resume(returning: .init(response1: "test")) } test: { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) let response = try await URLSession.shared.response(with: simple) #expect(response.response1 == "test") } } @Test func basicResponseInline() async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test"))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) let response = try await URLSession.shared.response(with: simple) #expect(response.response1 == "test") } diff --git a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift index db1bee4..4441d9a 100644 --- a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift @@ -15,9 +15,9 @@ import Combine struct ClosureURLSessionMocking { @Test func inline() async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test"))) { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test"))) { try await wait { continuation in - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) let task = try URLSession.shared.endpointTask(with: simple) { result in #expect(throws: Never.self) { let response = try result.get() @@ -31,9 +31,9 @@ struct ClosureURLSessionMocking { } @Test func inline2() async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: "test2"))) { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test2"))) { try await wait { continuation in - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) let task = try URLSession.shared.endpointTask(with: simple) { result in #expect(throws: Never.self) { let response = try result.get() diff --git a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift index 7388673..47a7a5e 100644 --- a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift @@ -55,8 +55,8 @@ struct CombineURLSessionMocking { @available(iOS 15.0, *) @Test(arguments: ["test", "test2"]) func combineInline(response: String) async throws { - try await withMock(SimpleEndpoint.self, action: .return(.init(response1: response))) { - let simple = SimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: response))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) let endpointResponse = try await URLSession.shared.endpointPublisher(with: simple).awaitFirst #expect(endpointResponse.response1 == response) } diff --git a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift index 69db76a..adb77b5 100644 --- a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift @@ -10,7 +10,9 @@ import Endpoints import Foundation struct CustomEncodingEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, path: "/", parameters: [ diff --git a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift index 01289ef..13f5c1d 100644 --- a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift @@ -9,7 +9,9 @@ import Endpoints struct InvalidEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, path: "/", parameters: [ diff --git a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift index 58a84ee..8a9069c 100644 --- a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift @@ -10,8 +10,9 @@ import Foundation @testable import Endpoints struct JSONProviderEndpoint: Endpoint { + typealias Server = TestServer - static let definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift index 1bb4742..74872b5 100644 --- a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift @@ -2,7 +2,9 @@ import Foundation @testable import Endpoints struct MultipartUploadEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .post, path: "upload" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift index 64cdf47..afa9dcd 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct PostEndpoint1: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift index a86537a..c6e46e5 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct PostEndpoint2: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift index 0ad6d8b..8ab6bfb 100644 --- a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct SimpleEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift index cfde321..8ea511d 100644 --- a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct UserEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, path: "hey" + \.userId, parameters: [ From 8d3bcb22d923aadddf9ad40d003019b8dd84fb51 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 20:10:30 -0800 Subject: [PATCH 14/24] Slight simplification --- Sources/Endpoints/Mocking/Mocking.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 48f1a0c..6529c47 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -34,10 +34,11 @@ struct Mocking { } func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { - guard let current = Self.current else { return .none } - let continuation = MockContinuation() - await current.toReturn(for: T.self)(continuation) - switch continuation.action { + guard let action = await actionForMock(for: T.self) else { + return nil + } + + switch action { case .none: return nil case .return(let value): @@ -76,7 +77,7 @@ extension Mocking { extension Mocking { func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { - guard let current = Self.current else { + guard shouldHandleMock(for: T.self) else { return Just(nil) .setFailureType(to: T.TaskError.self) .eraseToAnyPublisher() @@ -85,9 +86,12 @@ extension Mocking { let subject = CurrentValueSubject(nil) Task { - let continuation = MockContinuation() - await current.toReturn(for: T.self)(continuation) - switch continuation.action { + guard let action = await actionForMock(for: T.self) else { + subject.send(nil) + return + } + + switch action { case .none: subject.send(nil) case .return(let value): From ae69efc18cb9679b4f625b1fb6eca2ed7f6a7dc7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 20:19:30 -0800 Subject: [PATCH 15/24] Cleanup in EnvironmentType --- Sources/Endpoints/EnvironmentType.swift | 109 ++---------------- .../Endpoints/Environment.swift | 6 - .../EndpointsTests/Endpoints/TestServer.swift | 6 - 3 files changed, 8 insertions(+), 113 deletions(-) diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index eced25b..7ae8bed 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,7 +12,7 @@ import Foundation import FoundationNetworking #endif -public enum TypicalEnvironments: String, CaseIterable { +public enum TypicalEnvironments: String, CaseIterable, Sendable { case local case development case staging @@ -53,86 +53,6 @@ struct ApiServer: ServerDefinition { static let api = Self() } -//@Server(MyEnvironments.self) -//struct ApiServer { -// var baseUrls: [MyEnvironments: URL] { -// return [ -// .blueSteel: URL(string: "https://bluesteel-api.velosmobile.com")!, -// .redStone: URL(string: "https://redstone-api.velosmobile.com")!, -// .production: URL(string: "https://api.velosmobile.com")! -// ] -// } -//} - -//@Server -//struct ApiServer { -// var baseUrls: [Environments: URL] { -// return [ -// .local: URL(string: "https://local-api.velosmobile.com")!, -// .staging: URL(string: "https://staging-api.velosmobile.com")!, -// .production: URL(string: "https://api.velosmobile.com")! -// ] -// } -//} - -/* -extension ServerEnvironments { - static var localApi = ServerEnvironment(URL(string: "https://local-api.velosmobile.com")!) - static var stagingApi = ServerEnvironment(URL(string: "https://staging-api.velosmobile.com")!) - static var prodApi = ServerEnvironment(URL(string: "https://api.velosmobile.com")!) -} - -extension ServerEnvironments { - static var localAnalytics = ServerEnvironment(URL(string: "https://local-analytics.velosmobile.com")!) - static var stagingAnalytics = ServerEnvironment(URL(string: "https://staging-analytics.velosmobile.com")!) - static var prodAnalytics = ServerEnvironment(URL(string: "https://analytics.velosmobile.com")!) -} - -extension Servers { - static var api = Server(localApi, stagingApi, prodApi) - static var analytics = Server(localAnalytics, stagingAnalytics, prodAnalytics) -} - -Servers.setDefault(.api) -Servers.setEnvironment(.local) - -@Endpoint(.get, path: "user/\(path: \.name)/\(path: \.id)/profile", server: .analytics) -struct TestEndpoint { - struct PathComponents: Codable { - let name: String - let id: String - } - struct Response { - let id: String - } -} - -import EndpointsTesting - -Endpoints.respondTo(TestEndpoint.self, after: .seconds(1), with: { path in - if path.contains("123") { - return TestEndpoint.Response(id: "123") - } - throw TestErrors.unknownPath -}) - -Endpoints.respondTo(PostEndpoint.self, with: { path, body in - if body.id == "123" { - return .init(id: "123") - } - throw TestErrors.unknownBody -}) - - */ - -//@Endpoint(.get, path: "user/\(path: \.name)/\(path: \.id)/profile") -//struct TestEndpoint { -// struct PathComponents: Codable { -// let name: String -// let id: String -// } -//} - struct TestEndpoint: Endpoint { typealias Server = ApiServer typealias Response = Void @@ -142,31 +62,28 @@ struct TestEndpoint: Endpoint { /// A generic server implementation that can be used as a default for simple endpoints. /// Supports multiple environments (development, staging, production) with configurable base URLs. public struct GenericServer: ServerDefinition { - public enum Environments: String, CaseIterable, Hashable, Sendable { - case development - case staging - case production - } - public let baseUrls: [Environments: URL] public let requestProcessor: @Sendable (URLRequest) -> URLRequest /// Creates a GenericServer with the given base URLs for different environments. /// - Parameters: + /// - local: URL for local development (optional) /// - development: URL for development environment (optional) /// - staging: URL for staging environment (optional) /// - production: URL for production environment (optional) /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) public init( + local: URL? = nil, development: URL? = nil, staging: URL? = nil, production: URL? = nil, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 } ) { var urls: [Environments: URL] = [:] - if let dev = development { urls[.development] = dev } - if let stg = staging { urls[.staging] = stg } - if let prod = production { urls[.production] = prod } + if let local { urls[.local] = local } + if let development { urls[.development] = development } + if let staging { urls[.staging] = staging } + if let production { urls[.production] = production } self.baseUrls = urls self.requestProcessor = requestProcessor } @@ -177,6 +94,7 @@ public struct GenericServer: ServerDefinition { /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) public init(baseUrl: URL, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 }) { self.baseUrls = [ + .local: baseUrl, .development: baseUrl, .staging: baseUrl, .production: baseUrl @@ -194,14 +112,3 @@ public struct GenericServer: ServerDefinition { public static var defaultEnvironment: Environments { .production } } - -//public protocol EnvironmentType { -// /// The baseUrl of the Environment -// var baseUrl: URL { get } -// /// Processes the built URLRequest right before sending in order to attach any Environment related authentication or data to the outbound request -// var requestProcessor: (URLRequest) -> URLRequest { get } -//} -// -//public extension EnvironmentType { -// var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } -//} diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index 5ae219e..8c467d5 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -10,12 +10,6 @@ import Foundation @testable import Endpoints struct MyServer: ServerDefinition { - enum Environments: String, CaseIterable { - case local - case staging - case production - } - var baseUrls: [Environments: URL] { return [ .local: URL(string: "https://api.velos.me")!, diff --git a/Tests/EndpointsTests/Endpoints/TestServer.swift b/Tests/EndpointsTests/Endpoints/TestServer.swift index 50d2454..531f374 100644 --- a/Tests/EndpointsTests/Endpoints/TestServer.swift +++ b/Tests/EndpointsTests/Endpoints/TestServer.swift @@ -9,12 +9,6 @@ import Endpoints import Foundation struct TestServer: ServerDefinition { - enum Environments: String, CaseIterable { - case local - case staging - case production - } - var baseUrls: [Environments: URL] { return [ .local: URL(string: "https://local-api.velosmobile.com")!, From 794835d98d82412160eb47d012e373dbe74a4fbf Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 21:34:39 -0800 Subject: [PATCH 16/24] Updated workflows --- .github/workflows/ci.yml | 71 +++++++++++++++++++++++++---- .github/workflows/documentation.yml | 21 ++++++--- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 089c3ff..e694341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,19 +1,70 @@ -name: Build +name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] -jobs: - build: +permissions: + contents: read +jobs: + build-macos: runs-on: macos-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-macos + path: .build/debug/*Tests* + retention-days: 5 + build-linux: + runs-on: ubuntu-latest + timeout-minutes: 15 + container: swift:6.0 + steps: - - uses: actions/checkout@v2 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v + - uses: actions/checkout@v4 + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-linux + path: .build/debug/*Tests* + retention-days: 5 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 126ff77..9281941 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -2,9 +2,9 @@ name: Documentation on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] + # Runs on releases + release: + types: [published] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -26,14 +26,21 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- - name: Generate Docs run: | xcodebuild docbuild -scheme Endpoints -destination generic/platform=iOS OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path Endpoints --output-path docs" - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: ./docs @@ -49,4 +56,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 From ade933f216747de13f62a747680d199f2af8b427 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 21:39:38 -0800 Subject: [PATCH 17/24] Documentation updates --- README.md | 133 ++++++- Sources/Endpoints/Endpoints.docc/Endpoints.md | 17 +- Sources/Endpoints/Endpoints.docc/Examples.md | 203 ++++++++-- Sources/Endpoints/Endpoints.docc/Mocking.md | 374 ++++++++++++++++++ Sources/Endpoints/EnvironmentType.swift | 29 ++ .../Endpoints/Mocking/MockContinuation.swift | 27 ++ Sources/Endpoints/Mocking/Mocking.swift | 21 + .../Mocking/URLSessionTask+Swizzling.swift | 7 + Sources/Endpoints/Server.swift | 11 + .../EndpointsMocking/EndpointsMocking.swift | 39 +- 10 files changed, 829 insertions(+), 32 deletions(-) create mode 100644 Sources/Endpoints/Endpoints.docc/Mocking.md diff --git a/README.md b/README.md index 0ed5369..ae6e5d6 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,49 @@ Endpoints is a small library for creating statically and strongly-typed definiti The purpose of Endpoints is to, in a type-safe way, define how to create a `URLRequest` from typed properties and, additionally, define how a response for the request should be handled. The library not only includes the ability to create these requests in a type-safe way, but also includes helpers to perform the requests using `URLSession`. Endpoints does not try to wrap the URL loading system to provide features on top of it like Alamofire. Instead, Endpoints focuses on defining endpoints and associated data to produce a request as a `URLRequest` object to be plugged into vanilla `URLSession`s. However, this library could be used in conjunction with Alamofire if desired. +## Features + +- **Type-safe endpoint definitions** - Define endpoints with compile-time checking of paths, parameters, and headers +- **Server definition with multiple environments** - Support for local, development, staging, and production environments with easy switching +- **Built-in mocking support** - Comprehensive testing utilities through the `EndpointsMocking` module +- **Swift 6.0 compatible** - Built with modern Swift concurrency and Sendable support +- **Combine and async/await support** - Use either reactive or async patterns + ## Getting Started The basic process for defining an Endpoint starts with defining a value conforming to `Endpoint`. With the `Endpoint` protocol, you are encapsulating the definition of the endpoint, all the properties that are plugged into the definition and the types for parsing the response. Within the `Endpoint`, the `definition` static var serves as an immutable definition of the server's endpoint and how the variable pieces of the `Endpoint` should fit together when making the full request. -To get started, first create a type (struct or class) conforming to `Endpoint`. There are only two required elements to conform: defining the `Response` and creating the ``Definition``. +### Defining a Server -`Endpoints` and `Definitions` do not contain base URLs so that these requests can be used on different environments. Environments are defined as conforming to the `EnvironmentType` and implement a `baseURL` as well as an optional `requestProcessor` which has a final hook before `URLRequest` creation to modify the `URLRequest` to attach authentication or signatures. +First, define a server that conforms to `ServerDefinition`. This encapsulates your base URLs for different environments: -To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki. +```swift +import Endpoints +import Foundation -## Examples +struct ApiServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://local-api.example.com")!, + .staging: URL(string: "https://staging-api.example.com")!, + .production: URL(string: "https://api.example.com")! + ] + } + + static var defaultEnvironment: Environments { .production } +} +``` + +To get started, first create a type (struct or class) conforming to `Endpoint`. There are only two required elements to conform: defining the `Response` and creating the `Definition`. -The most basic example of defining an Endpoint is creating a simple GET request. This means defining a type that conforms to `Endpoint` such as: +`Endpoints` and `Definitions` now include server information, eliminating the need to pass environments at call time. Servers implement a `requestProcessor` which has a final hook before `URLRequest` creation to modify the `URLRequest` to attach authentication or signatures. + +### Basic Endpoint Example ```Swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource" @@ -36,13 +63,13 @@ struct MyEndpoint: Endpoint { } ``` -This includes a `Response` associated type (can be typealiased to a more complex existing type) which defines how the response will come back from the request. +This includes a `Response` associated type (can be typealiased to a more complex existing type) which defines how the response will come back from the request. The server is specified via `typealias Server = ApiServer`. Then usage can employ the `URLSession` extensions: #### Usage ```Swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -52,4 +79,94 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) .store(in: &cancellables) ``` -To browse more complex examples, make sure to check out the [Examples](https://github.com/velos/Endpoints/wiki/Examples) wiki page. +Notice that the API no longer requires passing an environment - it's handled automatically by the server definition. + +### Async/Await + +```swift +do { + let response = try await URLSession.shared.response(with: MyEndpoint()) + // handle response +} catch { + // handle error +} +``` + +## Testing with EndpointsMocking + +Endpoints includes a comprehensive mocking system through the `EndpointsMocking` module: + +```swift +import Testing +import Endpoints +import EndpointsMocking + +@Test func testMyEndpoint() async throws { + try await withMock(MyEndpoint.self, action: .return(.init(resourceId: "123", resourceName: "Test"))) { + let response = try await URLSession.shared.response(with: MyEndpoint()) + #expect(response.resourceId == "123") + } +} +``` + +The mocking system supports: +- Returning successful responses +- Returning error responses +- Throwing network errors +- Dynamic response generation +- Combine publisher mocking + +To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki. + +## Examples + +To browse more complex examples, make sure to check out the [Examples](https://github.com/velos/Endpoints/wiki/Examples) wiki page or the documentation in Xcode. + +## Requirements + +- Swift 6.0+ +- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+ + +## Installation + +### Swift Package Manager + +Add the following to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/velos/Endpoints.git", from: "2.0.0") +] +``` + +For testing, also add: + +```swift +testTarget( + name: "YourTests", + dependencies: ["Endpoints", "EndpointsMocking"] +) +``` + +## Documentation + +Full documentation is available in Xcode (Product > Build Documentation) and includes: +- API reference for all types +- Comprehensive examples +- Mocking guide +- Best practices + +## Migration from 0.4.0 + +If you're upgrading from version 0.4.0 or earlier, the main changes are: + +1. **ServerDefinition replaces EnvironmentType** - Define your environments in a `ServerDefinition` conforming type +2. **No more environment parameter** - Remove `in: .production` from all API calls +3. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints +4. **Swift 6.0 required** - Update your Swift toolchain + +See the [Migration Guide](https://github.com/velos/Endpoints/wiki/Migration) for detailed instructions. + +## License + +Endpoints is released under the MIT license. See LICENSE for details. diff --git a/Sources/Endpoints/Endpoints.docc/Endpoints.md b/Sources/Endpoints/Endpoints.docc/Endpoints.md index 7dc0831..4a5e5cb 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -11,9 +11,24 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR ### Essentials - ``Endpoint`` -- ``EnvironmentType`` +- ``ServerDefinition`` +- ``Definition`` - +### Server Configuration + +- ``ServerDefinition`` +- ``GenericServer`` +- ``TypicalEnvironments`` + +### Testing and Mocking + +- +- ``EndpointsMocking`` +- ``withMock(_:_:test:)`` +- ``MockContinuation`` +- ``MockAction`` + ### Making Requests #### Combine diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 48d32ae..fad2e3d 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -1,11 +1,97 @@ # Examples +## Defining a Server + +Before creating endpoints, you first need to define a server that conforms to ``ServerDefinition``. This replaces the old `EnvironmentType` approach and provides a more integrated way to manage environments. + +### Basic Server Definition + +```swift +import Endpoints +import Foundation + +struct ApiServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://local-api.example.com")!, + .staging: URL(string: "https://staging-api.example.com")!, + .production: URL(string: "https://api.example.com")! + ] + } + + static var defaultEnvironment: Environments { .production } +} +``` + +### Using GenericServer + +For simple use cases, you can use the built-in ``GenericServer``: + +```swift +let server = GenericServer( + local: URL(string: "https://localhost:8080"), + staging: URL(string: "https://staging-api.example.com"), + production: URL(string: "https://api.example.com") +) +``` + +### Custom Environments + +You can define custom environment types beyond the standard ``TypicalEnvironments``: + +```swift +enum CustomEnvironments: String, CaseIterable, Sendable { + case debug + case testing + case production +} + +struct CustomServer: ServerDefinition { + typealias Environments = CustomEnvironments + + var baseUrls: [Environments: URL] { + return [ + .debug: URL(string: "https://debug-api.example.com")!, + .testing: URL(string: "https://test-api.example.com")!, + .production: URL(string: "https://api.example.com")! + ] + } + + static var defaultEnvironment: Environments { .debug } + + var requestProcessor: (URLRequest) -> URLRequest { + return { request in + var mutableRequest = request + mutableRequest.setValue("Bearer token", forHTTPHeaderField: "Authorization") + return mutableRequest + } + } +} +``` + +### Changing Environments + +To switch environments at runtime, set the environment on the server type: + +```swift +// Switch to staging environment +ApiServer.environment = .staging + +// All subsequent requests will use the staging URL +``` + +--- + +## Endpoint Examples + ### GET Request #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -19,7 +105,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -34,7 +120,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.userId)/resource" ) @@ -53,7 +141,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(pathComponents: .init(userId: "42"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(pathComponents: .init(userId: "42"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -74,7 +162,9 @@ extension Header { } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "path/to/resource", headers: [ @@ -99,7 +189,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(headerValues: .init(headerString: "headerValue", headerInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(headerValues: .init(headerString: "headerValue", headerInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -114,7 +204,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(headerValu #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .post, path: "path/to/resource" ) @@ -133,7 +225,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .init(bodyName: "value"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyName: "value"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -148,7 +240,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .ini #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .post, path: "path/to/resource", parameters: [ @@ -174,7 +268,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -189,7 +283,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .post, path: "path/to/resource", parameters: [ @@ -215,7 +311,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -236,7 +332,9 @@ https://production.mydomain.com/path/to/resource?keyString=value&keyInt=42&key=h #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .delete, path: "path/to/resource" ) @@ -247,7 +345,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -262,7 +360,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -282,7 +382,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -298,7 +398,9 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .post, path: "path/to/resource" ) @@ -323,7 +425,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .init(bodyValue: "value"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyValue: "value"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -343,7 +445,9 @@ struct ServerError: Decodable { } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -358,7 +462,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } switch error { @@ -383,7 +487,9 @@ struct ServerError: Decodable { } struct MyEndpoint: Endpoint { - static let definition: Definition = Definition( + typealias Server = ApiServer + + static let definition: Definition = Definition( method: .get, path: "path/to/resource" ) @@ -404,7 +510,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } switch error { @@ -419,3 +525,56 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) } .store(in: &cancellables) ``` + +### Async/Await Usage + +All endpoints can also be used with Swift's async/await: + +```swift +do { + let response = try await URLSession.shared.response(with: MyEndpoint()) + // handle response +} catch { + // handle error +} +``` + +### Multipart Form Upload + +For file uploads using multipart/form-data: + +```swift +struct UploadEndpoint: Endpoint { + typealias Server = ApiServer + + static let definition: Definition = Definition( + method: .post, + path: "upload" + ) + + struct Response: Decodable { + let fileId: String + } + + struct Body: MultipartFormEncodable { + let file: MultipartFile + let description: String + } + + let body: Body +} + +// Usage +let file = MultipartFile( + filename: "photo.jpg", + contentType: "image/jpeg", + data: imageData +) + +let endpoint = UploadEndpoint(body: .init( + file: file, + description: "Profile photo" +)) + +let response = try await URLSession.shared.response(with: endpoint) +``` diff --git a/Sources/Endpoints/Endpoints.docc/Mocking.md b/Sources/Endpoints/Endpoints.docc/Mocking.md new file mode 100644 index 0000000..57a7cdd --- /dev/null +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -0,0 +1,374 @@ +# Mocking + +The Endpoints library includes a powerful mocking system through the `EndpointsMocking` module that allows you to intercept and mock network requests during testing. This enables fast, reliable tests without making actual network calls. + +## Overview + +The mocking system works by: +1. Intercepting URLSession data task `resume()` calls when a mock is active +2. Providing mock responses through a continuation-based API +3. Supporting async/await, Combine, and closure-based callbacks + +## Setup + +Add the `EndpointsMocking` module to your test target dependencies: + +```swift +// Package.swift +testTarget( + name: "YourTests", + dependencies: ["Endpoints", "EndpointsMocking"] +) +``` + +Import the mocking module in your tests: + +```swift +import Testing // or XCTest +import Endpoints +import EndpointsMocking +``` + +## Basic Mocking + +### Mocking a Successful Response + +Use `withMock` to wrap your test code and provide mock responses: + +```swift +import Testing +import Endpoints +import EndpointsMocking + +@Test func testSuccessfulResponse() async throws { + try await withMock(MyEndpoint.self) { continuation in + // Provide the mock response + continuation.resume(returning: .init(userId: "123", name: "John Doe")) + } test: { + // Your actual test code + let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) + let response = try await URLSession.shared.response(with: endpoint) + + #expect(response.userId == "123") + #expect(response.name == "John Doe") + } +} +``` + +### Inline Mock Action + +For simple cases, use the inline action syntax: + +```swift +@Test func testWithInlineAction() async throws { + try await withMock( + MyEndpoint.self, + action: .return(.init(userId: "456", name: "Jane Smith")) + ) { + let endpoint = MyEndpoint(pathComponents: .init(userId: "456")) + let response = try await URLSession.shared.response(with: endpoint) + + #expect(response.name == "Jane Smith") + } +} +``` + +## Mock Actions + +The `MockAction` enum provides four different actions: + +### 1. Return a Success Response + +```swift +continuation.resume(returning: responseObject) +// or inline: +withMock(MyEndpoint.self, action: .return(responseObject)) +``` + +### 2. Return an Error Response + +Use this when the server returns a structured error (matching your endpoint's `ErrorResponse` type): + +```swift +continuation.resume(failingWith: ErrorResponse(code: 404, message: "Not found")) +// or inline: +withMock(MyEndpoint.self, action: .fail(errorResponse)) +``` + +### 3. Throw a Task Error + +Use this to simulate network or parsing errors: + +```swift +continuation.resume(throwing: .internetConnectionOffline) +// or inline: +withMock(MyEndpoint.self, action: .throw(.internetConnectionOffline)) +``` + +### 4. Do Nothing + +For cases where you want the mock to not interfere (rarely used): + +```swift +// Just don't call any resume method, or: +withMock(MyEndpoint.self, action: .none) +``` + +## Advanced Mocking + +### Dynamic Responses Based on Request + +The continuation closure receives the endpoint instance, allowing dynamic responses: + +```swift +@Test func testDynamicResponse() async throws { + try await withMock(MyEndpoint.self) { continuation in + // Access the endpoint being requested + let endpoint = continuation.endpoint + + // Return different responses based on the request + if endpoint.pathComponents.userId == "admin" { + continuation.resume(returning: .init(userId: "admin", name: "Administrator")) + } else { + continuation.resume(returning: .init(userId: "user", name: "Regular User")) + } + } test: { + let adminEndpoint = MyEndpoint(pathComponents: .init(userId: "admin")) + let adminResponse = try await URLSession.shared.response(with: adminEndpoint) + #expect(adminResponse.name == "Administrator") + } +} +``` + +### Async Mock Data Loading + +You can load mock data asynchronously from files or other sources: + +```swift +@Test func testWithAsyncMockLoading() async throws { + try await withMock(MyEndpoint.self) { continuation in + // Load mock from JSON file + let mockData = try await loadMockData(filename: "user_response.json") + let decoder = JSONDecoder() + let response = try decoder.decode(MyEndpoint.Response.self, from: mockData) + + continuation.resume(returning: response) + } test: { + let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) + let response = try await URLSession.shared.response(with: endpoint) + + #expect(response.userId == "123") + } +} + +func loadMockData(filename: String) async throws -> Data { + let url = Bundle.module.url(forResource: filename, withExtension: nil)! + return try Data(contentsOf: url) +} +``` + +### Multiple Requests in One Mock Block + +The mock applies to all requests of the specified endpoint type within the test block: + +```swift +@Test func testMultipleRequests() async throws { + var callCount = 0 + + try await withMock(MyEndpoint.self) { continuation in + callCount += 1 + continuation.resume(returning: .init(userId: "\(callCount)", name: "User \(callCount)")) + } test: { + let endpoint1 = MyEndpoint(pathComponents: .init(userId: "1")) + let response1 = try await URLSession.shared.response(with: endpoint1) + + let endpoint2 = MyEndpoint(pathComponents: .init(userId: "2")) + let response2 = try await URLSession.shared.response(with: endpoint2) + + #expect(callCount == 2) + #expect(response1.name == "User 1") + #expect(response2.name == "User 2") + } +} +``` + +## Combine Support + +Mocking works seamlessly with Combine publishers: + +```swift +import Testing +import Endpoints +import EndpointsMocking +@preconcurrency import Combine + +@Suite("Combine Mocking") +struct CombineMockingTests { + + @Test func testCombinePublisher() async throws { + try await withMock(MyEndpoint.self, action: .return(.init(userId: "123", name: "Test"))) { + let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) + + let response = try await URLSession.shared + .endpointPublisher(with: endpoint) + .awaitFirst() + + #expect(response.name == "Test") + } + } +} + +// Helper to await publisher values +@available(iOS 15.0, *) +extension AnyPublisher where Output: Sendable { + var awaitFirst: Output { + get async throws { + try await self.first().asyncThrowing() + } + } +} +``` + +## Testing Errors + +### Testing Error Responses + +```swift +@Test func testErrorResponse() async throws { + struct ServerError: Codable, Equatable { + let code: Int + let message: String + } + + struct ErrorEndpoint: Endpoint { + typealias Server = ApiServer + typealias ErrorResponse = ServerError + + static let definition: Definition = Definition( + method: .get, + path: "error" + ) + + struct Response: Decodable { + let value: String + } + } + + try await withMock(ErrorEndpoint.self) { continuation in + continuation.resume(failingWith: ServerError(code: 500, message: "Server Error")) + } test: { + do { + _ = try await URLSession.shared.response(with: ErrorEndpoint()) + #expect(Bool(false), "Expected error to be thrown") + } catch { + guard case .errorResponse(_, let errorResponse) = error as? ErrorEndpoint.TaskError else { + #expect(Bool(false), "Wrong error type") + return + } + #expect(errorResponse.code == 500) + #expect(errorResponse.message == "Server Error") + } + } +} +``` + +### Testing Thrown Errors + +```swift +@Test func testThrownError() async throws { + await #expect(throws: MyEndpoint.TaskError.self) { + try await withMock(MyEndpoint.self) { continuation in + continuation.resume(throwing: .internetConnectionOffline) + } test: { + _ = try await URLSession.shared.response(with: MyEndpoint()) + } + } +} +``` + +## Best Practices + +### 1. Use Type-Specific Mocks + +Always specify the endpoint type explicitly to ensure type safety: + +```swift +// Good +withMock(MySpecificEndpoint.self) { ... } + +// Avoid (if possible) +withMock(endpoint) { ... } +``` + +### 2. Organize Mock Data + +Create helper functions or extensions for common mock scenarios: + +```swift +extension MyEndpoint { + static func mockSuccess(userId: String, name: String) -> MockAction { + .return(.init(userId: userId, name: name)) + } + + static func mockNotFound() -> MockAction { + .fail(.init(code: 404, message: "User not found")) + } +} + +// Usage +try await withMock(MyEndpoint.self, action: .mockSuccess(userId: "123", name: "Test")) { + // test code +} +``` + +### 3. Test Error Cases + +Always test both success and failure paths: + +```swift +@Suite("User Endpoint Tests") +struct UserEndpointTests { + + @Test func successCase() async throws { ... } + + @Test func notFoundCase() async throws { ... } + + @Test func networkErrorCase() async throws { ... } + + @Test func decodingErrorCase() async throws { ... } +} +``` + +### 4. Reset Environment After Tests + +If your tests change the server environment, reset it afterward: + +```swift +@Test func testStagingEnvironment() async throws { + let originalEnvironment = ApiServer.environment + ApiServer.environment = .staging + + defer { + ApiServer.environment = originalEnvironment + } + + // Test code... +} +``` + +## Limitations + +- Mocking only works in DEBUG builds (disabled in release builds) +- Mocking applies to all instances of an endpoint type within the test block +- You cannot selectively mock some requests and not others within the same block + +## Migration from Old Mocking + +If you were previously using a different mocking approach, the new `withMock` API offers several advantages: + +1. **No URLSession swizzling needed** - Clean, Swift-native approach +2. **Type-safe** - Mock responses are checked at compile time +3. **Async-native** - Built for Swift's async/await +4. **Combine support** - Works with both async and Combine APIs + +Replace manual URLProtocol mocking or stubbing with `withMock` for cleaner, more maintainable tests. diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 7ae8bed..b7b1fca 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,6 +12,9 @@ import Foundation import FoundationNetworking #endif +/// Standard environment types used by most servers. +/// +/// Use these as a starting point, or define your own environment enum. public enum TypicalEnvironments: String, CaseIterable, Sendable { case local case development @@ -19,17 +22,43 @@ public enum TypicalEnvironments: String, CaseIterable, Sendable { case production } +/// Defines the server configuration for endpoints. +/// +/// Conform to this protocol to create a server definition that specifies +/// base URLs for different environments and request processing behavior. +/// +/// ```swift +/// struct ApiServer: ServerDefinition { +/// var baseUrls: [Environments: URL] { +/// return [ +/// .staging: URL(string: "https://staging-api.example.com")!, +/// .production: URL(string: "https://api.example.com")! +/// ] +/// } +/// +/// static var defaultEnvironment: Environments { .production } +/// } +/// ``` public protocol ServerDefinition: Sendable { + /// The environment type for this server. Defaults to ``TypicalEnvironments``. associatedtype Environments: Hashable = TypicalEnvironments + /// Required initializer for creating server instances. init() + + /// Maps environments to their base URLs. var baseUrls: [Environments: URL] { get } + + /// Optional request processor to modify requests before sending. + /// Use this to add authentication headers or signatures. var requestProcessor: (URLRequest) -> URLRequest { get } + /// The default environment to use when none is explicitly set. static var defaultEnvironment: Environments { get } } public extension ServerDefinition { + /// Default passthrough request processor that returns the request unchanged. var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } } diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift index 648ada9..96f6c25 100644 --- a/Sources/Endpoints/Mocking/MockContinuation.swift +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -7,6 +7,13 @@ import Foundation +/// Actions that can be performed by a mock response. +/// +/// Use these actions in the `withMock` closure to specify how the mock should respond: +/// - `.return(value)`: Return a successful response +/// - `.fail(errorResponse)`: Return a server error response +/// - `.throw(error)`: Throw a network or task error +/// - `.none`: Perform no action (pass through to actual request) public enum MockAction: Sendable { case none case `return`(Value) @@ -14,6 +21,18 @@ public enum MockAction: Sendable { case `throw`(EndpointTaskError) } +/// A continuation passed to the `withMock` closure to configure mock responses. +/// +/// The continuation provides methods to specify what response or error should be returned +/// when the endpoint is requested. Call one of the `resume` methods to configure the mock. +/// +/// ```swift +/// try await withMock(MyEndpoint.self) { continuation in +/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// } test: { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// } +/// ``` public class MockContinuation where T.Response: Sendable { var action: MockAction @@ -25,14 +44,22 @@ public class MockContinuation where T.Response: Sendable { self.action = action } + /// Resumes the mock with a successful response value. + /// - Parameter value: The response value to return public func resume(returning value: T.Response) { action = .return(value) } + /// Resumes the mock with an error response. + /// Use this when the server returns a structured error. + /// - Parameter error: The error response from the server public func resume(failingWith error: T.ErrorResponse) { action = .fail(error) } + /// Resumes the mock by throwing a task error. + /// Use this to simulate network failures or other request errors. + /// - Parameter error: The error to throw public func resume(throwing error: EndpointTaskError) where T.ErrorResponse: Sendable { action = .throw(error) } diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 6529c47..1b6da83 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -22,6 +22,11 @@ struct ToReturnWrapper: Sendable { } } +/// Internal mocking system that intercepts URLSession requests. +/// +/// This type manages the mock state and coordinates between the `withMock` functions +/// and the URLSession task interception. It uses TaskLocal storage to track active mocks +/// and method swizzling to intercept data task resume calls. struct Mocking { static let shared = Mocking() @@ -30,9 +35,13 @@ struct Mocking { static private var current: ToReturnWrapper? init() { + // Initialize URLSession swizzling on first use URLSessionTask.classInit } + /// Handles a mock request for the specified endpoint type (async/await version). + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: The mock response, or nil if no mock is active func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { guard let action = await actionForMock(for: T.self) else { return nil @@ -50,6 +59,11 @@ struct Mocking { } } + /// Sets up a mock context and executes the test block within it. + /// - Parameters: + /// - ofType: The endpoint type to mock + /// - body: Closure that configures the mock response + /// - test: The test code to execute with mocking enabled func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @escaping () async throws -> R) async rethrows -> R { try await Self.$current.withValue(ToReturnWrapper(body)) { try await test() @@ -58,10 +72,14 @@ struct Mocking { } extension Mocking { + /// Checks if a mock is currently active for the given endpoint type. func shouldHandleMock(for endpointsOfType: T.Type) -> Bool { Self.current != nil } + /// Retrieves the mock action for the specified endpoint type. + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: The configured mock action, or nil if no mock is active func actionForMock(for endpointsOfType: T.Type) async -> MockAction? { guard let current = Self.current else { return nil @@ -76,6 +94,9 @@ extension Mocking { @preconcurrency import Combine extension Mocking { + /// Handles a mock request for Combine publishers. + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: A publisher that emits the mock response or error func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { guard shouldHandleMock(for: T.self) else { return Just(nil) diff --git a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift index 9139928..a9009d8 100644 --- a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift +++ b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift @@ -8,8 +8,12 @@ import Foundation #if DEBUG +/// Storage key for the resume override closure. nonisolated(unsafe) private var resumeOverrideKey: UInt8 = 0 + extension URLSessionTask { + /// A closure that overrides the default resume behavior. + /// When set, this closure is called instead of the actual network request. var resumeOverride: (() -> Void)? { get { return (objc_getAssociatedObject(self, &resumeOverrideKey) as? () -> Void) ?? nil @@ -19,12 +23,15 @@ extension URLSessionTask { } } + /// One-time initialization that swizzles the resume method. + /// This is called automatically when the mocking system is first used. static let classInit: Void = { guard let originalMethod = class_getInstanceMethod(URLSessionTask.self, #selector(resume)), let swizzledMethod = class_getInstanceMethod(URLSessionTask.self, #selector(swizzled_resume)) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) }() + /// The swizzled implementation of resume that checks for an override. @objc func swizzled_resume() { if let resumeOverride { resumeOverride() diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 0f25c7b..3f6f7d8 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -7,6 +7,8 @@ import Foundation +/// Thread-safe storage for server environments. +/// Maps environment types to their current values, allowing runtime switching. enum EnvironmentStorage { private static let lock = NSLock() nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] @@ -27,6 +29,15 @@ enum EnvironmentStorage { } extension ServerDefinition { + /// The current environment for this server type. + /// + /// Use this property to switch environments at runtime. The value persists across + /// all endpoints using this server type. + /// + /// ```swift + /// // Switch to staging for all subsequent requests + /// ApiServer.environment = .staging + /// ``` public static var environment: Self.Environments { get { EnvironmentStorage.getEnvironment(for: Self.Environments.self) ?? Self.defaultEnvironment diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index ea0a493..09a8d8e 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -1,5 +1,5 @@ // -// File.swift +// EndpointsMocking.swift // Endpoints // // Created by Zac White on 11/30/24. @@ -8,10 +8,47 @@ import Foundation @testable import Endpoints +/// Executes a test block with mocking enabled for the specified endpoint type. +/// +/// Use this function to intercept network requests and provide mock responses instead of +/// making actual network calls. The mock applies to all requests of the specified endpoint +/// type within the test block. +/// +/// ```swift +/// try await withMock(MyEndpoint.self) { continuation in +/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// } test: { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// #expect(response.userId == "123") +/// } +/// ``` +/// +/// - Parameters: +/// - ofType: The endpoint type to mock +/// - body: A closure that receives a ``MockContinuation`` to configure the mock response +/// - test: The test code that will execute with mocking enabled +/// - Returns: The value returned by the test block public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { return try await Mocking.shared.withMock(T.self, body, test: test) } +/// Executes a test block with a pre-configured mock action. +/// +/// This is a convenience variant that accepts a ``MockAction`` directly instead of a closure. +/// Use this for simple cases where you don't need dynamic response generation. +/// +/// ```swift +/// try await withMock(MyEndpoint.self, action: .return(.init(userId: "123", name: "Test"))) { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// #expect(response.name == "Test") +/// } +/// ``` +/// +/// - Parameters: +/// - ofType: The endpoint type to mock +/// - action: The mock action to perform (return, fail, throw, or none) +/// - test: The test code that will execute with mocking enabled +/// - Returns: The value returned by the test block public func withMock(_ ofType: T.Type, action: MockAction, test: @Sendable @escaping () async throws -> R) async rethrows -> R { return try await Mocking.shared.withMock(T.self, { continuation in switch action { From 16574257be338ddbcc22f58e6daa3b6a99dd5209 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 21:44:30 -0800 Subject: [PATCH 18/24] Possible linux fixes --- Sources/Endpoints/Mocking/Mocking.swift | 2 ++ Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift | 1 - Tests/EndpointsMockingTests/CombineURLSessionMocking.swift | 4 ++++ Tests/EndpointsTests/URLSessionExtensionTests.swift | 3 ++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 1b6da83..c342557 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -91,6 +91,7 @@ extension Mocking { } } +#if canImport(Combine) @preconcurrency import Combine extension Mocking { @@ -128,3 +129,4 @@ extension Mocking { .eraseToAnyPublisher() } } +#endif diff --git a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift index 4441d9a..0443b50 100644 --- a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift @@ -9,7 +9,6 @@ import Testing import Endpoints import Foundation @testable import EndpointsMocking -import Combine @Suite("Closure URLSession Mocking") struct ClosureURLSessionMocking { diff --git a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift index 47a7a5e..6fb15dc 100644 --- a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift +++ b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift @@ -5,6 +5,8 @@ // Created by Zac White on 11/30/24. // +#if canImport(Combine) + import Testing import Endpoints import Foundation @@ -62,3 +64,5 @@ struct CombineURLSessionMocking { } } } + +#endif diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index d64a403..67c96fc 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -8,7 +8,6 @@ import Testing import Foundation -import Combine @testable import Endpoints @Suite @@ -23,6 +22,7 @@ struct URLSessionExtensionTests { } } + #if canImport(Combine) @Test @available(iOS 15.0, *) func publisherCreationFailure() async { @@ -36,4 +36,5 @@ struct URLSessionExtensionTests { } } } + #endif } From b8b6f164100b04bae7efbd96cc7228e5af1e9003 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 21:50:29 -0800 Subject: [PATCH 19/24] More checks for linux --- Sources/Endpoints/Definition+URLResponse.swift | 4 ++++ Sources/Endpoints/EnvironmentType.swift | 4 ++-- Sources/Endpoints/Extensions/URLSession+Async.swift | 4 ++++ Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift | 4 ++++ Tests/EndpointsTests/Endpoints/Environment.swift | 2 +- 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Sources/Endpoints/Definition+URLResponse.swift b/Sources/Endpoints/Definition+URLResponse.swift index f65402c..0fa8d64 100644 --- a/Sources/Endpoints/Definition+URLResponse.swift +++ b/Sources/Endpoints/Definition+URLResponse.swift @@ -8,6 +8,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + public extension Definition { /// Converts data, response and error into a Result type by processing data and throwing errors based on response codes and response data. diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index b7b1fca..fcfe657 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -51,7 +51,7 @@ public protocol ServerDefinition: Sendable { /// Optional request processor to modify requests before sending. /// Use this to add authentication headers or signatures. - var requestProcessor: (URLRequest) -> URLRequest { get } + var requestProcessor: @Sendable (URLRequest) -> URLRequest { get } /// The default environment to use when none is explicitly set. static var defaultEnvironment: Environments { get } @@ -59,7 +59,7 @@ public protocol ServerDefinition: Sendable { public extension ServerDefinition { /// Default passthrough request processor that returns the request unchanged. - var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } + var requestProcessor: @Sendable (URLRequest) -> URLRequest { return { $0 } } } struct ApiServer: ServerDefinition { diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 0ec0ee3..0f11a14 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -8,6 +8,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) public extension URLSession { diff --git a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift index a9009d8..53200ac 100644 --- a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift +++ b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift @@ -7,6 +7,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + #if DEBUG /// Storage key for the resume override closure. nonisolated(unsafe) private var resumeOverrideKey: UInt8 = 0 diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index 8c467d5..2a15b0b 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -17,6 +17,6 @@ struct MyServer: ServerDefinition { .production: URL(string: "https://api.velos.me")! ] } - + static var defaultEnvironment: Environments { .production } } From 3111c155feeb53f89718670e5ab457df644738bd Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 22:00:47 -0800 Subject: [PATCH 20/24] More fixes for linux --- Sources/Endpoints/Extensions/URLSession+Async.swift | 6 +++--- Sources/Endpoints/Extensions/URLSession+Combine.swift | 6 +++--- Sources/Endpoints/Extensions/URLSession+Endpoints.swift | 6 +++--- Sources/Endpoints/Mocking/MockContinuation.swift | 4 ++++ Sources/Endpoints/Mocking/Mocking.swift | 4 ++++ Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift | 2 +- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 0f11a14..40f3afb 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -24,7 +24,7 @@ public extension URLSession { func response(with endpoint: T) async throws where T.Response == Void { let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { return mockResponse } @@ -47,7 +47,7 @@ public extension URLSession { func response(with endpoint: T) async throws -> T.Response where T.Response == Data { let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { return mockResponse } @@ -70,7 +70,7 @@ public extension URLSession { func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { return mockResponse } diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index d5ceb17..0163f3d 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -44,7 +44,7 @@ public extension URLSession { // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) return Mocking.shared.handleMock(for: T.self) .flatMap { mock in if let mock { @@ -94,7 +94,7 @@ public extension URLSession { // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) return Mocking.shared.handleMock(for: T.self) .flatMap { mock in if let mock { @@ -150,7 +150,7 @@ public extension URLSession { // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) return Mocking.shared.handleMock(for: T.self) .flatMap { mock in if let mock { diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 4cf074f..7e2b2af 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -50,7 +50,7 @@ public extension URLSession { completion(T.definition.response(data: data, response: response, error: error).map { _ in }) } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if Mocking.shared.shouldHandleMock(for: T.self) { task.resumeOverride = { Task { @@ -89,7 +89,7 @@ public extension URLSession { let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error)) } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if Mocking.shared.shouldHandleMock(for: T.self) { task.resumeOverride = { Task { @@ -140,7 +140,7 @@ public extension URLSession { completion(.failure(failure)) } } - #if DEBUG + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if Mocking.shared.shouldHandleMock(for: T.self) { task.resumeOverride = { Task { diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift index 96f6c25..57d6805 100644 --- a/Sources/Endpoints/Mocking/MockContinuation.swift +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -5,6 +5,8 @@ // Created by Zac White on 11/30/24. // +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + import Foundation /// Actions that can be performed by a mock response. @@ -64,3 +66,5 @@ public class MockContinuation where T.Response: Sendable { action = .throw(error) } } + +#endif diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index c342557..f2c1548 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -5,6 +5,8 @@ // Created by Zac White on 11/30/24. // +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + import Foundation struct ToReturnWrapper: Sendable { @@ -130,3 +132,5 @@ extension Mocking { } } #endif + +#endif diff --git a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift index 53200ac..49436be 100644 --- a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift +++ b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift @@ -11,7 +11,7 @@ import Foundation import FoundationNetworking #endif -#if DEBUG +#if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) /// Storage key for the resume override closure. nonisolated(unsafe) private var resumeOverrideKey: UInt8 = 0 From dc63429851ca2b7c52227a036ee316c609eb193f Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 22:02:52 -0800 Subject: [PATCH 21/24] Another possible linux fix --- Sources/Endpoints/EnvironmentType.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index fcfe657..ca49fea 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -136,7 +136,7 @@ public struct GenericServer: ServerDefinition { /// Note: You must set base URLs using the `baseUrls` property or use a different initializer. public init() { self.baseUrls = [:] - self.requestProcessor = { $0 } + self.requestProcessor = { @Sendable in $0 } } public static var defaultEnvironment: Environments { .production } From f80cb070e3dab3e142cf0ab0420bdc0a73ee981a Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 22:09:14 -0800 Subject: [PATCH 22/24] Another linux fix --- Package.swift | 66 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index 286bcbd..13cea1d 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,48 @@ import PackageDescription +// Conditional targets based on platform +#if os(Linux) +let products: [Product] = [ + .library( + name: "Endpoints", + targets: ["Endpoints"]), +] + +let targets: [Target] = [ + .target( + name: "Endpoints", + dependencies: []), + .testTarget( + name: "EndpointsTests", + dependencies: ["Endpoints"]), +] +#else +let products: [Product] = [ + .library( + name: "Endpoints", + targets: ["Endpoints"]), + .library( + name: "EndpointsMocking", + targets: ["EndpointsMocking"]), +] + +let targets: [Target] = [ + .target( + name: "Endpoints", + dependencies: []), + .target( + name: "EndpointsMocking", + dependencies: ["Endpoints"]), + .testTarget( + name: "EndpointsTests", + dependencies: ["Endpoints"]), + .testTarget( + name: "EndpointsMockingTests", + dependencies: ["EndpointsMocking"]), +] +#endif + let package = Package( name: "Endpoints", platforms: [ @@ -11,26 +53,6 @@ let package = Package( .macOS(.v10_15), .watchOS(.v6) ], - products: [ - .library( - name: "Endpoints", - targets: ["Endpoints"]), - .library( - name: "EndpointsMocking", - targets: ["EndpointsMocking"]), - ], - targets: [ - .target( - name: "Endpoints", - dependencies: []), - .target( - name: "EndpointsMocking", - dependencies: ["Endpoints"]), - .testTarget( - name: "EndpointsTests", - dependencies: ["Endpoints"]), - .testTarget( - name: "EndpointsMockingTests", - dependencies: ["EndpointsMocking"]), - ] + products: products, + targets: targets ) From 5f7a3fdb0cde2b1014212112c0029eb0994fef51 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 22:13:05 -0800 Subject: [PATCH 23/24] More fixes --- Tests/EndpointsTests/EndpointsTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index db99736..1cd2c9e 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -7,6 +7,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Testing @testable import Endpoints From 2ecc4a4e89b85aa6125099e52b60e01ccc488dc7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 1 Feb 2026 22:19:04 -0800 Subject: [PATCH 24/24] More import fixes --- Tests/EndpointsTests/EndpointsTests.swift | 3 ++- Tests/EndpointsTests/URLSessionExtensionTests.swift | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 1cd2c9e..19df9f3 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -6,11 +6,12 @@ // Copyright © 2019 Velos Mobile LLC. All rights reserved. // +import Testing import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif -import Testing + @testable import Endpoints @Suite diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index 67c96fc..e9ec30e 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -8,6 +8,10 @@ import Testing import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + @testable import Endpoints @Suite