Skip to content

Update to OpenAPI 0.3.0 #2

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

Merged
merged 10 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
.library(name: "OpenAPIVapor", targets: ["OpenAPIVapor"])
],
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.git", .upToNextMinor(from: "0.3.0")),
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")
],
targets: [
Expand Down
160 changes: 95 additions & 65 deletions Sources/OpenAPIVapor/VaporTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import Foundation
import OpenAPIRuntime
import HTTPTypes
import Vapor
import NIOFoundationCompat

Expand All @@ -31,24 +32,24 @@ public final class VaporTransport {

extension VaporTransport: ServerTransport {
public func register(
_ handler: @Sendable @escaping (OpenAPIRuntime.Request, OpenAPIRuntime.ServerRequestMetadata)
async throws -> OpenAPIRuntime.Response,
method: OpenAPIRuntime.HTTPMethod,
path: [RouterPathComponent],
queryItemNames: Set<String>
_ handler: @Sendable @escaping (
HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata
) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?),
method: HTTPRequest.Method,
path: String
) throws {
self.routesBuilder.on(
HTTPMethod(method),
path.map(Vapor.PathComponent.init(_:))
[PathComponent](path)
) { vaporRequest in
let request = try await OpenAPIRuntime.Request(vaporRequest)
let request = try HTTPTypes.HTTPRequest(vaporRequest)
let body = OpenAPIRuntime.HTTPBody(vaporRequest)
let requestMetadata = try OpenAPIRuntime.ServerRequestMetadata(
from: vaporRequest,
forPath: path,
extractingQueryItemNamed: queryItemNames
forPath: path
)
let response = try await handler(request, requestMetadata)
return Vapor.Response(response)
let response = try await handler(request, body, requestMetadata)
return Vapor.Response(response: response.0, body: response.1)
}
}
}
Expand All @@ -59,56 +60,65 @@ enum VaporTransportError: Error {
case missingRequiredPathParameter(String)
}

extension Vapor.PathComponent {
init(_ pathComponent: OpenAPIRuntime.RouterPathComponent) {
switch pathComponent {
case .constant(let value): self = .constant(value)
case .parameter(let value): self = .parameter(value)
extension [Vapor.PathComponent] {
init(_ path: String) {
self = path.split(
separator: "/",
omittingEmptySubsequences: false
).map { parameter in
if parameter.first == "{", parameter.last == "}" {
return .parameter(String(parameter.dropFirst().dropLast()))
} else {
return .constant(String(parameter))
}
}
}
}

extension OpenAPIRuntime.Request {
init(_ vaporRequest: Vapor.Request) async throws {
let headerFields: [OpenAPIRuntime.HeaderField] = .init(vaporRequest.headers)

let bodyData = Data(buffer: try await vaporRequest.body.collect(upTo: .max), byteTransferStrategy: .noCopy)

let method = try OpenAPIRuntime.HTTPMethod(vaporRequest.method)

extension HTTPTypes.HTTPRequest {
init(_ vaporRequest: Vapor.Request) throws {
let headerFields: HTTPTypes.HTTPFields = .init(vaporRequest.headers)
let method = try HTTPTypes.HTTPRequest.Method(vaporRequest.method)
let queries = vaporRequest.url.query.map { "?\($0)" } ?? ""
self.init(
path: vaporRequest.url.path,
query: vaporRequest.url.query,
method: method,
headerFields: headerFields,
body: bodyData
scheme: vaporRequest.url.scheme,
authority: vaporRequest.url.host,
path: vaporRequest.url.path + queries,
headerFields: headerFields
)
}
}

extension OpenAPIRuntime.ServerRequestMetadata {
init(
from vaporRequest: Vapor.Request,
forPath path: [RouterPathComponent],
extractingQueryItemNamed queryItemNames: Set<String>
) throws {
extension OpenAPIRuntime.HTTPBody {
convenience init(_ vaporRequest: Vapor.Request) {
let contentLength = vaporRequest.headers.first(name: "content-length").map(Int.init)
self.init(
pathParameters: try .init(from: vaporRequest, forPath: path),
queryParameters: .init(from: vaporRequest, queryItemNames: queryItemNames)
vaporRequest.body.map(\.readableBytesView),
length: contentLength?.map { .known($0) } ?? .unknown,
iterationBehavior: .single
)
}
}

extension Dictionary where Key == String, Value == String {
init(from vaporRequest: Vapor.Request, forPath path: [RouterPathComponent]) throws {
let keysAndValues = try path.compactMap { item -> (String, String)? in
guard case let .parameter(name) = item else {
extension OpenAPIRuntime.ServerRequestMetadata {
init(from vaporRequest: Vapor.Request, forPath path: String) throws {
self.init(pathParameters: try .init(from: vaporRequest, forPath: path))
}
}

extension Dictionary<String, Substring> {
init(from vaporRequest: Vapor.Request, forPath path: String) throws {
let keysAndValues = try [PathComponent](path).compactMap { component throws -> String? in
guard case let .parameter(parameter) = component else {
return nil
}
guard let value = vaporRequest.parameters.get(name) else {
throw VaporTransportError.missingRequiredPathParameter(name)
return parameter
}.map { parameter -> (String, Substring) in
guard let value = vaporRequest.parameters.get(parameter) else {
throw VaporTransportError.missingRequiredPathParameter(parameter)
}
return (name, value)
return (parameter, Substring(value))
}
let pathParameterDictionary = try Dictionary(keysAndValues, uniquingKeysWith: { _, _ in
throw VaporTransportError.duplicatePathParameter(keysAndValues.map(\.0))
Expand All @@ -117,41 +127,61 @@ extension Dictionary where Key == String, Value == String {
}
}

extension Array where Element == URLQueryItem {
init(from vaporRequest: Vapor.Request, queryItemNames: Set<String>) {
let queryParameters = queryItemNames.sorted().compactMap { name -> URLQueryItem? in
guard let value = try? vaporRequest.query.get(String.self, at: name) else {
return nil
}
return .init(name: name, value: value)
}
self = queryParameters
}
}

extension Vapor.Response {
convenience init(_ response: OpenAPIRuntime.Response) {
convenience init(response: HTTPTypes.HTTPResponse, body: OpenAPIRuntime.HTTPBody?) {
self.init(
status: .init(statusCode: response.statusCode),
status: .init(statusCode: response.status.code),
headers: .init(response.headerFields),
body: .init(data: response.body)
body: .init(body)
)
}
}

extension Array where Element == OpenAPIRuntime.HeaderField {
extension Vapor.Response.Body {
init(_ body: OpenAPIRuntime.HTTPBody?) {
guard let body else {
self = .empty
return
}
let stream: @Sendable (any Vapor.BodyStreamWriter) -> () = { writer in
_ = writer.eventLoop.makeFutureWithTask {
do {
for try await chunk in body {
try await writer.write(.buffer(ByteBuffer(bytes: chunk))).get()
}
try await writer.write(.end).get()
} catch {
try await writer.write(.error(error)).get()
}
}
}
switch body.length {
case let .known(count):
self = .init(stream: stream, count: count)
case .unknown:
self = .init(stream: stream)
}
}
}

extension HTTPTypes.HTTPFields {
init(_ headers: NIOHTTP1.HTTPHeaders) {
self = headers.map { .init(name: $0.name, value: $0.value) }
self.init(headers.compactMap { name, value in
guard let name = HTTPField.Name(name) else {
return nil
}
return HTTPField(name: name, value: value)
})
}
}

extension NIOHTTP1.HTTPHeaders {
init(_ headers: [OpenAPIRuntime.HeaderField]) {
self.init(headers.map { ($0.name, $0.value) })
init(_ headers: HTTPTypes.HTTPFields) {
self.init(headers.map { ($0.name.rawName, $0.value) })
}
}

extension OpenAPIRuntime.HTTPMethod {
extension HTTPTypes.HTTPRequest.Method {
init(_ method: NIOHTTP1.HTTPMethod) throws {
switch method {
case .GET: self = .get
Expand All @@ -168,7 +198,7 @@ extension OpenAPIRuntime.HTTPMethod {
}

extension NIOHTTP1.HTTPMethod {
init(_ method: OpenAPIRuntime.HTTPMethod) {
init(_ method: HTTPTypes.HTTPRequest.Method) {
switch method {
case .get: self = .GET
case .put: self = .PUT
Expand All @@ -178,7 +208,7 @@ extension NIOHTTP1.HTTPMethod {
case .head: self = .HEAD
case .patch: self = .PATCH
case .trace: self = .TRACE
default: self = .RAW(value: method.name)
default: self = .RAW(value: method.rawValue)
}
}
}
52 changes: 27 additions & 25 deletions Tests/OpenAPIVaporTests/VaporTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import XCTVapor
@testable import OpenAPIVapor
import HTTPTypes
import OpenAPIRuntime

final class VaporTransportTests: XCTestCase {
Expand All @@ -30,47 +31,47 @@ final class VaporTransportTests: XCTestCase {

func testRequestConversion() async throws {
// POST /hello/{name}
app.post("hello", ":name") { vaporRequest in
app.post("hello", ":name", "world") { vaporRequest in
// Hijack the request handler to test the request-conversion functions.
let expectedRequest = Request(
path: "/hello/Maria",
query: "greeting=Howdy",
let expectedRequest = HTTPTypes.HTTPRequest(
method: .post,
scheme: nil,
authority: nil,
path: "/hello/Maria/world?greeting=Howdy",
headerFields: [
.init(name: "X-Mumble", value: "mumble"),
.init(name: "content-length", value: "4")
],
body: Data("👋".utf8)
HTTPField.Name("X-Mumble")!: "mumble",
HTTPField.Name("content-length")!: "4",
]
)
let expectedRequestMetadata = ServerRequestMetadata(
pathParameters: [ "name": "Maria" ],
queryParameters: [ URLQueryItem(name: "greeting", value: "Howdy") ]
pathParameters: ["name": "Maria"]
)
let request = try await Request(vaporRequest)
let request = try HTTPTypes.HTTPRequest(vaporRequest)
let body = OpenAPIRuntime.HTTPBody(vaporRequest)
let collectedBody = try await [UInt8](collecting: body, upTo: .max)
XCTAssertEqual(request, expectedRequest)
XCTAssertEqual(collectedBody, [UInt8]("👋".utf8))
XCTAssertEqual(
try ServerRequestMetadata(
from: vaporRequest,
forPath: [.constant("hello"), .parameter("name")],
extractingQueryItemNamed: ["greeting"]
forPath: "/hello/{name}/world"
),
expectedRequestMetadata
)

// Use the response-conversion to create the Vapor response for returning.
let response = Response(
statusCode: 201,
let response = HTTPTypes.HTTPResponse(
status: .created,
headerFields: [
.init(name: "X-Mumble", value: "mumble")
],
body: Data("👋".utf8)
HTTPField.Name("X-Mumble")!: "mumble"
]
)
return Vapor.Response(response)
return Vapor.Response(response: response, body: .init([UInt8]("👋".utf8)))
}

try app.test(
.POST,
"/hello/Maria?greeting=Howdy",
"/hello/Maria/world?greeting=Howdy",
headers: ["X-Mumble": "mumble"],
body: ByteBuffer(string: "👋"),
afterResponse: { response in
Expand All @@ -81,18 +82,19 @@ final class VaporTransportTests: XCTestCase {

func testHandlerRegistration() throws {
let transport = VaporTransport(routesBuilder: app)
try transport.register({ _, _ in OpenAPIRuntime.Response(statusCode: 201) },
let response = HTTPTypes.HTTPResponse(status: .created)
try transport.register(
{ _, _, _ in (response, nil) },
method: .post,
path: [.constant("hello"), .parameter("name")],
queryItemNames: ["greeting"]
path: "/hello/{name}"
)
try app.test(
.POST,
"/hello/Maria?greeting=Howdy",
headers: ["X-Mumble": "mumble"],
body: ByteBuffer(string: "👋"),
afterResponse: { response in
XCTAssertEqual(response.status.code, 201)
XCTAssertEqual(response.status, .created)
}
)
}
Expand All @@ -108,7 +110,7 @@ final class VaporTransportTests: XCTestCase {
(.patch, .PATCH),
(.trace, .TRACE)
])
try XCTAssert(function: OpenAPIRuntime.HTTPMethod.init(_:), behavesAccordingTo: [
try XCTAssert(function: HTTPTypes.HTTPRequest.Method.init(_:), behavesAccordingTo: [
(.GET, .get),
(.PUT, .put),
(.POST, .post),
Expand Down