Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[URLSession Transport] Async bodies + swift-http-types adoption #15

Merged
merged 11 commits into from
Oct 2, 2023
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ let package = Package(
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-openapi-runtime", "0.1.3" ..< "0.3.0"),
.package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.3.0")),
.package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
],
targets: [
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Add the package dependency in your `Package.swift`:
```swift
.package(
url: "https://github.com/apple/swift-openapi-urlsession",
.upToNextMinor(from: "0.2.0")
.upToNextMinor(from: "0.3.0")
),
```

Expand Down
4 changes: 2 additions & 2 deletions Sources/OpenAPIURLSession/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use the transport with client code generated by [Swift OpenAPI Generator](https:
### Supported platforms and minimum versions
| macOS | Linux | iOS | tvOS | watchOS |
| :-: | :-: | :-: | :-: | :-: |
| ✅ 10.15+ | ✅ | ✅ 13+ | ✅ 13+ | ✅ 6+ |
| ✅ 10.15+ | ✅ | ✅ 13+ | ✅ 13+ | ✅ 6+ |

### Usage

Expand All @@ -20,7 +20,7 @@ Add the package dependency in your `Package.swift`:
```swift
.package(
url: "https://github.com/apple/swift-openapi-urlsession",
.upToNextMinor(from: "0.2.0")
.upToNextMinor(from: "0.3.0")
),
```

Expand Down
95 changes: 68 additions & 27 deletions Sources/OpenAPIURLSession/URLSessionTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//
//===----------------------------------------------------------------------===//
import OpenAPIRuntime
import HTTPTypes
#if canImport(Darwin)
import Foundation
#else
Expand Down Expand Up @@ -90,13 +91,19 @@ public struct URLSessionTransport: ClientTransport {
}

public func send(
_ request: OpenAPIRuntime.Request,
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String
) async throws -> OpenAPIRuntime.Response {
let urlRequest = try URLRequest(request, baseURL: baseURL)
) async throws -> (HTTPResponse, HTTPBody?) {
// TODO: Investigate how to get bidirectional streaming working.
let urlRequest = try await URLRequest(request, body: body, baseURL: baseURL)
let (responseBody, urlResponse) = try await invokeSession(urlRequest)
return try OpenAPIRuntime.Response(from: urlResponse, body: responseBody)
return try HTTPResponse.response(
method: request.method,
urlResponse: urlResponse,
data: responseBody
)
}

private func invokeSession(_ urlRequest: URLRequest) async throws -> (Data, URLResponse) {
Expand Down Expand Up @@ -129,7 +136,7 @@ public struct URLSessionTransport: ClientTransport {
internal enum URLSessionTransportError: Error {

/// Invalid URL composed from base URL and received request.
case invalidRequestURL(request: OpenAPIRuntime.Request, baseURL: URL)
case invalidRequestURL(path: String, method: HTTPRequest.Method, baseURL: URL)

/// Returned `URLResponse` could not be converted to `HTTPURLResponse`.
case notHTTPResponse(URLResponse)
Expand All @@ -138,40 +145,74 @@ internal enum URLSessionTransportError: Error {
case noResponse(url: URL?)
}

extension OpenAPIRuntime.Response {
init(from urlResponse: URLResponse, body: Data) throws {
extension HTTPResponse {
static func response(
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
method: HTTPRequest.Method,
urlResponse: URLResponse,
data: Data
) throws -> (HTTPResponse, HTTPBody?) {
guard let httpResponse = urlResponse as? HTTPURLResponse else {
throw URLSessionTransportError.notHTTPResponse(urlResponse)
}
let headerFields: [HeaderField] = httpResponse
.allHeaderFields
.compactMap { headerName, headerValue in
guard let name = headerName as? String, let value = headerValue as? String else {
return nil
}
return HeaderField(name: name, value: value)
var headerFields = HTTPFields()
for (headerName, headerValue) in httpResponse.allHeaderFields {
guard
let rawName = headerName as? String,
let name = HTTPField.Name(rawName),
let value = headerValue as? String
else {
continue
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
}
self.init(statusCode: httpResponse.statusCode, headerFields: headerFields, body: body)
headerFields[name] = value
}
let body: HTTPBody?
switch method {
case .head, .connect, .trace:
body = nil
default:
body = .init(data)
}
simonjbeaumont marked this conversation as resolved.
Show resolved Hide resolved
return (
HTTPResponse(
status: .init(code: httpResponse.statusCode),
headerFields: headerFields
),
body
)
}
}

extension URLRequest {
init(_ request: OpenAPIRuntime.Request, baseURL: URL) throws {
guard var baseUrlComponents = URLComponents(string: baseURL.absoluteString) else {
throw URLSessionTransportError.invalidRequestURL(request: request, baseURL: baseURL)
init(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL) async throws {
guard
var baseUrlComponents = URLComponents(string: baseURL.absoluteString),
let requestUrlComponents = URLComponents(string: request.path ?? "")
else {
throw URLSessionTransportError.invalidRequestURL(
path: request.path ?? "<nil>",
method: request.method,
baseURL: baseURL
)
}
baseUrlComponents.percentEncodedPath += request.path
baseUrlComponents.percentEncodedQuery = request.query

let path = requestUrlComponents.percentEncodedPath
baseUrlComponents.percentEncodedPath += path
baseUrlComponents.percentEncodedQuery = requestUrlComponents.percentEncodedQuery
guard let url = baseUrlComponents.url else {
throw URLSessionTransportError.invalidRequestURL(request: request, baseURL: baseURL)
throw URLSessionTransportError.invalidRequestURL(
path: path,
method: request.method,
baseURL: baseURL
)
}
self.init(url: url)
self.httpMethod = request.method.name
self.httpMethod = request.method.rawValue
for header in request.headerFields {
self.addValue(header.value, forHTTPHeaderField: header.name)
self.setValue(header.value, forHTTPHeaderField: header.name.canonicalName)
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
}
if let body = request.body {
self.httpBody = body
if let body {
// TODO: Avoid buffering, stream intead.
self.httpBody = try await Data(collecting: body, upTo: .max)
}
}
}
Expand All @@ -183,9 +224,9 @@ extension URLSessionTransportError: LocalizedError {
extension URLSessionTransportError: CustomStringConvertible {
public var description: String {
switch self {
case let .invalidRequestURL(request: request, baseURL: baseURL):
case let .invalidRequestURL(path: path, method: method, baseURL: baseURL):
return
"Invalid request URL from request path: \(request.path), query: \(request.query ?? "<nil>") relative to base URL: \(baseURL.absoluteString)"
"Invalid request URL from request path: \(path), method: \(method), relative to base URL: \(baseURL.absoluteString)"
case .notHTTPResponse(let response):
return "Received a non-HTTP response, of type: \(String(describing: type(of: response)))"
case .noResponse(let url):
Expand Down
66 changes: 42 additions & 24 deletions Tests/OpenAPIURLSessionTests/URLSessionTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,41 +27,54 @@ import Foundation
@preconcurrency import class FoundationNetworking.URLSessionConfiguration
#endif
@testable import OpenAPIURLSession
import HTTPTypes

class URLSessionTransportTests: XCTestCase {

func testRequestConversion() throws {
let request = OpenAPIRuntime.Request(
path: "/hello%20world/Maria",
query: "greeting=Howdy",
func testRequestConversion() async throws {
let request = HTTPRequest(
method: .post,
scheme: nil,
authority: nil,
path: "/hello%20world/Maria?greeting=Howdy",
headerFields: [
.init(name: "X-Mumble", value: "mumble")
],
body: Data("👋".utf8)
.init("x-mumble2")!: "mumble"
]
)
let body: HTTPBody = "👋"
let urlRequest = try await URLRequest(
request,
body: body,
baseURL: URL(string: "http://example.com/api")!
)
let urlRequest = try URLRequest(request, baseURL: URL(string: "http://example.com/api")!)
XCTAssertEqual(urlRequest.url, URL(string: "http://example.com/api/hello%20world/Maria?greeting=Howdy"))
XCTAssertEqual(urlRequest.httpMethod, "POST")
XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["X-Mumble": "mumble"])
czechboy0 marked this conversation as resolved.
Show resolved Hide resolved
XCTAssertEqual(urlRequest.allHTTPHeaderFields?.count, 1)
XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "x-mumble2"), "mumble")
XCTAssertEqual(urlRequest.httpBody, Data("👋".utf8))
}

func testResponseConversion() throws {
func testResponseConversion() async throws {
let urlResponse: URLResponse = HTTPURLResponse(
url: URL(string: "http://example.com/api/hello/Maria?greeting=Howdy")!,
url: URL(string: "http://example.com/api/hello%20world/Maria?greeting=Howdy")!,
statusCode: 201,
httpVersion: "HTTP/1.1",
headerFields: ["X-Mumble": "mumble"]
headerFields: ["x-mumble3": "mumble"]
)!
let response = try OpenAPIRuntime.Response(from: urlResponse, body: Data("👋".utf8))
XCTAssertEqual(response.statusCode, 201)
XCTAssertEqual(response.headerFields, [.init(name: "X-Mumble", value: "mumble")])
XCTAssertEqual(response.body, Data("👋".utf8))
let (response, maybeResponseBody) = try HTTPResponse.response(
method: .get,
urlResponse: urlResponse,
data: Data("👋".utf8)
)
let responseBody = try XCTUnwrap(maybeResponseBody)
XCTAssertEqual(response.status.code, 201)
XCTAssertEqual(response.headerFields, [.init("x-mumble3")!: "mumble"])
let bufferedResponseBody = try await String(collecting: responseBody, upTo: .max)
XCTAssertEqual(bufferedResponseBody, "👋")
}

func testSend() async throws {
let endpointURL = URL(string: "http://example.com/api/hello/Maria?greeting=Howdy")!
let endpointURL = URL(string: "http://example.com/api/hello%20world/Maria?greeting=Howdy")!
MockURLProtocol.mockHTTPResponses.withValue { map in
map[endpointURL] = .success(
(
Expand All @@ -73,21 +86,26 @@ class URLSessionTransportTests: XCTestCase {
let transport: any ClientTransport = URLSessionTransport(
configuration: .init(session: MockURLProtocol.mockURLSession)
)
let request = OpenAPIRuntime.Request(
path: "/hello/Maria",
query: "greeting=Howdy",
let request = HTTPRequest(
method: .post,
scheme: nil,
authority: nil,
path: "/hello%20world/Maria?greeting=Howdy",
headerFields: [
.init(name: "X-Mumble", value: "mumble")
.init("x-mumble1")!: "mumble"
]
)
let response = try await transport.send(
let requestBody: HTTPBody = "👋"
let (response, maybeResponseBody) = try await transport.send(
request,
body: requestBody,
baseURL: URL(string: "http://example.com/api")!,
operationID: "postGreeting"
)
XCTAssertEqual(response.statusCode, 201)
XCTAssertEqual(response.body, Data("👋".utf8))
let responseBody = try XCTUnwrap(maybeResponseBody)
XCTAssertEqual(response.status.code, 201)
let bufferedResponseBody = try await String(collecting: responseBody, upTo: .max)
XCTAssertEqual(bufferedResponseBody, "👋")
}
}

Expand Down