Initial code commit

This commit is contained in:
Tim
2023-05-11 17:23:26 +01:00
parent 4bcfed11d3
commit 6438206556
4 changed files with 337 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.DS_Store
/.build
/.vscode
/.devcontainer
/Packages
Package.resolved
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+36
View File
@@ -0,0 +1,36 @@
// swift-tools-version: 5.8
import PackageDescription
let package = Package(
name: "swift-openapi-vapor",
platforms: [
.macOS(.v13),
.iOS(.v16),
.tvOS(.v16),
.watchOS(.v9),
],
products: [
.library(name: "VaporOpenAPIRuntime", targets: ["VaporOpenAPIRuntime"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-openapi-runtime.git", branch: "main"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
],
targets: [
.target(
name: "VaporOpenAPIRuntime",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
]
),
.testTarget(
name: "VaporOpenAPIRuntimeTests",
dependencies: [
"VaporOpenAPIRuntime",
.product(name: "XCTVapor", package: "vapor"),
]
),
]
)
@@ -0,0 +1,169 @@
import Foundation
import OpenAPIRuntime
import Vapor
import NIOFoundationCompat
public final class VaporTransport {
/// A routes builder with which to register request handlers.
internal var routesBuilder: Vapor.RoutesBuilder
/// Creates a new transport.
/// - Parameter routesBuilder: A routes builder with which to register request handlers.
public init(routesBuilder: Vapor.RoutesBuilder) {
self.routesBuilder = routesBuilder
}
}
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>
) throws {
self.routesBuilder.on(
HTTPMethod(method),
path.map(Vapor.PathComponent.init(_:))
) { vaporRequest in
let request = try await OpenAPIRuntime.Request(vaporRequest)
let requestMetadata = try OpenAPIRuntime.ServerRequestMetadata(
from: vaporRequest,
forPath: path,
extractingQueryItemNamed: queryItemNames
)
let response = try await handler(request, requestMetadata)
return Vapor.Response(response)
}
}
}
enum VaporTransportError: Error {
case unsupportedHTTPMethod(String)
case duplicatePathParameter([String])
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 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)
self.init(
path: vaporRequest.url.path,
query: vaporRequest.url.query,
method: method,
headerFields: headerFields,
body: bodyData
)
}
}
extension OpenAPIRuntime.ServerRequestMetadata {
init(
from vaporRequest: Vapor.Request,
forPath path: [RouterPathComponent],
extractingQueryItemNamed queryItemNames: Set<String>
) throws {
self.init(
pathParameters: try .init(from: vaporRequest, forPath: path),
queryParameters: .init(from: vaporRequest, queryItemNames: queryItemNames)
)
}
}
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 {
return nil
}
guard let value = vaporRequest.parameters.get(name) else {
throw VaporTransportError.missingRequiredPathParameter(name)
}
return (name, value)
}
let pathParameterDictionary = try Dictionary(keysAndValues, uniquingKeysWith: { _, _ in
throw VaporTransportError.duplicatePathParameter(keysAndValues.map(\.0))
})
self = pathParameterDictionary
}
}
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) {
self.init(
status: .init(statusCode: response.statusCode),
headers: .init(response.headerFields),
body: .init(data: response.body)
)
}
}
extension Array where Element == OpenAPIRuntime.HeaderField {
init(_ headers: NIOHTTP1.HTTPHeaders) {
self = headers.map { .init(name: $0.name, value: $0.value) }
}
}
extension NIOHTTP1.HTTPHeaders {
init(_ headers: [OpenAPIRuntime.HeaderField]) {
self.init(headers.map { ($0.name, $0.value) })
}
}
extension OpenAPIRuntime.HTTPMethod {
init(_ method: NIOHTTP1.HTTPMethod) throws {
switch method {
case .GET: self = .get
case .PUT: self = .put
case .POST: self = .post
case .DELETE: self = .delete
case .OPTIONS: self = .options
case .HEAD: self = .head
case .PATCH: self = .patch
case .TRACE: self = .trace
default: throw VaporTransportError.unsupportedHTTPMethod(method.rawValue)
}
}
}
extension NIOHTTP1.HTTPMethod {
init(_ method: OpenAPIRuntime.HTTPMethod) {
switch method {
case .get: self = .GET
case .put: self = .PUT
case .post: self = .POST
case .delete: self = .DELETE
case .options: self = .OPTIONS
case .head: self = .HEAD
case .patch: self = .PATCH
case .trace: self = .TRACE
default: self = .RAW(value: method.name)
}
}
}
@@ -0,0 +1,120 @@
import XCTVapor
@testable import VaporOpenAPIRuntime
import OpenAPIRuntime
final class VaporTransportTests: XCTestCase {
var app: Application!
override func setUp() async throws {
app = Application(.testing)
}
override func tearDown() async throws {
app.shutdown()
}
func testRequestConversion() async throws {
// POST /hello/{name}
app.post("hello", ":name") { vaporRequest in
// Hijack the request handler to test the request-conversion functions.
let expectedRequest = Request(
path: "/hello/Maria",
query: "greeting=Howdy",
method: .post,
headerFields: [
.init(name: "X-Mumble", value: "mumble"),
.init(name: "content-length", value: "4"),
],
body: Data("👋".utf8)
)
let expectedRequestMetadata = ServerRequestMetadata(
pathParameters: [ "name": "Maria" ],
queryParameters: [ URLQueryItem(name: "greeting", value: "Howdy") ]
)
let request = try await Request(vaporRequest)
XCTAssertEqual(request, expectedRequest)
XCTAssertEqual(
try ServerRequestMetadata(
from: vaporRequest,
forPath: [.constant("hello"), .parameter("name")],
extractingQueryItemNamed: ["greeting"]
),
expectedRequestMetadata
)
// Use the response-conversion to create the Vapor response for returning.
let response = Response(
statusCode: 201,
headerFields: [
.init(name: "X-Mumble", value: "mumble"),
],
body: Data("👋".utf8)
)
return Vapor.Response(response)
}
try app.test(
.POST,
"/hello/Maria?greeting=Howdy",
headers: ["X-Mumble": "mumble"],
body: ByteBuffer(string: "👋"),
afterResponse: { response in
XCTAssertEqual(response.status.code, 201)
}
)
}
func testHandlerRegistration() throws {
let transport = VaporTransport(routesBuilder: app)
try transport.register(
{ _, _ in OpenAPIRuntime.Response(statusCode: 201) },
method: .post,
path: [.constant("hello"), .parameter("name")],
queryItemNames: ["greeting"]
)
try app.test(
.POST,
"/hello/Maria?greeting=Howdy",
headers: ["X-Mumble": "mumble"],
body: ByteBuffer(string: "👋"),
afterResponse: { response in
XCTAssertEqual(response.status.code, 201)
}
)
}
func testHTTPMethodConversion() throws {
XCTAssert(function: NIOHTTP1.HTTPMethod.init(_:), behavesAccordingTo: [
(.get, .GET),
(.put, .PUT),
(.post, .POST),
(.delete, .DELETE),
(.options, .OPTIONS),
(.head, .HEAD),
(.patch, .PATCH),
(.trace, .TRACE),
])
try XCTAssert(function: OpenAPIRuntime.HTTPMethod.init(_:), behavesAccordingTo: [
(.GET, .get),
(.PUT, .put),
(.POST, .post),
(.DELETE, .delete),
(.OPTIONS, .options),
(.HEAD, .head),
(.PATCH, .patch),
(.TRACE, .trace),
])
}
}
fileprivate func XCTAssert<Input, Output>(
function: (Input) throws -> Output,
behavesAccordingTo expectations: [(Input, Output)],
file: StaticString = #file,
line: UInt = #line
) rethrows where Output: Equatable {
for (input, output) in expectations {
try XCTAssertEqual(function(input), output, file: file, line: line)
}
}