Browse Source

Add name resolver protocols and registry (#1807)

Motivation:

When creating a client users will provide a target to connect to. This
is usually a host to resolve via DNS but may be a Unix domain socket,
pre-resolved IP address or something custom.

Users get a resolver from a registry by asking it to resolve a given
target. Resolvers then provide a set of endpoints and a service
configuration. These values can change over time.

Modifications:

- Add the `NameResolver` struct which provides an `AsyncSequence` of
  `NameResolutionResult`s.
- Add `NameResolutionResult` which provides an array of `Endpoint`s and
  a `ServiceConfiguration`.
- Add the `NameResolverFactory` protocol which returns a `NameResolver`
  for a given resolvable target
- Add the `ResolvableTarget` protocol
- Add the `NameResolverRegistry` which holds a set of name resolver
  factories and can return a `NameResolver` for a given target

Note, the actual resolver factories and targets will be added in a later PR.

Result:

All the boilerplate is in place for name resolvers.
George Barnett 1 year ago
parent
commit
ba4acac35d

+ 129 - 0
Sources/GRPCHTTP2Core/Client/Resolver/NameResolver.swift

@@ -0,0 +1,129 @@
+/*
+ * Copyright 2024, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import GRPCCore
+
+/// A name resolver can provide resolved addresses and service configuration values over time.
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+public struct NameResolver: Sendable {
+  /// A sequence of name resolution results.
+  ///
+  /// Resolvers may be push or pull based. Resolvers with the ``UpdateMode-swift.enum/push``
+  /// update mode have addresses pushed to them by an external source and you should subscribe
+  /// to changes in addresses by awaiting for new values in a loop.
+  ///
+  /// Resolvers with the ``UpdateMode-swift.enum/pull`` update mode shouldn't be subscribed to,
+  /// instead you should create an iterator and ask for new results as and when necessary.
+  public var names: RPCAsyncSequence<NameResolutionResult>
+
+  /// How ``names`` is updated and should be consumed.
+  public let updateMode: UpdateMode
+
+  public struct UpdateMode: Hashable, Sendable {
+    enum Value: Hashable, Sendable {
+      case push
+      case pull
+    }
+
+    let value: Value
+
+    private init(_ value: Value) {
+      self.value = value
+    }
+
+    /// Addresses are pushed to the resolve by an external source.
+    public static var push: Self { Self(.push) }
+
+    /// Addresses are resolved lazily, when the caller asks them to be resolved.
+    public static var pull: Self { Self(.pull) }
+  }
+
+  /// Create a new name resolver.
+  public init(results: RPCAsyncSequence<NameResolutionResult>, updateMode: UpdateMode) {
+    self.names = results
+    self.updateMode = updateMode
+  }
+}
+
+/// The result of name resolution, a list of endpoints to connect to and the service
+/// configuration reported by the resolver.
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+public struct NameResolutionResult: Hashable, Sendable {
+  /// A list of endpoints to connect to.
+  public var endpoints: [Endpoint]
+
+  /// The service configuration reported by the resolver, or an error if it couldn't be parsed.
+  public var serviceConfiguration: Result<ServiceConfiguration, RPCError>
+
+  public init(
+    endpoints: [Endpoint],
+    serviceConfiguration: Result<ServiceConfiguration, RPCError>
+  ) {
+    self.endpoints = endpoints
+    self.serviceConfiguration = serviceConfiguration
+  }
+}
+
+/// A group of addresses which are considered equivalent when establishing a connection.
+public struct Endpoint: Hashable, Sendable {
+  /// A list of equivalent addresses.
+  ///
+  /// Earlier addresses are typically but not always connected to first. Some load balancers may
+  /// choose to ignore the order.
+  public var addresses: [SocketAddress]
+
+  /// Create a new ``Endpoint``.
+  /// - Parameter addresses: A list of equivalent addresses.
+  public init(addresses: [SocketAddress]) {
+    self.addresses = addresses
+  }
+}
+
+/// A resolver capable of resolving targets of type ``Target``.
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+public protocol NameResolverFactory<Target> {
+  /// The type of ``ResolvableTarget`` this factory makes resolvers for.
+  associatedtype Target: ResolvableTarget
+
+  /// Creates a resolver for the given target.
+  ///
+  /// - Parameter target: The target to make a resolver for.
+  /// - Returns: The name resolver for the target.
+  func resolver(for target: Target) -> NameResolver
+}
+
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+extension NameResolverFactory {
+  /// Returns whether the given target is compatible with this factory.
+  ///
+  /// - Parameter target: The target to check the compatibility of.
+  /// - Returns: Whether the target is compatible with this factory.
+  func isCompatible<Other: ResolvableTarget>(withTarget target: Other) -> Bool {
+    return target is Target
+  }
+
+  /// Returns a name resolver if the given target is compatible.
+  ///
+  /// - Parameter target: The target to make a name resolver for.
+  /// - Returns: A name resolver or `nil` if the target isn't compatible.
+  func makeResolverIfCompatible<Other: ResolvableTarget>(_ target: Other) -> NameResolver? {
+    guard let target = target as? Target else { return nil }
+    return self.resolver(for: target)
+  }
+}
+
+/// A target which can be resolved to a ``SocketAddress``.
+public protocol ResolvableTarget {}

+ 151 - 0
Sources/GRPCHTTP2Core/Client/Resolver/NameResolverRegistry.swift

@@ -0,0 +1,151 @@
+/*
+ * Copyright 2024, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/// A registry for name resolver factories.
+///
+/// The registry provides name resolvers for resolvable targets. You can control which name
+/// resolvers are available by registering and removing resolvers by type. The following code
+/// demonstrates how to create a registry, add and remove resolver factories, and create a resolver.
+///
+/// ```swift
+/// // Create a new resolver registry with the default resolvers.
+/// var registry = NameResolverRegistry.defaults
+///
+/// // Register a custom resolver, the registry can now resolve targets of
+/// // type `CustomResolver.ResolvableTarget`.
+/// registry.registerFactory(CustomResolver())
+///
+/// // Remove the Unix Domain Socket and VSOCK resolvers, if they exist.
+/// registry.removeFactory(ofType: NameResolvers.UnixDomainSocket.self)
+/// registry.removeFactory(ofType: NameResolvers.VSOCK.self)
+///
+/// // Resolve an IPv4 target
+/// if let resolver = registry.makeResolver(for: .ipv4(host: "localhost", port: 80)) {
+///   // ...
+/// }
+/// ```
+@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
+public struct NameResolverRegistry {
+  private enum Factory {
+    case other(any NameResolverFactory)
+
+    init(_ factory: some NameResolverFactory) {
+      self = .other(factory)
+    }
+
+    func makeResolverIfCompatible<Target: ResolvableTarget>(_ target: Target) -> NameResolver? {
+      switch self {
+      case .other(let factory):
+        return factory.makeResolverIfCompatible(target)
+      }
+    }
+
+    func hasTarget<Target: ResolvableTarget>(_ target: Target) -> Bool {
+      switch self {
+      case .other(let factory):
+        return factory.isCompatible(withTarget: target)
+      }
+    }
+
+    func `is`<Factory: NameResolverFactory>(ofType factoryType: Factory.Type) -> Bool {
+      switch self {
+      case .other(let factory):
+        return type(of: factory) == factoryType
+      }
+    }
+  }
+
+  private var factories: [Factory]
+
+  /// Creates a new name resolver registry with no resolve factories.
+  public init() {
+    self.factories = []
+  }
+
+  /// Returns a new name resolver registry with the default factories registered.
+  public static var defaults: Self {
+    return NameResolverRegistry()
+  }
+
+  /// The number of resolver factories in the registry.
+  public var count: Int {
+    return self.factories.count
+  }
+
+  /// Whether there are no resolver factories in the registry.
+  public var isEmpty: Bool {
+    return self.factories.isEmpty
+  }
+
+  /// Registers a new name resolver factory.
+  ///
+  /// Any factories of the same type are removed prior to inserting the factory.
+  ///
+  /// - Parameter factory: The factory to register.
+  public mutating func registerFactory<Factory: NameResolverFactory>(_ factory: Factory) {
+    self.removeFactory(ofType: Factory.self)
+    self.factories.append(Self.Factory(factory))
+  }
+
+  /// Removes any factories which have the given type
+  ///
+  /// - Parameter type: The type of factory to remove.
+  /// - Returns: Whether a factory was removed.
+  @discardableResult
+  public mutating func removeFactory<Factory: NameResolverFactory>(
+    ofType type: Factory.Type
+  ) -> Bool {
+    let factoryCount = self.factories.count
+    self.factories.removeAll {
+      $0.is(ofType: Factory.self)
+    }
+    return self.factories.count < factoryCount
+  }
+
+  /// Returns whether the registry contains a factory of the given type.
+  ///
+  /// - Parameter type: The type of factory to look for.
+  /// - Returns: Whether the registry contained the factory of the given type.
+  public func containsFactory<Factory: NameResolverFactory>(ofType type: Factory.Type) -> Bool {
+    self.factories.contains {
+      $0.is(ofType: Factory.self)
+    }
+  }
+
+  /// Returns whether the registry contains a factory capable of resolving the given target.
+  ///
+  /// - Parameter target:
+  /// - Returns: Whether the registry contains a resolve capable of resolving the target.
+  public func containsFactory(capableOfResolving target: some ResolvableTarget) -> Bool {
+    self.factories.contains { $0.hasTarget(target) }
+  }
+
+  /// Makes a ``NameResolver`` for the target, if a suitable factory exists.
+  ///
+  /// If multiple factories exist which are capable of resolving the target then the first
+  /// is used.
+  ///
+  /// - Parameter target: The target to make a resolver for.
+  /// - Returns: The resolver, or `nil` if no factory could make a resolver for the target.
+  public func makeResolver(for target: some ResolvableTarget) -> NameResolver? {
+    for factory in self.factories {
+      if let resolver = factory.makeResolverIfCompatible(target) {
+        return resolver
+      }
+    }
+    return nil
+  }
+}

+ 131 - 0
Tests/GRPCHTTP2CoreTests/Client/Resolver/NameResolverRegistryTests.swift

@@ -0,0 +1,131 @@
+/*
+ * Copyright 2024, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import GRPCCore
+import GRPCHTTP2Core
+import XCTest
+
+final class NameResolverRegistryTests: XCTestCase {
+  struct FailingResolver: NameResolverFactory {
+    typealias Target = StringTarget
+
+    private let code: RPCError.Code
+
+    init(code: RPCError.Code = .unavailable) {
+      self.code = code
+    }
+
+    func resolver(for target: NameResolverRegistryTests.StringTarget) -> NameResolver {
+      let stream = AsyncThrowingStream(NameResolutionResult.self) {
+        $0.yield(with: .failure(RPCError(code: self.code, message: target.value)))
+      }
+
+      return NameResolver(results: RPCAsyncSequence(wrapping: stream), updateMode: .pull)
+    }
+  }
+
+  struct StringTarget: ResolvableTarget {
+    var value: String
+
+    init(value: String) {
+      self.value = value
+    }
+  }
+
+  func testEmptyNameResolvers() {
+    let resolvers = NameResolverRegistry()
+    XCTAssert(resolvers.isEmpty)
+    XCTAssertEqual(resolvers.count, 0)
+  }
+
+  func testRegisterFactory() async throws {
+    var resolvers = NameResolverRegistry()
+    resolvers.registerFactory(FailingResolver(code: .unknown))
+    XCTAssertEqual(resolvers.count, 1)
+
+    do {
+      let resolver = resolvers.makeResolver(for: StringTarget(value: "foo"))
+      await XCTAssertThrowsErrorAsync(ofType: RPCError.self) {
+        var iterator = resolver?.names.makeAsyncIterator()
+        _ = try await iterator?.next()
+      } errorHandler: { error in
+        XCTAssertEqual(error.code, .unknown)
+      }
+    }
+
+    // Adding a resolver of the same type replaces it. Use the code of the thrown error to
+    // distinguish between the instances.
+    resolvers.registerFactory(FailingResolver(code: .cancelled))
+    XCTAssertEqual(resolvers.count, 1)
+
+    do {
+      let resolver = resolvers.makeResolver(for: StringTarget(value: "foo"))
+      await XCTAssertThrowsErrorAsync(ofType: RPCError.self) {
+        var iterator = resolver?.names.makeAsyncIterator()
+        _ = try await iterator?.next()
+      } errorHandler: { error in
+        XCTAssertEqual(error.code, .cancelled)
+      }
+    }
+  }
+
+  func testRemoveFactory() {
+    var resolvers = NameResolverRegistry()
+    resolvers.registerFactory(FailingResolver())
+    XCTAssertEqual(resolvers.count, 1)
+
+    resolvers.removeFactory(ofType: FailingResolver.self)
+    XCTAssertEqual(resolvers.count, 0)
+
+    // Removing an unknown factory is a no-op.
+    resolvers.removeFactory(ofType: FailingResolver.self)
+    XCTAssertEqual(resolvers.count, 0)
+  }
+
+  func testContainsFactoryOfType() {
+    var resolvers = NameResolverRegistry()
+    XCTAssertFalse(resolvers.containsFactory(ofType: FailingResolver.self))
+
+    resolvers.registerFactory(FailingResolver())
+    XCTAssertTrue(resolvers.containsFactory(ofType: FailingResolver.self))
+  }
+
+  func testContainsFactoryCapableOfResolving() {
+    var resolvers = NameResolverRegistry()
+    XCTAssertFalse(resolvers.containsFactory(capableOfResolving: StringTarget(value: "")))
+
+    resolvers.registerFactory(FailingResolver())
+    XCTAssertTrue(resolvers.containsFactory(capableOfResolving: StringTarget(value: "")))
+  }
+
+  func testMakeFailingResolver() async throws {
+    var resolvers = NameResolverRegistry()
+    XCTAssertNil(resolvers.makeResolver(for: StringTarget(value: "")))
+
+    resolvers.registerFactory(FailingResolver())
+
+    let resolver = try XCTUnwrap(resolvers.makeResolver(for: StringTarget(value: "foo")))
+    XCTAssertEqual(resolver.updateMode, .pull)
+
+    var iterator = resolver.names.makeAsyncIterator()
+    await XCTAssertThrowsErrorAsync(ofType: RPCError.self) {
+      try await iterator.next()
+    } errorHandler: { error in
+      XCTAssertEqual(error.code, .unavailable)
+      XCTAssertEqual(error.message, "foo")
+    }
+  }
+}

+ 21 - 0
Tests/GRPCHTTP2CoreTests/Test Utilities/XCTest+Utilities.swift

@@ -37,3 +37,24 @@ func XCTAssertDescription(
 ) {
   XCTAssertEqual(String(describing: subject), expected, file: file, line: line)
 }
+
+func XCTUnwrapAsync<T>(_ expression: () async throws -> T?) async throws -> T {
+  let value = try await expression()
+  return try XCTUnwrap(value)
+}
+
+@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
+func XCTAssertThrowsErrorAsync<T, E: Error>(
+  ofType: E.Type = E.self,
+  _ expression: () async throws -> T,
+  errorHandler: (E) -> Void
+) async {
+  do {
+    _ = try await expression()
+    XCTFail("Expression didn't throw")
+  } catch let error as E {
+    errorHandler(error)
+  } catch {
+    XCTFail("Error had unexpected type '\(type(of: error))'")
+  }
+}