diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 2ee7ab00..fd3a2048 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -152,6 +152,12 @@ public struct Configuration: Sendable { /// Custom XML coder for encoding and decoding xml bodies. public var xmlCoder: (any CustomCoder)? + /// An error mapping closure to allow customizing the error thrown by the client. + public var clientErrorMapper: (@Sendable (ClientError) -> any Error)? + + /// An error mapping closure to allow customizing the error thrown by the server. + public var serverErrorMapper: (@Sendable (ServerError) -> any Error)? + /// Creates a new configuration with the specified values. /// /// - Parameters: @@ -160,15 +166,21 @@ public struct Configuration: Sendable { /// - jsonEncodingOptions: The options for the underlying JSON encoder. /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. + /// - clientErrorMapper: An error mapping closure to allow customizing the error thrown by the client. + /// - serverErrorMapper: An error mapping closure to allow customizing the error thrown by the server. public init( dateTranscoder: any DateTranscoder = .iso8601, jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted], multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, - xmlCoder: (any CustomCoder)? = nil + xmlCoder: (any CustomCoder)? = nil, + clientErrorMapper: (@Sendable (ClientError) -> any Error)? = nil, + serverErrorMapper: (@Sendable (ServerError) -> any Error)? = nil ) { self.dateTranscoder = dateTranscoder self.jsonEncodingOptions = jsonEncodingOptions self.multipartBoundaryGenerator = multipartBoundaryGenerator self.xmlCoder = xmlCoder + self.clientErrorMapper = clientErrorMapper + self.serverErrorMapper = serverErrorMapper } } diff --git a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift index 2ce41750..8b5d19ee 100644 --- a/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift +++ b/Sources/OpenAPIRuntime/Deprecated/Deprecated.swift @@ -58,6 +58,35 @@ extension Configuration { xmlCoder: xmlCoder ) } + + /// Creates a new configuration with the specified values. + /// + /// - Parameters: + /// - dateTranscoder: The transcoder to use when converting between date + /// and string values. + /// - jsonEncodingOptions: The options for the underlying JSON encoder. + /// - multipartBoundaryGenerator: The generator to use when creating mutlipart bodies. + /// - xmlCoder: Custom XML coder for encoding and decoding xml bodies. Only required when using XML body payloads. + @available( + *, + deprecated, + renamed: + "init(dateTranscoder:jsonEncodingOptions:multipartBoundaryGenerator:xmlCoder:clientErrorMapper:serverErrorMapper:)" + ) @_disfavoredOverload public init( + dateTranscoder: any DateTranscoder = .iso8601, + jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted], + multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, + xmlCoder: (any CustomCoder)? = nil + ) { + self.init( + dateTranscoder: dateTranscoder, + jsonEncodingOptions: [.sortedKeys, .prettyPrinted], + multipartBoundaryGenerator: multipartBoundaryGenerator, + xmlCoder: xmlCoder, + clientErrorMapper: nil, + serverErrorMapper: nil + ) + } } extension AsyncSequence where Element == ArraySlice, Self: Sendable { diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 5afff2b1..3a2bf2e0 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -38,17 +38,22 @@ import struct Foundation.URL /// The middlewares to be invoked before the transport. public var middlewares: [any ClientMiddleware] + /// An error mapping closure to allow customizing the error thrown by the client. + public var errorMapper: (@Sendable (ClientError) -> any Error)? + /// Internal initializer that takes an initialized `Converter`. internal init( serverURL: URL, converter: Converter, transport: any ClientTransport, - middlewares: [any ClientMiddleware] + middlewares: [any ClientMiddleware], + errorMapper: (@Sendable (ClientError) -> any Error)? ) { self.serverURL = serverURL self.converter = converter self.transport = transport self.middlewares = middlewares + self.errorMapper = errorMapper } /// Creates a new client. @@ -62,7 +67,8 @@ import struct Foundation.URL serverURL: serverURL, converter: Converter(configuration: configuration), transport: transport, - middlewares: middlewares + middlewares: middlewares, + errorMapper: configuration.clientErrorMapper ) } @@ -135,57 +141,65 @@ import struct Foundation.URL underlyingError: underlyingError ) } - let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors { - try serializer(input) - } mapError: { error in - makeError(error: error) - } - var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { - (_request, _body, _url) in - try await wrappingErrors { - try await transport.send(_request, body: _body, baseURL: _url, operationID: operationID) + do { + let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors { + try serializer(input) } mapError: { error in - makeError( - request: request, - requestBody: requestBody, - baseURL: baseURL, - error: RuntimeError.transportFailed(error) - ) + makeError(error: error) } - } - for middleware in middlewares.reversed() { - let tmp = next - next = { (_request, _body, _url) in + var next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) = { + (_request, _body, _url) in try await wrappingErrors { - try await middleware.intercept( - _request, - body: _body, - baseURL: _url, - operationID: operationID, - next: tmp - ) + try await transport.send(_request, body: _body, baseURL: _url, operationID: operationID) } mapError: { error in makeError( request: request, requestBody: requestBody, baseURL: baseURL, - error: RuntimeError.middlewareFailed(middlewareType: type(of: middleware), error) + error: RuntimeError.transportFailed(error) ) } } - } - let (response, responseBody): (HTTPResponse, HTTPBody?) = try await next(request, requestBody, baseURL) - return try await wrappingErrors { - try await deserializer(response, responseBody) - } mapError: { error in - makeError( - request: request, - requestBody: requestBody, - baseURL: baseURL, - response: response, - responseBody: responseBody, - error: error - ) + for middleware in middlewares.reversed() { + let tmp = next + next = { (_request, _body, _url) in + try await wrappingErrors { + try await middleware.intercept( + _request, + body: _body, + baseURL: _url, + operationID: operationID, + next: tmp + ) + } mapError: { error in + makeError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + error: RuntimeError.middlewareFailed(middlewareType: type(of: middleware), error) + ) + } + } + } + let (response, responseBody): (HTTPResponse, HTTPBody?) = try await next(request, requestBody, baseURL) + return try await wrappingErrors { + try await deserializer(response, responseBody) + } mapError: { error in + makeError( + request: request, + requestBody: requestBody, + baseURL: baseURL, + response: response, + responseBody: responseBody, + error: error + ) + } + } catch { + if let errorMapper, let clientError = error as? ClientError { + throw errorMapper(clientError) + } else { + throw error + } } } } diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 4608dafe..866f05b6 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -40,12 +40,22 @@ import struct Foundation.URLComponents /// The middlewares to be invoked before the handler receives the request. public var middlewares: [any ServerMiddleware] + /// An error mapping closure to allow customizing the error thrown by the server handler. + public var errorMapper: (@Sendable (ServerError) -> any Error)? + /// Internal initializer that takes an initialized converter. - internal init(serverURL: URL, converter: Converter, handler: APIHandler, middlewares: [any ServerMiddleware]) { + internal init( + serverURL: URL, + converter: Converter, + handler: APIHandler, + middlewares: [any ServerMiddleware], + errorMapper: (@Sendable (ServerError) -> any Error)? + ) { self.serverURL = serverURL self.converter = converter self.handler = handler self.middlewares = middlewares + self.errorMapper = errorMapper } /// Creates a new server with the specified parameters. @@ -59,7 +69,8 @@ import struct Foundation.URLComponents serverURL: serverURL, converter: Converter(configuration: configuration), handler: handler, - middlewares: middlewares + middlewares: middlewares, + errorMapper: configuration.serverErrorMapper ) } @@ -169,7 +180,13 @@ import struct Foundation.URLComponents } } } - return try await next(request, requestBody, metadata) + do { return try await next(request, requestBody, metadata) } catch { + if let errorMapper, let serverError = error as? ServerError { + throw errorMapper(serverError) + } else { + throw error + } + } } /// Returns the path with the server URL's path prefix prepended. diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift index b0063e70..2e3d3be2 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalClient.swift @@ -119,6 +119,24 @@ final class Test_UniversalClient: Test_Runtime { } } + func testErrorPropagation_customErrorMapper() async throws { + do { + let client = UniversalClient( + configuration: .init(clientErrorMapper: { clientError in + // Don't care about the extra context, just wants the underlyingError + clientError.underlyingError + }), + transport: MockClientTransport.failing + ) + try await client.send( + input: "input", + forOperation: "op", + serializer: { input in (HTTPRequest(soar_path: "/", method: .post), MockClientTransport.requestBody) }, + deserializer: { response, body in fatalError() } + ) + } catch { XCTAssertTrue(error is TestError, "Threw an unexpected error: \(type(of: error))") } + } + func testErrorPropagation_middlewareOnResponse() async throws { do { let client = UniversalClient( diff --git a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift index e65afe4f..6a9fe816 100644 --- a/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift +++ b/Tests/OpenAPIRuntimeTests/Interface/Test_UniversalServer.swift @@ -129,6 +129,30 @@ final class Test_UniversalServer: Test_Runtime { } } + func testErrorPropagation_errorMapper() async throws { + do { + let server = UniversalServer( + handler: MockHandler(shouldFail: true), + configuration: .init(serverErrorMapper: { serverError in + // Don't care about the extra context, just wants the underlyingError + serverError.underlyingError + }) + ) + _ = 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 + let body = try XCTUnwrap(body) + return try await String(collecting: body, upTo: 10) + }, + serializer: { output, _ in fatalError() } + ) + } catch { XCTAssertTrue(error is TestError, "Threw an unexpected error: \(type(of: error))") } + } + func testErrorPropagation_serializer() async throws { do { let server = UniversalServer(handler: MockHandler())