Skip to content

Commit 66668b2

Browse files
authored
Merge branch 'main' into hd-uri-decoder-refactor-deepObject
2 parents 331046f + 3d5d957 commit 66668b2

File tree

7 files changed

+298
-8
lines changed

7 files changed

+298
-8
lines changed

.editorconfig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 4
6+
end_of_line = lf
7+
insert_final_newline = true
8+
trim_trailing_whitespace = true

.licenseignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
**.txt
99
**Package.swift
1010
docker/*
11+
.editorconfig

Sources/OpenAPIRuntime/Conversion/Converter+Server.swift

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,21 @@ extension Converter {
5656
// Drop everything after the optional semicolon (q, extensions, ...)
5757
value.split(separator: ";")[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
5858
}
59-
6059
if acceptValues.isEmpty { return }
61-
if acceptValues.contains("*/*") { return }
62-
if acceptValues.contains("\(substring.split(separator: "/")[0].lowercased())/*") { return }
63-
if acceptValues.contains(where: { $0.localizedCaseInsensitiveContains(substring) }) { return }
60+
guard let parsedSubstring = OpenAPIMIMEType(substring) else {
61+
throw RuntimeError.invalidAcceptSubstring(substring)
62+
}
63+
// Look for the first match.
64+
for acceptValue in acceptValues {
65+
// Fast path.
66+
if acceptValue == substring { return }
67+
guard let parsedAcceptValue = OpenAPIMIMEType(acceptValue) else {
68+
throw RuntimeError.invalidExpectedContentType(acceptValue)
69+
}
70+
if parsedSubstring.satisfies(acceptValue: parsedAcceptValue) { return }
71+
}
6472
throw RuntimeError.unexpectedAcceptHeader(acceptHeader)
6573
}
66-
6774
/// Retrieves and decodes a path parameter as a URI-encoded value of the specified type.
6875
///
6976
/// - Parameters:
@@ -469,3 +476,27 @@ extension Converter {
469476
)
470477
}
471478
}
479+
480+
fileprivate extension OpenAPIMIMEType {
481+
/// Checks if the type satisfies the provided Accept header value.
482+
/// - Parameter acceptValue: A parsed Accept header MIME type.
483+
/// - Returns: `true` if it satisfies the Accept header, `false` otherwise.
484+
func satisfies(acceptValue: OpenAPIMIMEType) -> Bool {
485+
switch (acceptValue.kind, self.kind) {
486+
case (.concrete, .any), (.concrete, .anySubtype), (.anySubtype, .any):
487+
// The response content-type must be at least as specific as the accept header.
488+
return false
489+
case (.any, _):
490+
// Accept: */* -- Any content-type satisfies the accept header.
491+
return true
492+
case (.anySubtype(let acceptType), .anySubtype(let substringType)),
493+
(.anySubtype(let acceptType), .concrete(let substringType, _)):
494+
// Accept: type/* -- The content-type should match the partially-specified accept header.
495+
return acceptType.lowercased() == substringType.lowercased()
496+
case (.concrete(let acceptType, let acceptSubtype), .concrete(let substringType, let substringSubtype)):
497+
// Accept: type/subtype -- The content-type should match the concrete type.
498+
return acceptType.lowercased() == substringType.lowercased()
499+
&& acceptSubtype.lowercased() == substringSubtype.lowercased()
500+
}
501+
}
502+
}

Sources/OpenAPIRuntime/Errors/RuntimeError.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
2121
case invalidServerURL(String)
2222
case invalidServerVariableValue(name: String, value: String, allowedValues: [String])
2323
case invalidExpectedContentType(String)
24+
case invalidAcceptSubstring(String)
2425
case invalidHeaderFieldName(String)
2526
case invalidBase64String(String)
2627

