diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5998d8c..b514580 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,15 +7,15 @@ jobs: SwiftLint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: SwiftLint uses: norio-nomura/action-swiftlint@3.2.1 Test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Cache Swift build - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: .build key: ${{ runner.os }}-build diff --git a/.swiftlint.yml b/.swiftlint.yml index 80c9d09..2416a49 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,6 +2,7 @@ disabled_rules: - closure_parameter_position - identifier_name - multiple_closures_with_trailing_closure + - type_name opt_in_rules: - attributes - closure_end_indentation @@ -11,7 +12,6 @@ opt_in_rules: - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - - convenience_type - empty_count - empty_string - explicit_init diff --git a/Package.swift b/Package.swift index 05c87ec..3b562c2 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( .visionOS(.v1) ], products: [ - .library(name: "EndpointBuilderURLSession", targets: ["EndpointBuilderURLSession"]), + .library(name: "EndpointBuilderURLSession", targets: ["EndpointBuilderURLSession"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-http-types", from: "1.0.0"), @@ -24,6 +24,12 @@ let package = Package( .product(name: "EndpointBuilder", package: "endpoint-builder"), .product(name: "HTTPTypes", package: "swift-http-types") ] + ), + .testTarget( + name: "EndpointBuilderURLSessionTests", + dependencies: [ + .byName(name: "EndpointBuilderURLSession") + ] ) ] ) diff --git a/Sources/EndpointBuilderURLSession/EndpointBuilderURLSession.swift b/Sources/EndpointBuilderURLSession/EndpointBuilderURLSession.swift index ab293bf..e1c3616 100644 --- a/Sources/EndpointBuilderURLSession/EndpointBuilderURLSession.swift +++ b/Sources/EndpointBuilderURLSession/EndpointBuilderURLSession.swift @@ -6,31 +6,35 @@ import FoundationNetworking import HTTPTypes /// An API endpoint client that uses `URLSession` to create requests -public struct EndpointBuilderURLSession: Sendable { +public protocol EndpointBuilderURLSession: Sendable { /// The base server URL on which endpoint paths will be appended - public let serverBaseURL: URL + var serverBaseURL: URL { get } /// The URLSession object that will make requests - public let urlSession: @Sendable () -> URLSession + var urlSession: @Sendable () -> URLRequestHandler { get } /// JSON encoder - public let encoder: @Sendable () -> JSONEncoder + var encoder: @Sendable () -> JSONEncoder { get } /// JSON decoder - public let decoder: @Sendable () -> JSONDecoder - - /// Creates a new `EndpointBuilderURLSession`. - public init( - serverBaseURL: URL, - urlSession: @Sendable @escaping () -> URLSession = { URLSession.shared }, - encoder: @Sendable @escaping () -> JSONEncoder = { JSONEncoder() }, - decoder: @Sendable @escaping () -> JSONDecoder = { JSONDecoder() } - ) { - self.serverBaseURL = serverBaseURL - self.urlSession = urlSession - self.encoder = encoder - self.decoder = decoder + var decoder: @Sendable () -> JSONDecoder { get } + +} + +// Default values +extension EndpointBuilderURLSession { + + public var urlSession: @Sendable () -> URLSession { + { URLSession.shared } + } + + public var encoder: @Sendable () -> JSONEncoder { + { JSONEncoder() } + } + + var decoder: @Sendable () -> JSONDecoder { + { JSONDecoder() } } } @@ -70,7 +74,8 @@ extension EndpointBuilderURLSession { } // perform request - return try await urlSession().responseData(for: request) + let (data, _) = try await urlSession().data(for: request) + return data } } @@ -90,24 +95,3 @@ extension URL { } } - -extension URLSession { - - func responseData(for request: URLRequest) async throws -> Data { - #if canImport(FoundationNetworking) - await withCheckedContinuation { continuation in - self.dataTask(with: request) { data, _, _ in - guard let data = data else { - continuation.resume(returning: Data()) - return - } - continuation.resume(returning: data) - }.resume() - } - #else - let (data, _) = try await self.data(for: request) - return data - #endif - } - -} diff --git a/Sources/EndpointBuilderURLSession/URLRequestHandler.swift b/Sources/EndpointBuilderURLSession/URLRequestHandler.swift new file mode 100644 index 0000000..e693f8f --- /dev/null +++ b/Sources/EndpointBuilderURLSession/URLRequestHandler.swift @@ -0,0 +1,34 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public protocol URLRequestHandler: Sendable { + + func data(for request: URLRequest) async throws -> (Data, URLResponse) + +} + +extension URLSession: URLRequestHandler {} + +#if canImport(FoundationNetworking) +enum URLRequestHandlerError: Error { + case noResponse +} + +extension URLSession { + + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + self.dataTask(with: request) { data, response, error in + guard let data = data, let response = response else { + continuation.resume(throwing: error ?? URLRequestHandlerError.noResponse) + return + } + continuation.resume(returning: (data, response)) + }.resume() + } + } + +} +#endif diff --git a/Tests/EndpointBuilderURLSessionTests/EndpointBuilderURLSessionTests.swift b/Tests/EndpointBuilderURLSessionTests/EndpointBuilderURLSessionTests.swift new file mode 100644 index 0000000..20262a7 --- /dev/null +++ b/Tests/EndpointBuilderURLSessionTests/EndpointBuilderURLSessionTests.swift @@ -0,0 +1,77 @@ +import EndpointBuilder +@testable import EndpointBuilderURLSession +import HTTPTypes +import RoutingKit +import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct MockURLSession: URLRequestHandler { + + let requestHandler: @Sendable (URLRequest) -> Data + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let data = requestHandler(request) + return ( + data, + URLResponse(url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) + ) + } + +} + +final class EndpointBuilderURLSessionTests: XCTestCase { + + @Endpoint + struct EndpointWithNoResponse { + static let path: [PathComponent] = ["blank"] + static let httpMethod = HTTPRequest.Method.get + } + + @Endpoint + struct EndpointWithStringResponseAndPathComponent { + static let path: [PathComponent] = ["echo", ":id"] + static let httpMethod = HTTPRequest.Method.post + static let responseType = String.self + let body: String + } + + struct APIClient: EndpointBuilderURLSession { + + let serverBaseURL = URL(string: "https://api.shipyard.studio")! + let urlSession: @Sendable () -> URLRequestHandler + + init(mockSession: MockURLSession) { + let session: @Sendable () -> URLRequestHandler = { mockSession } + self.urlSession = session + } + + } + + func testEndpointWithNoResponse() async throws { + let endpoint = EndpointWithNoResponse() + let client = APIClient(mockSession: MockURLSession(requestHandler: { urlRequest in + XCTAssertEqual(urlRequest.url?.pathComponents, ["/", "blank"]) + XCTAssertEqual(urlRequest.httpMethod, "GET") + return Data() + })) + try await client.request(endpoint) + } + + func testEndpointWithStringResponse() async throws { + let endpoint = EndpointWithStringResponseAndPathComponent( + body: "hello", + pathParameters: EndpointWithStringResponseAndPathComponent.PathParameters(id: "my-ids") + ) + let client = APIClient(mockSession: MockURLSession(requestHandler: { urlRequest in + XCTAssertEqual(urlRequest.url?.pathComponents, ["/", "echo", "my-ids"]) + XCTAssertEqual(urlRequest.httpMethod, "POST") + XCTAssertEqual(urlRequest.httpBody, try? JSONEncoder().encode("hello")) + return (try? JSONEncoder().encode("world")) ?? Data() + })) + let response = try await client.request(endpoint) + XCTAssertEqual(response, "world") + } + +}