diff --git a/Package.swift b/Package.swift index 4c120eaa..2f4cc509 100644 --- a/Package.swift +++ b/Package.swift @@ -50,7 +50,7 @@ let dependencies: [Package.Dependency] = [ // This adds some build settings which allow us to map "@available(gRPCSwift 2.x, *)" to // the appropriate OS platforms. -let nextMinorVersion = 1 +let nextMinorVersion = 2 let availabilitySettings: [SwiftSetting] = (0 ... nextMinorVersion).map { minor in let name = "gRPCSwift" let version = "2.\(minor)" diff --git a/Sources/GRPCCore/Call/ConditionalInterceptor.swift b/Sources/GRPCCore/Call/ConditionalInterceptor.swift index c5302260..51b4510e 100644 --- a/Sources/GRPCCore/Call/ConditionalInterceptor.swift +++ b/Sources/GRPCCore/Call/ConditionalInterceptor.swift @@ -19,51 +19,96 @@ /// You can configure interceptors to be applied to: /// - all RPCs and services; /// - requests directed only to specific services; or -/// - requests directed only to specific methods (of a specific service). +/// - requests directed only to specific methods (of a specific service); or +/// - requests directed only to specific services or methods (of a specific service); or +/// - all requests excluding requests directed to specific services or methods (of a specific service); or +/// - requests whose ``MethodDescriptor`` satisfies a predicate. /// /// - SeeAlso: ``ClientInterceptor`` and ``ServerInterceptor`` for more information on client and /// server interceptors, respectively. @available(gRPCSwift 2.0, *) public struct ConditionalInterceptor: Sendable { public struct Subject: Sendable { - internal enum Wrapped: Sendable { - case all - case services(Set) - case methods(Set) - } - private let wrapped: Wrapped + private let predicate: @Sendable (_ descriptor: MethodDescriptor) -> Bool /// An operation subject specifying an interceptor that applies to all RPCs across all services will be registered with this client. - public static var all: Self { .init(wrapped: .all) } + public static var all: Self { + Self { _ in true } + } /// An operation subject specifying an interceptor that will be applied only to RPCs directed to the specified services. + /// /// - Parameters: - /// - services: The list of service names for which this interceptor should intercept RPCs. + /// - services: The list of service descriptors for which this interceptor should intercept RPCs. public static func services(_ services: Set) -> Self { - Self(wrapped: .services(services)) + Self { descriptor in + services.contains(descriptor.service) + } } /// An operation subject specifying an interceptor that will be applied only to RPCs directed to the specified service methods. + /// /// - Parameters: /// - methods: The list of method descriptors for which this interceptor should intercept RPCs. public static func methods(_ methods: Set) -> Self { - Self(wrapped: .methods(methods)) + Self { descriptor in + methods.contains(descriptor) + } } - @usableFromInline - package func applies(to descriptor: MethodDescriptor) -> Bool { - switch self.wrapped { - case .all: - return true - - case .services(let services): - return services.contains(descriptor.service) + /// An operation subject specifying an interceptor that will be applied only to RPCs directed to the specified services or service methods. + /// + /// - Parameters: + /// - services: The list of service descriptors for which this interceptor should intercept RPCs. + /// - methods: The list of method descriptors for which this interceptor should intercept RPCs. + @available(gRPCSwift 2.2, *) + public static func only( + services: Set, + methods: Set + ) -> Self { + Self { descriptor in + services.contains(descriptor.service) || methods.contains(descriptor) + } + } - case .methods(let methods): - return methods.contains(descriptor) + /// An operation subject specifying an interceptor that will be applied to all RPCs across all services for this client excluding the specified services or service methods. + /// + /// - Parameters: + /// - services: The list of service descriptors for which this interceptor should **not** intercept RPCs. + /// - methods: The list of method descriptors for which this interceptor should **not** intercept RPCs. + @available(gRPCSwift 2.2, *) + public static func allExcluding( + services: Set, + methods: Set + ) -> Self { + Self { descriptor in + !(services.contains(descriptor.service) || methods.contains(descriptor)) } } + + /// An operation subject specifying an interceptor that will be applied to RPCs whose ``MethodDescriptor`` satisfies a `predicate`. + /// + /// - Important: The result of `predicate` is **cached per `MethodDescriptor`** by the client. + /// The predicate is evaluated the first time a given method is encountered, and that result + /// is reused for subsequent RPCs of the same method for the lifetime of the client. + /// As a consequence, the `predicate` closure should be **deterministic**. + /// Do **not** base it on dynamic state (time, session, feature flags, etc.). + /// If you need per-call decisions, put that logic inside the interceptor itself. + /// + /// - Parameters: + /// - predicate: A `@Sendable` closure evaluated per RPC. Return `true` to intercept. + @available(gRPCSwift 2.2, *) + public static func allMatching( + _ predicate: @Sendable @escaping (_ descriptor: MethodDescriptor) -> Bool + ) -> Self { + Self(predicate: predicate) + } + + @usableFromInline + package func applies(to descriptor: MethodDescriptor) -> Bool { + self.predicate(descriptor) + } } /// The interceptor. diff --git a/Tests/GRPCCoreTests/Call/ConditionalInterceptorTests.swift b/Tests/GRPCCoreTests/Call/ConditionalInterceptorTests.swift index bbaa05d0..4caa3c51 100644 --- a/Tests/GRPCCoreTests/Call/ConditionalInterceptorTests.swift +++ b/Tests/GRPCCoreTests/Call/ConditionalInterceptorTests.swift @@ -20,7 +20,7 @@ import Testing @Suite("ConditionalInterceptor") struct ConditionalInterceptorTests { @Test( - "Applies to", + "Applies to all, services and methods", arguments: [ ( .all, @@ -53,6 +53,60 @@ struct ConditionalInterceptorTests { #expect(!target.applies(to: notApplicableMethod)) } } + + @Test( + "Applies to only and allExcluding", + arguments: [ + ( + .only(services: [.foo], methods: [.barFoo]), + [.fooBar, .fooBaz, .barFoo], + [.barBaz] + ), + ( + .allExcluding(services: [.foo], methods: [.barFoo]), + [.barBaz], + [.fooBar, .fooBaz, .barFoo] + ), + ] as [(ConditionalInterceptor.Subject, [MethodDescriptor], [MethodDescriptor])] + ) + @available(gRPCSwift 2.2, *) + func appliesToOnlyAndAllExcluding( + target: ConditionalInterceptor.Subject, + applicableMethods: [MethodDescriptor], + notApplicableMethods: [MethodDescriptor] + ) { + for applicableMethod in applicableMethods { + #expect(target.applies(to: applicableMethod)) + } + + for notApplicableMethod in notApplicableMethods { + #expect(!target.applies(to: notApplicableMethod)) + } + } + + @Test("Applies to all matching") + @available(gRPCSwift 2.2, *) + func appliesToAllMatching() { + let target = ConditionalInterceptor.Subject.allMatching { descriptor in + descriptor.method == "baz" + } + let applicableMethods: [MethodDescriptor] = [.fooBaz, .barBaz] + let notApplicableMethods: [MethodDescriptor] = [.fooBar, .barFoo] + + for applicableMethod in applicableMethods { + #expect(target.applies(to: applicableMethod)) + } + + for notApplicableMethod in notApplicableMethods { + #expect(!target.applies(to: notApplicableMethod)) + } + } +} + +@available(gRPCSwift 2.2, *) +extension ServiceDescriptor { + fileprivate static let foo = Self(fullyQualifiedService: "pkg.foo") + fileprivate static let bar = Self(fullyQualifiedService: "pkg.bar") } @available(gRPCSwift 2.0, *) @@ -60,5 +114,5 @@ extension MethodDescriptor { fileprivate static let fooBar = Self(fullyQualifiedService: "pkg.foo", method: "bar") fileprivate static let fooBaz = Self(fullyQualifiedService: "pkg.foo", method: "baz") fileprivate static let barFoo = Self(fullyQualifiedService: "pkg.bar", method: "foo") - fileprivate static let barBaz = Self(fullyQualifiedService: "pkg.bar", method: "Baz") + fileprivate static let barBaz = Self(fullyQualifiedService: "pkg.bar", method: "baz") }