From 8d2b9ba946df9cab29bb1531786e91205c44dc0f Mon Sep 17 00:00:00 2001 From: colin-dignazio Date: Mon, 27 Oct 2025 15:48:51 -0700 Subject: [PATCH 1/4] Convert DecodingErrors thrown from request handling code to HTTP 400 responses --- .../OpenAPIRuntime/Errors/RuntimeError.swift | 6 +++- .../OpenAPIRuntime/Errors/ServerError.swift | 22 ++++++++++++-- .../Interface/ErrorHandlingMiddleware.swift | 23 +++++++++------ .../Interface/UniversalServer.swift | 21 ++++++++++++-- .../Errors/Test_ClientError.swift | 5 +++- .../Test_ErrorHandlingMiddleware.swift | 5 +++- .../Interface/Test_UniversalServer.swift | 29 +++++++++++++++++++ 7 files changed, 95 insertions(+), 16 deletions(-) diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 2c3260ac..95c29913 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -54,6 +54,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret // Body case missingRequiredRequestBody case missingRequiredResponseBody + case failedToParseRequest(DecodingError) // Multipart case missingRequiredMultipartFormDataContentType @@ -72,6 +73,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret var underlyingError: (any Error)? { switch self { case .transportFailed(let error), .handlerFailed(let error), .middlewareFailed(_, let error): return error + case .failedToParseRequest(let decodingError): return decodingError default: return nil } } @@ -119,6 +121,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unexpected response, expected status code: \(expectedStatus), response: \(response)" case .unexpectedResponseBody(let expectedContentType, let body): return "Unexpected response body, expected content type: \(expectedContentType), body: \(body)" + case .failedToParseRequest(let decodingError): + return "An error occurred while attempting to parse the request: \(decodingError.prettyDescription)." } } @@ -160,7 +164,7 @@ extension RuntimeError: HTTPResponseConvertible { .invalidHeaderFieldName, .malformedAcceptHeader, .missingMultipartBoundaryContentTypeParameter, .missingOrMalformedContentDispositionName, .missingRequiredHeaderField, .missingRequiredMultipartFormDataContentType, .missingRequiredQueryParameter, .missingRequiredPathParameter, - .missingRequiredRequestBody, .unsupportedParameterStyle: + .missingRequiredRequestBody, .unsupportedParameterStyle, .failedToParseRequest: .badRequest case .handlerFailed, .middlewareFailed, .missingRequiredResponseBody, .transportFailed, .unexpectedResponseStatus, .unexpectedResponseBody: diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 13288a9c..a1668c99 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -16,7 +16,7 @@ import HTTPTypes import protocol Foundation.LocalizedError /// An error thrown by a server handling an OpenAPI operation. -public struct ServerError: Error { +public struct ServerError: Error, HTTPResponseConvertible { /// Identifier of the operation that threw the error. public var operationID: String @@ -47,6 +47,15 @@ public struct ServerError: Error { /// The underlying error that caused the operation to fail. public var underlyingError: any Error + /// An HTTP status to return in the response. + public var httpStatus: HTTPResponse.Status + + /// The HTTP header fields of the response. + public var httpHeaderFields: HTTPTypes.HTTPFields + + /// The body of the HTTP response. + public var httpBody: OpenAPIRuntime.HTTPBody? + /// Creates a new error. /// - Parameters: /// - operationID: The OpenAPI operation identifier. @@ -59,6 +68,9 @@ public struct ServerError: Error { /// the underlying error to be thrown. /// - underlyingError: The underlying error that caused the operation /// to fail. + /// - httpStatus: An HTTP status to return in the response. + /// - httpHeaderFields: The HTTP header fields of the response. + /// - httpBody: The body of the HTTP response. public init( operationID: String, request: HTTPRequest, @@ -67,7 +79,10 @@ public struct ServerError: Error { operationInput: (any Sendable)? = nil, operationOutput: (any Sendable)? = nil, causeDescription: String, - underlyingError: any Error + underlyingError: any Error, + httpStatus: HTTPResponse.Status, + httpHeaderFields: HTTPTypes.HTTPFields, + httpBody: OpenAPIRuntime.HTTPBody? ) { self.operationID = operationID self.request = request @@ -77,6 +92,9 @@ public struct ServerError: Error { self.operationOutput = operationOutput self.causeDescription = causeDescription self.underlyingError = underlyingError + self.httpStatus = httpStatus + self.httpHeaderFields = httpHeaderFields + self.httpBody = httpBody } // MARK: Private diff --git a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift index 48c3cabc..d27ba0c4 100644 --- a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift +++ b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift @@ -57,16 +57,21 @@ public struct ErrorHandlingMiddleware: ServerMiddleware { async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { do { return try await next(request, body, metadata) } catch { - if let serverError = error as? ServerError, - let appError = serverError.underlyingError as? (any HTTPResponseConvertible) - { - return ( - HTTPResponse(status: appError.httpStatus, headerFields: appError.httpHeaderFields), - appError.httpBody - ) - } else { - return (HTTPResponse(status: .internalServerError), nil) + if let serverError = error as? ServerError { + if let appError = serverError.underlyingError as? (any HTTPResponseConvertible) { + return ( + HTTPResponse(status: appError.httpStatus, headerFields: appError.httpHeaderFields), + appError.httpBody + ) + } else { + return ( + HTTPResponse(status: serverError.httpStatus, headerFields: serverError.httpHeaderFields), + serverError.httpBody + ) + } } + + return (HTTPResponse(status: .internalServerError), nil) } } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 4fb6bc82..d7f5f393 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -112,12 +112,22 @@ import struct Foundation.URLComponents } let causeDescription: String let underlyingError: any Error + let httpStatus: HTTPResponse.Status + let httpHeaderFields: HTTPTypes.HTTPFields + let httpBody: OpenAPIRuntime.HTTPBody? if let runtimeError = error as? RuntimeError { causeDescription = runtimeError.prettyDescription underlyingError = runtimeError.underlyingError ?? error + httpStatus = runtimeError.httpStatus + httpHeaderFields = runtimeError.httpHeaderFields + httpBody = runtimeError.httpBody + } else { causeDescription = "Unknown" underlyingError = error + httpStatus = .internalServerError + httpHeaderFields = [:] + httpBody = nil } return ServerError( operationID: operationID, @@ -127,13 +137,20 @@ import struct Foundation.URLComponents operationInput: input, operationOutput: output, causeDescription: causeDescription, - underlyingError: underlyingError + underlyingError: underlyingError, + httpStatus: httpStatus, + httpHeaderFields: httpHeaderFields, + httpBody: httpBody ) } var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = { _request, _requestBody, _metadata in let input: OperationInput = try await wrappingErrors { - try await deserializer(_request, _requestBody, _metadata) + do { + return try await deserializer(_request, _requestBody, _metadata) + } catch let decodingError as DecodingError { + throw RuntimeError.failedToParseRequest(decodingError) + } } mapError: { error in makeError(error: error) } diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift index f7198fc9..d7dd836a 100644 --- a/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_ClientError.swift @@ -25,7 +25,10 @@ final class Test_ServerError: XCTestCase { requestBody: nil, requestMetadata: .init(), causeDescription: upstreamError.prettyDescription, - underlyingError: upstreamError.underlyingError ?? upstreamError + underlyingError: upstreamError.underlyingError ?? upstreamError, + httpStatus: .internalServerError, + httpHeaderFields: [:], + httpBody: nil ) XCTAssertEqual( "\(error)", diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift index eceb59e0..f3b9820f 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift @@ -122,7 +122,10 @@ struct MockErrorMiddleware_Next: ServerMiddleware { requestBody: body, requestMetadata: metadata, causeDescription: "", - underlyingError: underlyingError + underlyingError: underlyingError, + httpStatus: .internalServerError, + httpHeaderFields: [:], + httpBody: nil ) } let (response, responseBody) = try await next(request, body, metadata) diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift index e65afe4f..1e2481a1 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift @@ -101,6 +101,35 @@ final class Test_UniversalServer: Test_Runtime { } } + func testErrorPropagation_deserializerWithDecodingError() async throws { + let decodingError = DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid request body.")) + do { + let server = UniversalServer(handler: MockHandler()) + _ = try await server.handle( + request: .init(soar_path: "/", method: .post), + requestBody: MockHandler.requestBody, + metadata: .init(), + forOperation: "op", + using: { MockHandler.greet($0) }, + deserializer: { request, body, metadata in throw decodingError }, + serializer: { output, _ in fatalError() } + ) + } catch { + let serverError = try XCTUnwrap(error as? ServerError) + XCTAssertEqual(serverError.operationID, "op") + XCTAssert(serverError.causeDescription.contains("An error occurred while attempting to parse the request")) + XCTAssert(serverError.underlyingError is DecodingError) + XCTAssertEqual(serverError.httpStatus, .badRequest) + XCTAssertEqual(serverError.httpHeaderFields, [:]) + XCTAssertNil(serverError.httpBody) + XCTAssertEqual(serverError.request, .init(soar_path: "/", method: .post)) + XCTAssertEqual(serverError.requestBody, MockHandler.requestBody) + XCTAssertEqual(serverError.requestMetadata, .init()) + XCTAssertNil(serverError.operationInput) + XCTAssertNil(serverError.operationOutput) + } + } + func testErrorPropagation_handler() async throws { do { let server = UniversalServer(handler: MockHandler(shouldFail: true)) From 04a3cecf47586affc80c9c4cc7662ad830604ce5 Mon Sep 17 00:00:00 2001 From: colin-dignazio Date: Wed, 29 Oct 2025 12:00:53 -0700 Subject: [PATCH 2/4] Add default initializer for ServerError --- .../OpenAPIRuntime/Errors/ServerError.swift | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index a1668c99..64a5d024 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -56,6 +56,43 @@ public struct ServerError: Error, HTTPResponseConvertible { /// The body of the HTTP response. public var httpBody: OpenAPIRuntime.HTTPBody? + /// Creates a new error. + /// - Parameters: + /// - operationID: The OpenAPI operation identifier. + /// - request: The HTTP request provided to the server. + /// - requestBody: The HTTP request body provided to the server. + /// - requestMetadata: The request metadata extracted by the server. + /// - operationInput: An operation-specific Input value. + /// - operationOutput: An operation-specific Output value. + /// - causeDescription: A user-facing description of what caused + /// the underlying error to be thrown. + /// - underlyingError: The underlying error that caused the operation + /// to fail. + public init( + operationID: String, + request: HTTPRequest, + requestBody: HTTPBody?, + requestMetadata: ServerRequestMetadata, + operationInput: (any Sendable)? = nil, + operationOutput: (any Sendable)? = nil, + causeDescription: String, + underlyingError: any Error + ) { + self.init( + operationID: operationID, + request: request, + requestBody: requestBody, + requestMetadata: requestMetadata, + operationInput: operationInput, + operationOutput: operationOutput, + causeDescription: causeDescription, + underlyingError: underlyingError, + httpStatus: .internalServerError, + httpHeaderFields: [:], + httpBody: nil + ) + } + /// Creates a new error. /// - Parameters: /// - operationID: The OpenAPI operation identifier. From a26aa63fd022ba45ac5577d6afce0c3d41a82290 Mon Sep 17 00:00:00 2001 From: colin-dignazio Date: Mon, 3 Nov 2025 09:49:47 -0800 Subject: [PATCH 3/4] Address comments --- .../OpenAPIRuntime/Errors/ServerError.swift | 19 ++++++++++++++--- .../Interface/ErrorHandlingMiddleware.swift | 19 ++++++----------- .../Interface/UniversalServer.swift | 21 ++++++++++++------- .../Test_ErrorHandlingMiddleware.swift | 3 --- 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/Sources/OpenAPIRuntime/Errors/ServerError.swift b/Sources/OpenAPIRuntime/Errors/ServerError.swift index 64a5d024..adcb2687 100644 --- a/Sources/OpenAPIRuntime/Errors/ServerError.swift +++ b/Sources/OpenAPIRuntime/Errors/ServerError.swift @@ -78,6 +78,19 @@ public struct ServerError: Error, HTTPResponseConvertible { causeDescription: String, underlyingError: any Error ) { + let httpStatus: HTTPResponse.Status + let httpHeaderFields: HTTPTypes.HTTPFields + let httpBody: OpenAPIRuntime.HTTPBody? + if let httpConvertibleError = underlyingError as? (any HTTPResponseConvertible) { + httpStatus = httpConvertibleError.httpStatus + httpHeaderFields = httpConvertibleError.httpHeaderFields + httpBody = httpConvertibleError.httpBody + } else { + httpStatus = .internalServerError + httpHeaderFields = [:] + httpBody = nil + } + self.init( operationID: operationID, request: request, @@ -87,9 +100,9 @@ public struct ServerError: Error, HTTPResponseConvertible { operationOutput: operationOutput, causeDescription: causeDescription, underlyingError: underlyingError, - httpStatus: .internalServerError, - httpHeaderFields: [:], - httpBody: nil + httpStatus: httpStatus, + httpHeaderFields: httpHeaderFields, + httpBody: httpBody ) } diff --git a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift index d27ba0c4..1fb49409 100644 --- a/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift +++ b/Sources/OpenAPIRuntime/Interface/ErrorHandlingMiddleware.swift @@ -58,20 +58,13 @@ public struct ErrorHandlingMiddleware: ServerMiddleware { ) async throws -> (HTTPTypes.HTTPResponse, OpenAPIRuntime.HTTPBody?) { do { return try await next(request, body, metadata) } catch { if let serverError = error as? ServerError { - if let appError = serverError.underlyingError as? (any HTTPResponseConvertible) { - return ( - HTTPResponse(status: appError.httpStatus, headerFields: appError.httpHeaderFields), - appError.httpBody - ) - } else { - return ( - HTTPResponse(status: serverError.httpStatus, headerFields: serverError.httpHeaderFields), - serverError.httpBody - ) - } + return ( + HTTPResponse(status: serverError.httpStatus, headerFields: serverError.httpHeaderFields), + serverError.httpBody + ) + } else { + return (HTTPResponse(status: .internalServerError), nil) } - - return (HTTPResponse(status: .internalServerError), nil) } } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index d7f5f393..dd329dc7 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -112,19 +112,26 @@ import struct Foundation.URLComponents } let causeDescription: String let underlyingError: any Error - let httpStatus: HTTPResponse.Status - let httpHeaderFields: HTTPTypes.HTTPFields - let httpBody: OpenAPIRuntime.HTTPBody? if let runtimeError = error as? RuntimeError { causeDescription = runtimeError.prettyDescription underlyingError = runtimeError.underlyingError ?? error - httpStatus = runtimeError.httpStatus - httpHeaderFields = runtimeError.httpHeaderFields - httpBody = runtimeError.httpBody - } else { causeDescription = "Unknown" underlyingError = error + } + + let httpStatus: HTTPResponse.Status + let httpHeaderFields: HTTPTypes.HTTPFields + let httpBody: OpenAPIRuntime.HTTPBody? + if let httpConvertibleError = underlyingError as? (any HTTPResponseConvertible) { + httpStatus = httpConvertibleError.httpStatus + httpHeaderFields = httpConvertibleError.httpHeaderFields + httpBody = httpConvertibleError.httpBody + } else if let httpConvertibleError = error as? (any HTTPResponseConvertible) { + httpStatus = httpConvertibleError.httpStatus + httpHeaderFields = httpConvertibleError.httpHeaderFields + httpBody = httpConvertibleError.httpBody + } else { httpStatus = .internalServerError httpHeaderFields = [:] httpBody = nil diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift index f3b9820f..a5a690f1 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift @@ -123,9 +123,6 @@ struct MockErrorMiddleware_Next: ServerMiddleware { requestMetadata: metadata, causeDescription: "", underlyingError: underlyingError, - httpStatus: .internalServerError, - httpHeaderFields: [:], - httpBody: nil ) } let (response, responseBody) = try await next(request, body, metadata) From 21bd0f9b76d3ef778ded66c1d2d3d00b3e11322e Mon Sep 17 00:00:00 2001 From: colin-dignazio Date: Mon, 3 Nov 2025 10:21:34 -0800 Subject: [PATCH 4/4] Fix format --- Sources/OpenAPIRuntime/Interface/UniversalServer.swift | 8 +++----- .../Interface/Test_ErrorHandlingMiddleware.swift | 2 +- .../Interface/Test_UniversalServer.swift | 4 +++- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index dd329dc7..2153ccea 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -153,11 +153,9 @@ import struct Foundation.URLComponents var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = { _request, _requestBody, _metadata in let input: OperationInput = try await wrappingErrors { - do { - return try await deserializer(_request, _requestBody, _metadata) - } catch let decodingError as DecodingError { - throw RuntimeError.failedToParseRequest(decodingError) - } + do { return try await deserializer(_request, _requestBody, _metadata) } catch let decodingError + as DecodingError + { throw RuntimeError.failedToParseRequest(decodingError) } } mapError: { error in makeError(error: error) } diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift index a5a690f1..eceb59e0 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_ErrorHandlingMiddleware.swift @@ -122,7 +122,7 @@ struct MockErrorMiddleware_Next: ServerMiddleware { requestBody: body, requestMetadata: metadata, causeDescription: "", - underlyingError: underlyingError, + underlyingError: underlyingError ) } let (response, responseBody) = try await next(request, body, metadata) diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift index 1e2481a1..db0e318b 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift @@ -102,7 +102,9 @@ final class Test_UniversalServer: Test_Runtime { } func testErrorPropagation_deserializerWithDecodingError() async throws { - let decodingError = DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid request body.")) + let decodingError = DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Invalid request body.") + ) do { let server = UniversalServer(handler: MockHandler()) _ = try await server.handle(