diff --git a/Sources/OpenAPIRuntime/Conversion/Configuration.swift b/Sources/OpenAPIRuntime/Conversion/Configuration.swift index 2ee7ab0..97a3154 100644 --- a/Sources/OpenAPIRuntime/Conversion/Configuration.swift +++ b/Sources/OpenAPIRuntime/Conversion/Configuration.swift @@ -152,6 +152,20 @@ public struct Configuration: Sendable { /// Custom XML coder for encoding and decoding xml bodies. public var xmlCoder: (any CustomCoder)? + /// The handler for client-side errors. + /// + /// This handler is invoked after a client error has been wrapped in a ``ClientError``. + /// Use this to add logging, monitoring, or analytics for client-side errors. + /// If `nil`, errors are thrown without additional handling. + public var clientErrorHandler: (any ClientErrorHandler)? + + /// The handler for server-side errors. + /// + /// This handler is invoked after a server error has been wrapped in a ``ServerError``. + /// Use this to add logging, monitoring, or analytics for server-side errors. + /// If `nil`, errors are thrown without additional handling. + public var serverErrorHandler: (any ServerErrorHandler)? + /// Creates a new configuration with the specified values. /// /// - Parameters: @@ -160,15 +174,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. + /// - clientErrorHandler: Optional handler for observing client-side errors. Defaults to `nil`. + /// - serverErrorHandler: Optional handler for observing server-side errors. Defaults to `nil`. public init( dateTranscoder: any DateTranscoder = .iso8601, jsonEncodingOptions: JSONEncodingOptions = [.sortedKeys, .prettyPrinted], multipartBoundaryGenerator: any MultipartBoundaryGenerator = .random, - xmlCoder: (any CustomCoder)? = nil + xmlCoder: (any CustomCoder)? = nil, + clientErrorHandler: (any ClientErrorHandler)? = nil, + serverErrorHandler: (any ServerErrorHandler)? = nil ) { self.dateTranscoder = dateTranscoder self.jsonEncodingOptions = jsonEncodingOptions self.multipartBoundaryGenerator = multipartBoundaryGenerator self.xmlCoder = xmlCoder + self.clientErrorHandler = clientErrorHandler + self.serverErrorHandler = serverErrorHandler } } diff --git a/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift b/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift new file mode 100644 index 0000000..37e6df9 --- /dev/null +++ b/Sources/OpenAPIRuntime/Errors/ErrorHandler.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import Foundation +import HTTPTypes + +// MARK: - Client Error Handler + +/// A protocol for observing and logging errors that occur during client operations. +/// +/// Implement this protocol to add logging, monitoring, or analytics for client-side errors. +/// This handler is called after the error has been wrapped in a ``ClientError``, providing +/// full context about the operation and the error. +/// +/// - Note: This handler should not throw or modify the error. Its purpose is observation only. +public protocol ClientErrorHandler: Sendable { + /// Called when a client error occurs, after it has been wrapped in a ``ClientError``. + /// + /// Use this method to log, monitor, or send analytics about the error. The error + /// will be thrown to the caller after this method returns. + /// + /// - Parameter error: The ``ClientError`` that will be thrown to the caller. + func handleClientError(_ error: ClientError) +} + + +// MARK: - Server Error Handler + +/// A protocol for observing and logging errors that occur during server operations. +/// +/// Implement this protocol to add logging, monitoring, or analytics for server-side errors. +/// This handler is called after the error has been wrapped in a ``ServerError``, providing +/// full context about the operation and the HTTP response that will be sent. +/// +/// - Note: This handler should not throw or modify the error. Its purpose is observation only. +public protocol ServerErrorHandler: Sendable { + /// Called when a server error occurs, after it has been wrapped in a ``ServerError``. + /// + /// Use this method to log, monitor, or send analytics about the error. The error + /// will be thrown to the error handling middleware after this method returns. + /// + /// - Parameter error: The ``ServerError`` that will be thrown to the middleware. + func handleServerError(_ error: ServerError) +} diff --git a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift index 5afff2b..24f94d5 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalClient.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalClient.swift @@ -98,6 +98,7 @@ import struct Foundation.URL } } let baseURL = serverURL + let errorHandler = converter.configuration.clientErrorHandler @Sendable func makeError( request: HTTPRequest? = nil, requestBody: HTTPBody? = nil, @@ -112,6 +113,7 @@ import struct Foundation.URL error.baseURL = error.baseURL ?? baseURL error.response = error.response ?? response error.responseBody = error.responseBody ?? responseBody + errorHandler?.handleClientError(error) return error } let causeDescription: String @@ -123,7 +125,7 @@ import struct Foundation.URL causeDescription = "Unknown" underlyingError = error } - return ClientError( + let clientError = ClientError( operationID: operationID, operationInput: input, request: request, @@ -134,6 +136,8 @@ import struct Foundation.URL causeDescription: causeDescription, underlyingError: underlyingError ) + errorHandler?.handleClientError(clientError) + return clientError } let (request, requestBody): (HTTPRequest, HTTPBody?) = try await wrappingErrors { try serializer(input) diff --git a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift index 2153cce..c5b976d 100644 --- a/Sources/OpenAPIRuntime/Interface/UniversalServer.swift +++ b/Sources/OpenAPIRuntime/Interface/UniversalServer.swift @@ -102,12 +102,14 @@ import struct Foundation.URLComponents throw mapError(error) } } + let errorHandler = converter.configuration.serverErrorHandler @Sendable func makeError(input: OperationInput? = nil, output: OperationOutput? = nil, error: any Error) -> any Error { if var error = error as? ServerError { error.operationInput = error.operationInput ?? input error.operationOutput = error.operationOutput ?? output + errorHandler?.handleServerError(error) return error } let causeDescription: String @@ -136,7 +138,7 @@ import struct Foundation.URLComponents httpHeaderFields = [:] httpBody = nil } - return ServerError( + let serverError = ServerError( operationID: operationID, request: request, requestBody: requestBody, @@ -149,6 +151,8 @@ import struct Foundation.URLComponents httpHeaderFields: httpHeaderFields, httpBody: httpBody ) + errorHandler?.handleServerError(serverError) + return serverError } var next: @Sendable (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?) = { _request, _requestBody, _metadata in diff --git a/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift b/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift new file mode 100644 index 0000000..b02a2e6 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Errors/Test_ErrorHandler.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +// MARK: - Test Helpers + +/// A custom client error handler that logs all errors for testing +final class LoggingClientErrorHandler: ClientErrorHandler { + var handledErrors: [ClientError] = [] + private let lock = NSLock() + + func handleClientError(_ error: ClientError) { + lock.lock() + handledErrors.append(error) + lock.unlock() + } +} + +/// A custom server error handler that logs all errors for testing +final class LoggingServerErrorHandler: ServerErrorHandler { + var handledErrors: [ServerError] = [] + private let lock = NSLock() + + func handleServerError(_ error: ServerError) { + lock.lock() + handledErrors.append(error) + lock.unlock() + } +} + +// MARK: - ErrorHandler Tests + +final class Test_ErrorHandler: XCTestCase { + + func testClientErrorHandler_IsCalledWithClientError() throws { + let handler = LoggingClientErrorHandler() + let clientError = ClientError( + operationID: "testOp", + operationInput: "test-input", + request: .init(soar_path: "/test", method: .get), + requestBody: nil, + baseURL: URL(string: "https://example.com"), + response: nil, + responseBody: nil, + causeDescription: "Test error", + underlyingError: NSError(domain: "test", code: 1) + ) + + handler.handleClientError(clientError) + + XCTAssertEqual(handler.handledErrors.count, 1) + XCTAssertEqual(handler.handledErrors[0].operationID, "testOp") + XCTAssertEqual(handler.handledErrors[0].causeDescription, "Test error") + } + + func testServerErrorHandler_IsCalledWithServerError() throws { + let handler = LoggingServerErrorHandler() + let serverError = ServerError( + operationID: "testOp", + request: .init(soar_path: "/test", method: .post), + requestBody: nil, + requestMetadata: .init(), + operationInput: "test-input", + operationOutput: nil, + causeDescription: "Test error", + underlyingError: NSError(domain: "test", code: 1), + httpStatus: .badRequest, + httpHeaderFields: [:], + httpBody: nil + ) + + handler.handleServerError(serverError) + + XCTAssertEqual(handler.handledErrors.count, 1) + XCTAssertEqual(handler.handledErrors[0].operationID, "testOp") + XCTAssertEqual(handler.handledErrors[0].httpStatus, .badRequest) + } + + func testMultipleErrors_AreAllLogged() throws { + let clientHandler = LoggingClientErrorHandler() + let serverHandler = LoggingServerErrorHandler() + + // Log multiple client errors + for i in 1...3 { + let error = ClientError( + operationID: "op\(i)", + operationInput: nil as String?, + request: nil, + requestBody: nil, + baseURL: nil, + response: nil, + responseBody: nil, + causeDescription: "Error \(i)", + underlyingError: NSError(domain: "test", code: i) + ) + clientHandler.handleClientError(error) + } + + // Log multiple server errors + for i in 1...3 { + let error = ServerError( + operationID: "op\(i)", + request: .init(soar_path: "/test", method: .get), + requestBody: nil, + requestMetadata: .init(), + operationInput: nil as String?, + operationOutput: nil as String?, + causeDescription: "Error \(i)", + underlyingError: NSError(domain: "test", code: i), + httpStatus: .internalServerError, + httpHeaderFields: [:], + httpBody: nil + ) + serverHandler.handleServerError(error) + } + + XCTAssertEqual(clientHandler.handledErrors.count, 3) + XCTAssertEqual(serverHandler.handledErrors.count, 3) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift b/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift new file mode 100644 index 0000000..ec87b2f --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Integration/Test_ErrorHandlerIntegration.swift @@ -0,0 +1,332 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import HTTPTypes +import Foundation +@_spi(Generated) @testable import OpenAPIRuntime +import XCTest + +// MARK: - Test Helpers + +/// Tracks all client errors that pass through it +final class TrackingClientErrorHandler: ClientErrorHandler { + private let lock = NSLock() + private var _handledErrors: [ClientError] = [] + + var handledErrors: [ClientError] { + lock.lock() + defer { lock.unlock() } + return _handledErrors + } + + func handleClientError(_ error: ClientError) { + lock.lock() + _handledErrors.append(error) + lock.unlock() + } +} + +/// Tracks all server errors that pass through it +final class TrackingServerErrorHandler: ServerErrorHandler { + private let lock = NSLock() + private var _handledErrors: [ServerError] = [] + + var handledErrors: [ServerError] { + lock.lock() + defer { lock.unlock() } + return _handledErrors + } + + func handleServerError(_ error: ServerError) { + lock.lock() + _handledErrors.append(error) + lock.unlock() + } +} + +/// Mock client transport for testing +struct TestClientTransport: ClientTransport { + var sendBlock: @Sendable (HTTPRequest, HTTPBody?, URL, String) async throws -> (HTTPResponse, HTTPBody?) + + func send(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String) async throws -> ( + HTTPResponse, HTTPBody? + ) { try await sendBlock(request, body, baseURL, operationID) } + + static var successful: Self { + TestClientTransport { _, _, _, _ in (HTTPResponse(status: .ok), HTTPBody("success")) } + } + + static var failing: Self { TestClientTransport { _, _, _, _ in throw NSError(domain: "Transport", code: -1) } } +} + +/// Mock API handler for testing +struct TestAPIHandler: Sendable { + var handleBlock: @Sendable (String) async throws -> String + + func handleRequest(_ input: String) async throws -> String { try await handleBlock(input) } + + static var successful: Self { TestAPIHandler { input in "Response: \(input)" } } + + static var failing: Self { + TestAPIHandler { _ in throw NSError(domain: "Handler", code: -1, userInfo: [NSLocalizedDescriptionKey: "Handler failed"]) } + } +} + +// MARK: - Integration Tests + +final class Test_ErrorHandlerIntegration: XCTestCase { + + // MARK: Client Integration Tests + + func testUniversalClient_CallsErrorHandler_OnSerializationError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.successful) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in throw NSError(domain: "Serialization", code: 1) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + XCTAssertTrue(error is ClientError) + } + } + + func testUniversalClient_CallsErrorHandler_OnTransportError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.failing) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + guard let clientError = error as? ClientError else { + XCTFail("Expected ClientError") + return + } + + // Should be wrapped in RuntimeError.transportFailed + XCTAssertTrue(clientError.causeDescription.contains("Transport")) + } + } + + func testUniversalClient_CallsErrorHandler_OnDeserializationError() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.successful) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in throw NSError(domain: "Deserialization", code: 1) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp") + + // Verify the error is a ClientError + XCTAssertTrue(error is ClientError) + } + } + + func testUniversalClient_DoesNotCallHandler_WhenNotConfigured() async throws { + // No custom handler in configuration + let client = UniversalClient(transport: TestClientTransport.failing) + + do { + _ = try await client.send( + input: "test", + forOperation: "testOp", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Should still produce a ClientError + guard let clientError = error as? ClientError else { + XCTFail("Expected ClientError") + return + } + + XCTAssertEqual(clientError.operationID, "testOp") + } + } + + // MARK: Server Integration Tests + + func testUniversalServer_CallsErrorHandler_OnDeserializationError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.successful, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in throw NSError(domain: "Deserialization", code: 1) }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + XCTAssertTrue(error is ServerError) + } + } + + func testUniversalServer_CallsErrorHandler_OnHandlerError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.failing, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + guard let serverError = error as? ServerError else { + XCTFail("Expected ServerError") + return + } + + // Should be wrapped in RuntimeError.handlerFailed + XCTAssertTrue(serverError.causeDescription.contains("handler")) + XCTAssertEqual(serverError.httpStatus, .internalServerError) + } + } + + func testUniversalServer_CallsErrorHandler_OnSerializationError() async throws { + let trackingHandler = TrackingServerErrorHandler() + let configuration = Configuration(serverErrorHandler: trackingHandler) + let server = UniversalServer(handler: TestAPIHandler.successful, configuration: configuration) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { _, _ in throw NSError(domain: "Serialization", code: 1) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Verify the error handler was called + XCTAssertEqual(trackingHandler.handledErrors.count, 1) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "serverOp") + + // Verify the error is a ServerError + XCTAssertTrue(error is ServerError) + } + } + + func testUniversalServer_DoesNotCallHandler_WhenNotConfigured() async throws { + // No custom handler in configuration + let server = UniversalServer(handler: TestAPIHandler.failing) + + do { + _ = try await server.handle( + request: HTTPRequest(soar_path: "/test", method: .post), + requestBody: nil, + metadata: ServerRequestMetadata(), + forOperation: "serverOp", + using: { handler in handler.handleRequest }, + deserializer: { _, _, _ in "test-input" }, + serializer: { output, _ in (HTTPResponse(status: .ok), HTTPBody(output)) } + ) + XCTFail("Expected error to be thrown") + } catch { + // Should still produce a ServerError + guard let serverError = error as? ServerError else { + XCTFail("Expected ServerError") + return + } + + XCTAssertEqual(serverError.operationID, "serverOp") + XCTAssertEqual(serverError.httpStatus, .internalServerError) + } + } + + // MARK: Multiple Error Handler Tests + + func testMultipleErrors_EachPassesThroughHandler() async throws { + let trackingHandler = TrackingClientErrorHandler() + let configuration = Configuration(clientErrorHandler: trackingHandler) + let client = UniversalClient(configuration: configuration, transport: TestClientTransport.failing) + + // Trigger multiple errors + for i in 1...3 { + do { + _ = try await client.send( + input: "test-\(i)", + forOperation: "testOp\(i)", + serializer: { _ in (HTTPRequest(soar_path: "/test", method: .get), nil) }, + deserializer: { _, _ in "" } + ) + XCTFail("Expected error to be thrown") + } catch { + // Expected + } + } + + // Verify all errors were tracked + XCTAssertEqual(trackingHandler.handledErrors.count, 3) + XCTAssertEqual(trackingHandler.handledErrors[0].operationID, "testOp1") + XCTAssertEqual(trackingHandler.handledErrors[1].operationID, "testOp2") + XCTAssertEqual(trackingHandler.handledErrors[2].operationID, "testOp3") + } +}