From 525d2d8ab94c07a0381b46e4bf729fd235c1ac44 Mon Sep 17 00:00:00 2001 From: David Yaffe Date: Thu, 25 Sep 2025 10:57:12 -0400 Subject: [PATCH 1/4] Add support for TLS configuration --- .../Networking/Http/NIO/NIOHTTPClient.swift | 20 ++++- .../Http/NIO/NIOHTTPClientTLSOptions.swift | 52 ++++++++++++ .../NIO/NIOHTTPClientTLSResolverUtils.swift | 85 +++++++++++++++++++ .../NIO/NIOHTTPClientTLSOptionsTests.swift | 65 ++++++++++++++ 4 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSOptions.swift create mode 100644 Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSResolverUtils.swift create mode 100644 Tests/ClientRuntimeTests/NetworkingTests/Http/NIO/NIOHTTPClientTLSOptionsTests.swift diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift index 17490b294..7b7f3f616 100644 --- a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift @@ -6,15 +6,20 @@ // import AsyncHTTPClient +import Foundation import NIOCore import NIOPosix +import NIOSSL import SmithyHTTPAPI +import NIOHTTP1 /// AsyncHTTPClient-based HTTP client implementation that conforms to SmithyHTTPAPI.HTTPClient /// This implementation is thread-safe and supports concurrent request execution. public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { private let client: AsyncHTTPClient.HTTPClient private let config: HttpClientConfiguration + private let tlsConfiguration: NIOHTTPClientTLSOptions? + private let allocator: ByteBufferAllocator /// Creates a new `NIOHTTPClient`. /// @@ -25,13 +30,22 @@ public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { httpClientConfiguration: HttpClientConfiguration ) throws { self.config = httpClientConfiguration - self.client = AsyncHTTPClient.HTTPClient( - configuration: .init() // TODO - ) + self.tlsConfiguration = httpClientConfiguration.tlsConfiguration as? NIOHTTPClientTLSOptions + self.allocator = ByteBufferAllocator() + + var clientConfig = AsyncHTTPClient.HTTPClient.Configuration() + + // Configure TLS if options are provided + if let tlsOptions = tlsConfiguration { + clientConfig.tlsConfiguration = try tlsOptions.makeNIOSSLConfiguration() + } + + self.client = AsyncHTTPClient.HTTPClient(configuration: clientConfig) } public func send(request: SmithyHTTPAPI.HTTPRequest) async throws -> SmithyHTTPAPI.HTTPResponse { // TODO return HTTPResponse() } + } diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSOptions.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSOptions.swift new file mode 100644 index 000000000..c4b40fe54 --- /dev/null +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSOptions.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import NIOSSL + +public struct NIOHTTPClientTLSOptions: TLSConfiguration, Sendable { + + /// Optional path to a PEM certificate + public var certificate: String? + + /// Optional path to certificate directory + public var certificateDir: String? + + /// Optional path to a PEM format private key + public var privateKey: String? + + /// Optional path to PKCS #12 certificate, in PEM format + public var pkcs12Path: String? + + /// Optional PKCS#12 password + public var pkcs12Password: String? + + /// Information is provided to use custom trust store + public var useSelfSignedCertificate: Bool { + return certificate != nil || certificateDir != nil + } + + /// Information is provided to use custom key store + public var useProvidedKeystore: Bool { + return (pkcs12Path != nil && pkcs12Password != nil) || + (certificate != nil && privateKey != nil) + } + + public init( + certificate: String? = nil, + certificateDir: String? = nil, + privateKey: String? = nil, + pkcs12Path: String? = nil, + pkcs12Password: String? = nil + ) { + self.certificate = certificate + self.certificateDir = certificateDir + self.privateKey = privateKey + self.pkcs12Path = pkcs12Path + self.pkcs12Password = pkcs12Password + } +} diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSResolverUtils.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSResolverUtils.swift new file mode 100644 index 000000000..12d57b724 --- /dev/null +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientTLSResolverUtils.swift @@ -0,0 +1,85 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import NIOSSL + +extension NIOHTTPClientTLSOptions { + + func makeNIOSSLConfiguration() throws -> NIOSSL.TLSConfiguration { + var tlsConfig = NIOSSL.TLSConfiguration.makeClientConfiguration() + + if useSelfSignedCertificate { + if let certificateDir = certificateDir, let certificate = certificate { + let certificatePath = "\(certificateDir)/\(certificate)" + let certificates = try NIOHTTPClientTLSOptions.loadCertificates(from: certificatePath) + tlsConfig.trustRoots = .certificates(certificates) + } else if let certificate = certificate { + let certificates = try NIOHTTPClientTLSOptions.loadCertificates(from: certificate) + tlsConfig.trustRoots = .certificates(certificates) + } + } + + if useProvidedKeystore { + if let pkcs12Path = pkcs12Path, let pkcs12Password = pkcs12Password { + let bundle = try NIOHTTPClientTLSOptions.loadPKCS12Bundle(from: pkcs12Path, password: pkcs12Password) + tlsConfig.certificateChain = bundle.certificateChain.map { .certificate($0) } + tlsConfig.privateKey = .privateKey(bundle.privateKey) + } else if let certificate = certificate, let privateKey = privateKey { + let cert = try NIOHTTPClientTLSOptions.loadCertificate(from: certificate) + let key = try NIOHTTPClientTLSOptions.loadPrivateKey(from: privateKey) + tlsConfig.certificateChain = [.certificate(cert)] + tlsConfig.privateKey = .privateKey(key) + } + } + + return tlsConfig + } +} + +extension NIOHTTPClientTLSOptions { + + static func loadCertificates(from filePath: String) throws -> [NIOSSLCertificate] { + let fileData = try Data(contentsOf: URL(fileURLWithPath: filePath)) + return try NIOSSLCertificate.fromPEMBytes(Array(fileData)) + } + + static func loadCertificate(from filePath: String) throws -> NIOSSLCertificate { + let certificates = try loadCertificates(from: filePath) + guard let certificate = certificates.first else { + throw NIOHTTPClientTLSError.noCertificateFound(filePath) + } + return certificate + } + + static func loadPrivateKey(from filePath: String) throws -> NIOSSLPrivateKey { + let fileData = try Data(contentsOf: URL(fileURLWithPath: filePath)) + return try NIOSSLPrivateKey(bytes: Array(fileData), format: .pem) + } + + static func loadPKCS12Bundle(from filePath: String, password: String) throws -> NIOSSLPKCS12Bundle { + do { + return try NIOSSLPKCS12Bundle(file: filePath, passphrase: password.utf8) + } catch { + throw NIOHTTPClientTLSError.invalidPKCS12(filePath, underlying: error) + } + } +} + +public enum NIOHTTPClientTLSError: Error, LocalizedError { + case noCertificateFound(String) + case invalidPKCS12(String, underlying: Error) + + public var errorDescription: String? { + switch self { + case .noCertificateFound(let path): + return "No certificate found at path: \(path)" + case .invalidPKCS12(let path, let underlying): + return "Failed to load PKCS#12 file at path: \(path). Error: \(underlying.localizedDescription)" + } + } +} diff --git a/Tests/ClientRuntimeTests/NetworkingTests/Http/NIO/NIOHTTPClientTLSOptionsTests.swift b/Tests/ClientRuntimeTests/NetworkingTests/Http/NIO/NIOHTTPClientTLSOptionsTests.swift new file mode 100644 index 000000000..e26d8db1f --- /dev/null +++ b/Tests/ClientRuntimeTests/NetworkingTests/Http/NIO/NIOHTTPClientTLSOptionsTests.swift @@ -0,0 +1,65 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import XCTest +@testable import ClientRuntime + +class NIOHTTPClientTLSOptionsTests: XCTestCase { + + func test_init_withDefaults() { + let tlsOptions = NIOHTTPClientTLSOptions() + + XCTAssertNil(tlsOptions.certificate) + XCTAssertNil(tlsOptions.certificateDir) + XCTAssertNil(tlsOptions.privateKey) + XCTAssertNil(tlsOptions.pkcs12Path) + XCTAssertNil(tlsOptions.pkcs12Password) + XCTAssertFalse(tlsOptions.useSelfSignedCertificate) + XCTAssertFalse(tlsOptions.useProvidedKeystore) + } + + func test_init_withCertificate() { + let tlsOptions = NIOHTTPClientTLSOptions(certificate: "/path/to/cert.pem") + + XCTAssertEqual(tlsOptions.certificate, "/path/to/cert.pem") + XCTAssertTrue(tlsOptions.useSelfSignedCertificate) + XCTAssertFalse(tlsOptions.useProvidedKeystore) + } + + func test_init_withCertificateDir() { + let tlsOptions = NIOHTTPClientTLSOptions(certificateDir: "/path/to/certs/") + + XCTAssertEqual(tlsOptions.certificateDir, "/path/to/certs/") + XCTAssertTrue(tlsOptions.useSelfSignedCertificate) + XCTAssertFalse(tlsOptions.useProvidedKeystore) + } + + func test_init_withPKCS12() { + let tlsOptions = NIOHTTPClientTLSOptions( + pkcs12Path: "/path/to/cert.p12", + pkcs12Password: "password" + ) + + XCTAssertEqual(tlsOptions.pkcs12Path, "/path/to/cert.p12") + XCTAssertEqual(tlsOptions.pkcs12Password, "password") + XCTAssertFalse(tlsOptions.useSelfSignedCertificate) + XCTAssertTrue(tlsOptions.useProvidedKeystore) + } + + func test_init_withCertificateAndPrivateKey() { + let tlsOptions = NIOHTTPClientTLSOptions( + certificate: "/path/to/cert.pem", + privateKey: "/path/to/key.pem" + ) + + XCTAssertEqual(tlsOptions.certificate, "/path/to/cert.pem") + XCTAssertEqual(tlsOptions.privateKey, "/path/to/key.pem") + XCTAssertTrue(tlsOptions.useSelfSignedCertificate) + XCTAssertTrue(tlsOptions.useProvidedKeystore) + } +} From c6984aade1f895fa9fce282ebc3bf547ccfc7d47 Mon Sep 17 00:00:00 2001 From: David Yaffe Date: Thu, 25 Sep 2025 11:08:23 -0400 Subject: [PATCH 2/4] remove some unnecessary imports --- Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift index 7b7f3f616..f065bc656 100644 --- a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift @@ -6,12 +6,10 @@ // import AsyncHTTPClient -import Foundation import NIOCore import NIOPosix import NIOSSL import SmithyHTTPAPI -import NIOHTTP1 /// AsyncHTTPClient-based HTTP client implementation that conforms to SmithyHTTPAPI.HTTPClient /// This implementation is thread-safe and supports concurrent request execution. @@ -47,5 +45,4 @@ public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { // TODO return HTTPResponse() } - } From a641197e4d0dce7d1f4d0a278a430a6d85d7e066 Mon Sep 17 00:00:00 2001 From: David Yaffe Date: Fri, 26 Sep 2025 16:35:40 -0400 Subject: [PATCH 3/4] Add telemetry support with placeholder comments for functionality --- .../Networking/Http/NIO/NIOHTTPClient.swift | 100 +++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift index f065bc656..44b8a7114 100644 --- a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift @@ -9,16 +9,38 @@ import AsyncHTTPClient import NIOCore import NIOPosix import NIOSSL -import SmithyHTTPAPI +import struct Smithy.Attributes +import struct Smithy.SwiftLogger +import protocol Smithy.LogAgent +import struct SmithyHTTPAPI.Headers +import class SmithyHTTPAPI.HTTPResponse +import class SmithyHTTPAPI.HTTPRequest +import enum SmithyHTTPAPI.HTTPStatusCode +import protocol Smithy.ReadableStream +import enum Smithy.ByteStream +import class SmithyStreams.BufferedStream +import struct Foundation.Date +import AwsCommonRuntimeKit /// AsyncHTTPClient-based HTTP client implementation that conforms to SmithyHTTPAPI.HTTPClient /// This implementation is thread-safe and supports concurrent request execution. public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { + public static let noOpNIOHTTPClientTelemetry = HttpTelemetry( + httpScope: "NIOHTTPClient", + telemetryProvider: DefaultTelemetry.provider + ) + private let client: AsyncHTTPClient.HTTPClient private let config: HttpClientConfiguration private let tlsConfiguration: NIOHTTPClientTLSOptions? private let allocator: ByteBufferAllocator + /// HTTP Client Telemetry + private let telemetry: HttpTelemetry + + /// Logger for HTTP-related events. + private var logger: LogAgent + /// Creates a new `NIOHTTPClient`. /// /// The client is created with its own internal `AsyncHTTPClient`, which is configured with system defaults. @@ -28,6 +50,8 @@ public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { httpClientConfiguration: HttpClientConfiguration ) throws { self.config = httpClientConfiguration + self.telemetry = httpClientConfiguration.telemetry ?? NIOHTTPClient.noOpNIOHTTPClientTelemetry + self.logger = self.telemetry.loggerProvider.getLogger(name: "NIOHTTPClient") self.tlsConfiguration = httpClientConfiguration.tlsConfiguration as? NIOHTTPClientTLSOptions self.allocator = ByteBufferAllocator() @@ -42,7 +66,77 @@ public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient { } public func send(request: SmithyHTTPAPI.HTTPRequest) async throws -> SmithyHTTPAPI.HTTPResponse { - // TODO - return HTTPResponse() + let telemetryContext = telemetry.contextManager.current() + let tracer = telemetry.tracerProvider.getTracer( + scope: telemetry.tracerScope + ) + do { + // START - smithy.client.http.requests.queued_duration + let queuedStart = Date().timeIntervalSinceReferenceDate + let span = tracer.createSpan( + name: telemetry.spanName, + initialAttributes: telemetry.spanAttributes, + spanKind: SpanKind.internal, + parentContext: telemetryContext) + defer { + span.end() + } + + // START - smithy.client.http.connections.acquire_duration + let acquireConnectionStart = Date().timeIntervalSinceReferenceDate + + // TODO: Convert Smithy HTTPRequest to AsyncHTTPClient HTTPClientRequest + + let acquireConnectionEnd = Date().timeIntervalSinceReferenceDate + telemetry.connectionsAcquireDuration.record( + value: acquireConnectionEnd - acquireConnectionStart, + attributes: Attributes(), + context: telemetryContext) + // END - smithy.client.http.connections.acquire_duration + + let queuedEnd = acquireConnectionEnd + telemetry.requestsQueuedDuration.record( + value: queuedEnd - queuedStart, + attributes: Attributes(), + context: telemetryContext) + // END - smithy.client.http.requests.queued_duration + + // TODO: Update connection and request usage metrics based on AsyncHTTPClient configuration + telemetry.updateHTTPMetricsUsage { httpMetricsUsage in + // TICK - smithy.client.http.connections.limit + httpMetricsUsage.connectionsLimit = 0 // TODO: Get from AsyncHTTPClient configuration + + // TICK - smithy.client.http.connections.usage + httpMetricsUsage.acquiredConnections = 0 // TODO: Get from AsyncHTTPClient + httpMetricsUsage.idleConnections = 0 // TODO: Get from AsyncHTTPClient + + // TICK - smithy.client.http.requests.usage + httpMetricsUsage.inflightRequests = httpMetricsUsage.acquiredConnections + httpMetricsUsage.queuedRequests = httpMetricsUsage.idleConnections + } + + // DURATION - smithy.client.http.connections.uptime + let connectionUptimeStart = acquireConnectionEnd + defer { + telemetry.connectionsUptime.record( + value: Date().timeIntervalSinceReferenceDate - connectionUptimeStart, + attributes: Attributes(), + context: telemetryContext) + } + + // TODO: Execute the HTTP request using AsyncHTTPClient + + // TODO: Log body description + + // TODO: Handle response + // TODO: Record bytes sent during request body streaming with server address attributes + // TODO: Record bytes received during response streaming with server address attributes + + // TODO: Convert NIO response to Smithy HTTPResponse + + return HTTPResponse() // TODO: Return actual response + } catch { + // TODO: Handle catch + } } } From 68ca17b6464f372b91396d7b70a02c4307360b45 Mon Sep 17 00:00:00 2001 From: David Yaffe Date: Fri, 26 Sep 2025 17:40:49 -0400 Subject: [PATCH 4/4] add import --- Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift index 44b8a7114..0cbfe7c67 100644 --- a/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift +++ b/Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift @@ -13,6 +13,7 @@ import struct Smithy.Attributes import struct Smithy.SwiftLogger import protocol Smithy.LogAgent import struct SmithyHTTPAPI.Headers +import protocol SmithyHTTPAPI.HTTPClient import class SmithyHTTPAPI.HTTPResponse import class SmithyHTTPAPI.HTTPRequest import enum SmithyHTTPAPI.HTTPStatusCode