@@ -85,6 +86,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret
8586
return
8687
"Invalid server variable named: '\(name)', which has the value: '\(value)', but the only allowed values are: \(allowedValues.map { "'\($0)'" }.joined(separator: ", "))"
8788
case .invalidExpectedContentType(let string): return "Invalid expected content type: '\(string)'"
89+
case .invalidAcceptSubstring(let string): return "Invalid Accept header content type: '\(string)'"
8890
case .invalidHeaderFieldName(let name): return "Invalid header field name: '\(name)'"
8991
case .invalidBase64String(let string):
9092
return "Invalid base64-encoded string (first 128 bytes): '\(string.prefix(128))'"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
17+
/// An opt-in error handling middleware that converts an error to an HTTP response.
18+
///
19+
/// Inclusion of ``ErrorHandlingMiddleware`` should be accompanied by conforming errors to the ``HTTPResponseConvertible`` protocol.
20+
/// Errors not conforming to ``HTTPResponseConvertible`` are converted to a response with the 500 status code.
21+
///
22+
/// ## Example usage
23+
///
24+
/// 1. Create an error type that conforms to the ``HTTPResponseConvertible`` protocol:
25+
///
26+
/// ```swift
27+
/// extension MyAppError: HTTPResponseConvertible {
28+
/// var httpStatus: HTTPResponse.Status {
29+
/// switch self {
30+
/// case .invalidInputFormat:
31+
/// .badRequest
32+
/// case .authorizationError:
33+
/// .forbidden
34+
/// }
35+
/// }
36+
/// }
37+
/// ```
38+
///
39+
/// 2. Opt into the ``ErrorHandlingMiddleware`` while registering the handler:
40+
///
41+
/// ```swift
42+
/// let handler = RequestHandler()
43+
/// try handler.registerHandlers(on: transport, middlewares: [ErrorHandlingMiddleware()])
44+
/// ```
45+
/// - Note: The placement of ``ErrorHandlingMiddleware`` in the middleware chain is important. It should be determined based on the specific needs of each application. Consider the order of execution and dependencies between middlewares.
46+
public struct ErrorHandlingMiddleware: ServerMiddleware {
47+
/// Creates a new middleware.
48+
public init() {}
49+
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
50+
public func intercept(
51+
_ request: HTTPTypes.HTTPRequest,
52+
body: OpenAPIRuntime.HTTPBody?,
53+
metadata: OpenAPIRuntime.ServerRequestMetadata,
54+
operationID: String,
55+
next: @Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata)
56+
async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?)
57+
) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
58+
do { return try await next(request, body, metadata) } catch {
59+
if let serverError = error as? ServerError,
60+
let appError = serverError.underlyingError as? (any HTTPResponseConvertible)
61+
{
62+
return (
63+
HTTPResponse(status: appError.httpStatus, headerFields: appError.httpHeaderFields),
64+
appError.httpBody
65+
)
66+
} else {
67+
return (HTTPResponse(status: .internalServerError), nil)
68+
}
69+
}
70+
}
71+
}
72+
73+
/// A value that can be converted to an HTTP response and body.
74+
///
75+
/// Conform your error type to this protocol to convert it to an `HTTPResponse` and ``HTTPBody``.
76+
///
77+
/// Used by ``ErrorHandlingMiddleware``.
78+
public protocol HTTPResponseConvertible {
79+
80+
/// An HTTP status to return in the response.
81+
var httpStatus: HTTPResponse.Status { get }
82+
83+
/// The HTTP header fields of the response.
84+
/// This is optional as default values are provided in the extension.
85+
var httpHeaderFields: HTTPTypes.HTTPFields { get }
86+
87+
/// The body of the HTTP response.
88+
var httpBody: OpenAPIRuntime.HTTPBody? { get }
89+
}
90+
91+
extension HTTPResponseConvertible {
92+
93+
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
94+
public var httpHeaderFields: HTTPTypes.HTTPFields { [:] }
95+
96+
// swift-format-ignore: AllPublicDeclarationsHaveDocumentation
97+
public var httpBody: OpenAPIRuntime.HTTPBody? { nil }
98+
}

Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,31 @@ final class Test_ServerConverterExtensions: Test_Runtime {
3939
.accept: "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
4040
]
4141
let multiple: HTTPFields = [.accept: "text/plain, application/json"]
42+
let params: HTTPFields = [.accept: "application/json; foo=bar"]
4243
let cases: [(HTTPFields, String, Bool)] = [
4344
// No Accept header, any string validates successfully
4445
(emptyHeaders, "foobar", true),
4546

46-
// Accept: */*, any string validates successfully
47-
(wildcard, "foobar", true),
47+
// Accept: */*, any MIME type validates successfully
48+
(wildcard, "foobaz/bar", true),
4849

4950
// Accept: text/*, so text/plain succeeds, application/json fails
5051
(partialWildcard, "text/plain", true), (partialWildcard, "application/json", false),
5152

5253
// Accept: text/plain, text/plain succeeds, application/json fails
53-
(short, "text/plain", true), (short, "application/json", false),
54+
(short, "text/plain", true), (short, "application/json", false), (short, "application/*", false),
55+
(short, "*/*", false),
5456

5557
// A bunch of acceptable content types
5658
(long, "text/html", true), (long, "application/xhtml+xml", true), (long, "application/xml", true),
5759
(long, "image/webp", true), (long, "application/json", true),
5860

5961
// Multiple values
6062
(multiple, "text/plain", true), (multiple, "application/json", true), (multiple, "application/xml", false),
63+
64+
// Params
65+
(params, "application/json; foo=bar", true), (params, "application/json; charset=utf-8; foo=bar", true),
66+
(params, "application/json", true), (params, "text/plain", false),
6167
]
6268
for (headers, contentType, success) in cases {
6369
if success {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import HTTPTypes
16+
17+
import XCTest
18+
@_spi(Generated) @testable import OpenAPIRuntime
19+
20+
final class Test_ErrorHandlingMiddlewareTests: XCTestCase {
21+
static let mockRequest: HTTPRequest = .init(soar_path: "http://abc.com", method: .get)
22+
static let mockBody: HTTPBody = HTTPBody("hello")
23+
static let errorHandlingMiddleware = ErrorHandlingMiddleware()
24+
25+
func testSuccessfulRequest() async throws {
26+
let response = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept(
27+
Test_ErrorHandlingMiddlewareTests.mockRequest,
28+
body: Test_ErrorHandlingMiddlewareTests.mockBody,
29+
metadata: .init(),
30+
operationID: "testop",
31+
next: getNextMiddleware(failurePhase: .never)
32+
)
33+
XCTAssertEqual(response.0.status, .ok)
34+
}
35+
36+
func testError_conformingToProtocol_convertedToResponse() async throws {
37+
let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept(
38+
Test_ErrorHandlingMiddlewareTests.mockRequest,
39+
body: Test_ErrorHandlingMiddlewareTests.mockBody,
40+
metadata: .init(),
41+
operationID: "testop",
42+
next: getNextMiddleware(failurePhase: .convertibleError)
43+
)
44+
XCTAssertEqual(response.status, .badGateway)
45+
XCTAssertEqual(response.headerFields, [.contentType: "application/json"])
46+
XCTAssertEqual(responseBody, testHTTPBody)
47+
}
48+
49+
func testError_conformingToProtocolWithoutAllValues_convertedToResponse() async throws {
50+
let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept(
51+
Test_ErrorHandlingMiddlewareTests.mockRequest,
52+
body: Test_ErrorHandlingMiddlewareTests.mockBody,
53+
metadata: .init(),
54+
operationID: "testop",
55+
next: getNextMiddleware(failurePhase: .partialConvertibleError)
56+
)
57+
XCTAssertEqual(response.status, .badRequest)
58+
XCTAssertEqual(response.headerFields, [:])
59+
XCTAssertEqual(responseBody, nil)
60+
}
61+
62+
func testError_notConformingToProtocol_returns500() async throws {
63+
let (response, responseBody) = try await Test_ErrorHandlingMiddlewareTests.errorHandlingMiddleware.intercept(
64+
Test_ErrorHandlingMiddlewareTests.mockRequest,
65+
body: Test_ErrorHandlingMiddlewareTests.mockBody,
66+
metadata: .init(),
67+
operationID: "testop",
68+
next: getNextMiddleware(failurePhase: .nonConvertibleError)
69+
)
70+
XCTAssertEqual(response.status, .internalServerError)
71+
XCTAssertEqual(response.headerFields, [:])
72+
XCTAssertEqual(responseBody, nil)
73+
}
74+
75+
private func getNextMiddleware(failurePhase: MockErrorMiddleware_Next.FailurePhase) -> @Sendable (
76+
HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata
77+
) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) {
78+
let mockNext:
79+
@Sendable (HTTPTypes.HTTPRequest, OpenAPIRuntime.HTTPBody?, OpenAPIRuntime.ServerRequestMetadata)
80+
async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) = { request, body, metadata in
81+
try await MockErrorMiddleware_Next(failurePhase: failurePhase)
82+
.intercept(
83+
request,
84+
body: body,
85+
metadata: metadata,
86+
operationID: "testop",
87+
next: { _, _, _ in (HTTPResponse.init(status: .ok), nil) }
88+
)
89+
}
90+
return mockNext
91+
}
92+
}
93+
94+
struct MockErrorMiddleware_Next: ServerMiddleware {
95+
enum FailurePhase {
96+
case never
97+
case convertibleError
98+
case nonConvertibleError
99+
case partialConvertibleError
100+
}
101+
var failurePhase: FailurePhase = .never
102+
103+
@Sendable func intercept(
104+
_ request: HTTPRequest,
105+
body: HTTPBody?,
106+
metadata: ServerRequestMetadata,
107+
operationID: String,
108+
next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?)
109+
) async throws -> (HTTPResponse, HTTPBody?) {
110+
var error: (any Error)?
111+
switch failurePhase {
112+
case .never: break
113+
case .convertibleError: error = ConvertibleError()
114+
case .nonConvertibleError: error = NonConvertibleError()
115+
case .partialConvertibleError: error = PartialConvertibleError()
116+
}
117+
if let underlyingError = error {
118+
throw ServerError(
119+
operationID: operationID,
120+
request: request,
121+
requestBody: body,
122+
requestMetadata: metadata,
123+
causeDescription: "",
124+
underlyingError: underlyingError
125+
)
126+
}
127+
let (response, responseBody) = try await next(request, body, metadata)
128+
return (response, responseBody)
129+
}
130+
}
131+
132+
struct ConvertibleError: Error, HTTPResponseConvertible {
133+
var httpStatus: HTTPTypes.HTTPResponse.Status = HTTPResponse.Status.badGateway
134+
var httpHeaderFields: HTTPFields = [.contentType: "application/json"]
135+
var httpBody: OpenAPIRuntime.HTTPBody? = testHTTPBody
136+
}
137+
138+
struct PartialConvertibleError: Error, HTTPResponseConvertible {
139+
var httpStatus: HTTPTypes.HTTPResponse.Status = HTTPResponse.Status.badRequest
140+
}
141+
142+
struct NonConvertibleError: Error {}
143+
144+
let testHTTPBody = HTTPBody(try! JSONEncoder().encode(["error", " test error"]))

0 commit comments

Comments
 (0)