diff --git a/Package@swift-6.swift b/Package@swift-6.swift index ba5f2176d..5cbb769bd 100644 --- a/Package@swift-6.swift +++ b/Package@swift-6.swift @@ -168,6 +168,7 @@ extension Target.Dependency { static var grpcHTTP2Core: Self { .target(name: "GRPCHTTP2Core") } static var grpcHTTP2TransportNIOPosix: Self { .target(name: "GRPCHTTP2TransportNIOPosix") } static var grpcHTTP2TransportNIOTransportServices: Self { .target(name: "GRPCHTTP2TransportNIOTransportServices") } + static var grpcHealth: Self { .target(name: "GRPCHealth") } } // MARK: - Targets @@ -510,6 +511,19 @@ extension Target { ) } + static var grpcHealthTests: Target { + .testTarget( + name: "GRPCHealthTests", + dependencies: [ + .grpcHealth, + .grpcProtobuf, + .grpcInProcessTransport + ], + path: "Tests/Services/HealthTests", + swiftSettings: [._swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny")] + ) + } + static var interopTestModels: Target { .target( name: "GRPCInteroperabilityTestModels", @@ -893,8 +907,7 @@ extension Target { path: "Sources/Services/Health", swiftSettings: [ ._swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") + .enableUpcomingFeature("ExistentialAny") ] ) } @@ -1046,6 +1059,7 @@ let package = Package( .grpcInterceptorsTests, .grpcHTTP2CoreTests, .grpcHTTP2TransportTests, + .grpcHealthTests, .grpcProtobufTests, .grpcProtobufCodeGenTests, .inProcessInteroperabilityTests, diff --git a/Protos/generate.sh b/Protos/generate.sh index 4be32ec1d..3e5c05631 100755 --- a/Protos/generate.sh +++ b/Protos/generate.sh @@ -244,8 +244,8 @@ function generate_health_service { local proto="$here/upstream/grpc/health/v1/health.proto" local output="$root/Sources/Services/Health/Generated" - generate_message "$proto" "$(dirname "$proto")" "$output" "Visibility=Internal" - generate_grpc "$proto" "$(dirname "$proto")" "$output" "Visibility=Internal" "Client=false" "Server=true" "_V2=true" + generate_message "$proto" "$(dirname "$proto")" "$output" "Visibility=Package" + generate_grpc "$proto" "$(dirname "$proto")" "$output" "Visibility=Package" "Client=true" "Server=true" "_V2=true" } #------------------------------------------------------------------------------ diff --git a/Sources/Services/Health/Generated/health.grpc.swift b/Sources/Services/Health/Generated/health.grpc.swift index 769b3a893..05c4387b4 100644 --- a/Sources/Services/Health/Generated/health.grpc.swift +++ b/Sources/Services/Health/Generated/health.grpc.swift @@ -27,40 +27,44 @@ import GRPCCore import GRPCProtobuf -internal enum Grpc_Health_V1_Health { - internal enum Method { - internal enum Check { - internal typealias Input = Grpc_Health_V1_HealthCheckRequest - internal typealias Output = Grpc_Health_V1_HealthCheckResponse - internal static let descriptor = MethodDescriptor( +package enum Grpc_Health_V1_Health { + package enum Method { + package enum Check { + package typealias Input = Grpc_Health_V1_HealthCheckRequest + package typealias Output = Grpc_Health_V1_HealthCheckResponse + package static let descriptor = MethodDescriptor( service: "grpc.health.v1.Health", method: "Check" ) } - internal enum Watch { - internal typealias Input = Grpc_Health_V1_HealthCheckRequest - internal typealias Output = Grpc_Health_V1_HealthCheckResponse - internal static let descriptor = MethodDescriptor( + package enum Watch { + package typealias Input = Grpc_Health_V1_HealthCheckRequest + package typealias Output = Grpc_Health_V1_HealthCheckResponse + package static let descriptor = MethodDescriptor( service: "grpc.health.v1.Health", method: "Watch" ) } - internal static let descriptors: [MethodDescriptor] = [ + package static let descriptors: [MethodDescriptor] = [ Check.descriptor, Watch.descriptor ] } @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) - internal typealias StreamingServiceProtocol = Grpc_Health_V1_HealthStreamingServiceProtocol + package typealias StreamingServiceProtocol = Grpc_Health_V1_HealthStreamingServiceProtocol @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) - internal typealias ServiceProtocol = Grpc_Health_V1_HealthServiceProtocol + package typealias ServiceProtocol = Grpc_Health_V1_HealthServiceProtocol + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + package typealias ClientProtocol = Grpc_Health_V1_HealthClientProtocol + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + package typealias Client = Grpc_Health_V1_HealthClient } /// Health is gRPC's mechanism for checking whether a server is able to handle /// RPCs. Its semantics are documented in /// https://github.com/grpc/grpc/blob/master/doc/health-checking.md. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) -internal protocol Grpc_Health_V1_HealthStreamingServiceProtocol: GRPCCore.RegistrableRPCService { +package protocol Grpc_Health_V1_HealthStreamingServiceProtocol: GRPCCore.RegistrableRPCService { /// Check gets the health of the specified service. If the requested service /// is unknown, the call will fail with status NOT_FOUND. If the caller does /// not specify a service name, the server should respond with its overall @@ -94,7 +98,7 @@ internal protocol Grpc_Health_V1_HealthStreamingServiceProtocol: GRPCCore.Regist @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension Grpc_Health_V1_Health.StreamingServiceProtocol { @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) - internal func registerMethods(with router: inout GRPCCore.RPCRouter) { + package func registerMethods(with router: inout GRPCCore.RPCRouter) { router.registerHandler( forMethod: Grpc_Health_V1_Health.Method.Check.descriptor, deserializer: ProtobufDeserializer(), @@ -118,7 +122,7 @@ extension Grpc_Health_V1_Health.StreamingServiceProtocol { /// RPCs. Its semantics are documented in /// https://github.com/grpc/grpc/blob/master/doc/health-checking.md. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) -internal protocol Grpc_Health_V1_HealthServiceProtocol: Grpc_Health_V1_Health.StreamingServiceProtocol { +package protocol Grpc_Health_V1_HealthServiceProtocol: Grpc_Health_V1_Health.StreamingServiceProtocol { /// Check gets the health of the specified service. If the requested service /// is unknown, the call will fail with status NOT_FOUND. If the caller does /// not specify a service name, the server should respond with its overall @@ -151,13 +155,160 @@ internal protocol Grpc_Health_V1_HealthServiceProtocol: Grpc_Health_V1_Health.St /// Partial conformance to `Grpc_Health_V1_HealthStreamingServiceProtocol`. @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension Grpc_Health_V1_Health.ServiceProtocol { - internal func check(request: ServerRequest.Stream) async throws -> ServerResponse.Stream { + package func check(request: ServerRequest.Stream) async throws -> ServerResponse.Stream { let response = try await self.check(request: ServerRequest.Single(stream: request)) return ServerResponse.Stream(single: response) } - internal func watch(request: ServerRequest.Stream) async throws -> ServerResponse.Stream { + package func watch(request: ServerRequest.Stream) async throws -> ServerResponse.Stream { let response = try await self.watch(request: ServerRequest.Single(stream: request)) return response } +} + +/// Health is gRPC's mechanism for checking whether a server is able to handle +/// RPCs. Its semantics are documented in +/// https://github.com/grpc/grpc/blob/master/doc/health-checking.md. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +package protocol Grpc_Health_V1_HealthClientProtocol: Sendable { + /// Check gets the health of the specified service. If the requested service + /// is unknown, the call will fail with status NOT_FOUND. If the caller does + /// not specify a service name, the server should respond with its overall + /// health status. + /// + /// Clients should set a deadline when calling Check, and can declare the + /// server unhealthy if they do not receive a timely response. + /// + /// Check implementations should be idempotent and side effect free. + func check( + request: ClientRequest.Single, + serializer: some MessageSerializer, + deserializer: some MessageDeserializer, + options: CallOptions, + _ body: @Sendable @escaping (ClientResponse.Single) async throws -> R + ) async throws -> R where R: Sendable + + /// Performs a watch for the serving status of the requested service. + /// The server will immediately send back a message indicating the current + /// serving status. It will then subsequently send a new message whenever + /// the service's serving status changes. + /// + /// If the requested service is unknown when the call is received, the + /// server will send a message setting the serving status to + /// SERVICE_UNKNOWN but will *not* terminate the call. If at some + /// future point, the serving status of the service becomes known, the + /// server will send a new message with the service's serving status. + /// + /// If the call terminates with status UNIMPLEMENTED, then clients + /// should assume this method is not supported and should not retry the + /// call. If the call terminates with any other status (including OK), + /// clients should retry the call with appropriate exponential backoff. + func watch( + request: ClientRequest.Single, + serializer: some MessageSerializer, + deserializer: some MessageDeserializer, + options: CallOptions, + _ body: @Sendable @escaping (ClientResponse.Stream) async throws -> R + ) async throws -> R where R: Sendable +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Grpc_Health_V1_Health.ClientProtocol { + package func check( + request: ClientRequest.Single, + options: CallOptions = .defaults, + _ body: @Sendable @escaping (ClientResponse.Single) async throws -> R + ) async throws -> R where R: Sendable { + try await self.check( + request: request, + serializer: ProtobufSerializer(), + deserializer: ProtobufDeserializer(), + options: options, + body + ) + } + + package func watch( + request: ClientRequest.Single, + options: CallOptions = .defaults, + _ body: @Sendable @escaping (ClientResponse.Stream) async throws -> R + ) async throws -> R where R: Sendable { + try await self.watch( + request: request, + serializer: ProtobufSerializer(), + deserializer: ProtobufDeserializer(), + options: options, + body + ) + } +} + +/// Health is gRPC's mechanism for checking whether a server is able to handle +/// RPCs. Its semantics are documented in +/// https://github.com/grpc/grpc/blob/master/doc/health-checking.md. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +package struct Grpc_Health_V1_HealthClient: Grpc_Health_V1_Health.ClientProtocol { + private let client: GRPCCore.GRPCClient + + package init(client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Check gets the health of the specified service. If the requested service + /// is unknown, the call will fail with status NOT_FOUND. If the caller does + /// not specify a service name, the server should respond with its overall + /// health status. + /// + /// Clients should set a deadline when calling Check, and can declare the + /// server unhealthy if they do not receive a timely response. + /// + /// Check implementations should be idempotent and side effect free. + package func check( + request: ClientRequest.Single, + serializer: some MessageSerializer, + deserializer: some MessageDeserializer, + options: CallOptions = .defaults, + _ body: @Sendable @escaping (ClientResponse.Single) async throws -> R + ) async throws -> R where R: Sendable { + try await self.client.unary( + request: request, + descriptor: Grpc_Health_V1_Health.Method.Check.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + handler: body + ) + } + + /// Performs a watch for the serving status of the requested service. + /// The server will immediately send back a message indicating the current + /// serving status. It will then subsequently send a new message whenever + /// the service's serving status changes. + /// + /// If the requested service is unknown when the call is received, the + /// server will send a message setting the serving status to + /// SERVICE_UNKNOWN but will *not* terminate the call. If at some + /// future point, the serving status of the service becomes known, the + /// server will send a new message with the service's serving status. + /// + /// If the call terminates with status UNIMPLEMENTED, then clients + /// should assume this method is not supported and should not retry the + /// call. If the call terminates with any other status (including OK), + /// clients should retry the call with appropriate exponential backoff. + package func watch( + request: ClientRequest.Single, + serializer: some MessageSerializer, + deserializer: some MessageDeserializer, + options: CallOptions = .defaults, + _ body: @Sendable @escaping (ClientResponse.Stream) async throws -> R + ) async throws -> R where R: Sendable { + try await self.client.serverStreaming( + request: request, + descriptor: Grpc_Health_V1_Health.Method.Watch.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + handler: body + ) + } } \ No newline at end of file diff --git a/Sources/Services/Health/Generated/health.pb.swift b/Sources/Services/Health/Generated/health.pb.swift index 883a55443..f95e659f8 100644 --- a/Sources/Services/Health/Generated/health.pb.swift +++ b/Sources/Services/Health/Generated/health.pb.swift @@ -37,29 +37,29 @@ fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAP typealias Version = _2 } -struct Grpc_Health_V1_HealthCheckRequest: Sendable { +package struct Grpc_Health_V1_HealthCheckRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - var service: String = String() + package var service: String = String() - var unknownFields = SwiftProtobuf.UnknownStorage() + package var unknownFields = SwiftProtobuf.UnknownStorage() - init() {} + package init() {} } -struct Grpc_Health_V1_HealthCheckResponse: Sendable { +package struct Grpc_Health_V1_HealthCheckResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - var status: Grpc_Health_V1_HealthCheckResponse.ServingStatus = .unknown + package var status: Grpc_Health_V1_HealthCheckResponse.ServingStatus = .unknown - var unknownFields = SwiftProtobuf.UnknownStorage() + package var unknownFields = SwiftProtobuf.UnknownStorage() - enum ServingStatus: SwiftProtobuf.Enum, Swift.CaseIterable { - typealias RawValue = Int + package enum ServingStatus: SwiftProtobuf.Enum, Swift.CaseIterable { + package typealias RawValue = Int case unknown // = 0 case serving // = 1 case notServing // = 2 @@ -68,11 +68,11 @@ struct Grpc_Health_V1_HealthCheckResponse: Sendable { case serviceUnknown // = 3 case UNRECOGNIZED(Int) - init() { + package init() { self = .unknown } - init?(rawValue: Int) { + package init?(rawValue: Int) { switch rawValue { case 0: self = .unknown case 1: self = .serving @@ -82,7 +82,7 @@ struct Grpc_Health_V1_HealthCheckResponse: Sendable { } } - var rawValue: Int { + package var rawValue: Int { switch self { case .unknown: return 0 case .serving: return 1 @@ -93,7 +93,7 @@ struct Grpc_Health_V1_HealthCheckResponse: Sendable { } // The compiler won't synthesize support with the UNRECOGNIZED case. - static let allCases: [Grpc_Health_V1_HealthCheckResponse.ServingStatus] = [ + package static let allCases: [Grpc_Health_V1_HealthCheckResponse.ServingStatus] = [ .unknown, .serving, .notServing, @@ -102,7 +102,7 @@ struct Grpc_Health_V1_HealthCheckResponse: Sendable { } - init() {} + package init() {} } // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -110,12 +110,12 @@ struct Grpc_Health_V1_HealthCheckResponse: Sendable { fileprivate let _protobuf_package = "grpc.health.v1" extension Grpc_Health_V1_HealthCheckRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".HealthCheckRequest" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + package static let protoMessageName: String = _protobuf_package + ".HealthCheckRequest" + package static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "service"), ] - mutating func decodeMessage(decoder: inout D) throws { + package mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are @@ -127,14 +127,14 @@ extension Grpc_Health_V1_HealthCheckRequest: SwiftProtobuf.Message, SwiftProtobu } } - func traverse(visitor: inout V) throws { + package func traverse(visitor: inout V) throws { if !self.service.isEmpty { try visitor.visitSingularStringField(value: self.service, fieldNumber: 1) } try unknownFields.traverse(visitor: &visitor) } - static func ==(lhs: Grpc_Health_V1_HealthCheckRequest, rhs: Grpc_Health_V1_HealthCheckRequest) -> Bool { + package static func ==(lhs: Grpc_Health_V1_HealthCheckRequest, rhs: Grpc_Health_V1_HealthCheckRequest) -> Bool { if lhs.service != rhs.service {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true @@ -142,12 +142,12 @@ extension Grpc_Health_V1_HealthCheckRequest: SwiftProtobuf.Message, SwiftProtobu } extension Grpc_Health_V1_HealthCheckResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".HealthCheckResponse" - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + package static let protoMessageName: String = _protobuf_package + ".HealthCheckResponse" + package static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "status"), ] - mutating func decodeMessage(decoder: inout D) throws { + package mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { // The use of inline closures is to circumvent an issue where the compiler // allocates stack space for every case branch when no optimizations are @@ -159,14 +159,14 @@ extension Grpc_Health_V1_HealthCheckResponse: SwiftProtobuf.Message, SwiftProtob } } - func traverse(visitor: inout V) throws { + package func traverse(visitor: inout V) throws { if self.status != .unknown { try visitor.visitSingularEnumField(value: self.status, fieldNumber: 1) } try unknownFields.traverse(visitor: &visitor) } - static func ==(lhs: Grpc_Health_V1_HealthCheckResponse, rhs: Grpc_Health_V1_HealthCheckResponse) -> Bool { + package static func ==(lhs: Grpc_Health_V1_HealthCheckResponse, rhs: Grpc_Health_V1_HealthCheckResponse) -> Bool { if lhs.status != rhs.status {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true @@ -174,7 +174,7 @@ extension Grpc_Health_V1_HealthCheckResponse: SwiftProtobuf.Message, SwiftProtob } extension Grpc_Health_V1_HealthCheckResponse.ServingStatus: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + package static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "UNKNOWN"), 1: .same(proto: "SERVING"), 2: .same(proto: "NOT_SERVING"), diff --git a/Sources/Services/Health/Health.swift b/Sources/Services/Health/Health.swift new file mode 100644 index 000000000..f0cdb1a34 --- /dev/null +++ b/Sources/Services/Health/Health.swift @@ -0,0 +1,127 @@ +/* + * 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 + +/// ``Health`` is gRPC’s mechanism for checking whether a server is able to handle RPCs. Its semantics are documented in +/// https://github.com/grpc/grpc/blob/master/doc/health-checking.md. +/// +/// `Health` initializes a new ``Health/Service-swift.struct`` and ``Health/Provider-swift.struct``. +/// - `Health.Service` implements the Health service from the `grpc.health.v1` package and can be registered with a server +/// like any other service. +/// - `Health.Provider` provides status updates to `Health.Service`. `Health.Service` doesn't know about the other +/// services running on a server so it must be provided with status updates via `Health.Provider`. To make specifying the service +/// being updated easier, the generated code for services includes an extension to `ServiceDescriptor`. +/// +/// The following shows an example of initializing a Health service and updating the status of the `Foo` service in the `bar` package. +/// +/// ```swift +/// let health = Health() +/// let server = GRPCServer( +/// transport: transport, +/// services: [health.service, FooService()] +/// ) +/// +/// health.provider.updateStatus( +/// .serving, +/// forService: .bar_Foo +/// ) +/// ``` +@available(macOS 15.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +public struct Health: Sendable { + /// An implementation of the `grpc.health.v1.Health` service. + public let service: Health.Service + + /// Provides status updates to the Health service. + public let provider: Health.Provider + + /// Constructs a new ``Health``, initializing a ``Health/Service-swift.struct`` and a + /// ``Health/Provider-swift.struct``. + public init() { + let healthService = HealthService() + + self.service = Health.Service(healthService: healthService) + self.provider = Health.Provider(healthService: healthService) + } +} + +@available(macOS 15.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension Health { + /// An implementation of the `grpc.health.v1.Health` service. + public struct Service: RegistrableRPCService, Sendable { + private let healthService: HealthService + + public func registerMethods(with router: inout RPCRouter) { + self.healthService.registerMethods(with: &router) + } + + fileprivate init(healthService: HealthService) { + self.healthService = healthService + } + } + + /// Provides status updates to ``Health/Service-swift.struct``. + public struct Provider: Sendable { + private let healthService: HealthService + + /// Updates the status of a service. + /// + /// - Parameters: + /// - status: The status of the service. + /// - service: The description of the service. + public func updateStatus( + _ status: ServingStatus, + forService service: ServiceDescriptor + ) { + self.healthService.updateStatus( + Grpc_Health_V1_HealthCheckResponse.ServingStatus(status), + forService: service.fullyQualifiedService + ) + } + + /// Updates the status of a service. + /// + /// - Parameters: + /// - status: The status of the service. + /// - service: The fully qualified service name in the format: + /// - "package.service": if the service is part of a package. For example, "helloworld.Greeter". + /// - "service": if the service is not part of a package. For example, "Greeter". + public func updateStatus( + _ status: ServingStatus, + forService service: String + ) { + self.healthService.updateStatus( + Grpc_Health_V1_HealthCheckResponse.ServingStatus(status), + forService: service + ) + } + + fileprivate init(healthService: HealthService) { + self.healthService = healthService + } + } +} + +extension Grpc_Health_V1_HealthCheckResponse.ServingStatus { + package init(_ status: ServingStatus) { + switch status.value { + case .serving: + self = .serving + case .notServing: + self = .notServing + } + } +} diff --git a/Sources/Services/Health/HealthService.swift b/Sources/Services/Health/HealthService.swift new file mode 100644 index 000000000..b8b54e80c --- /dev/null +++ b/Sources/Services/Health/HealthService.swift @@ -0,0 +1,132 @@ +/* + * 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 + +@available(macOS 15.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +internal struct HealthService: Grpc_Health_V1_HealthServiceProtocol { + private let state = HealthService.State() + + func check( + request: ServerRequest.Single + ) async throws -> ServerResponse.Single { + let service = request.message.service + + guard let status = self.state.currentStatus(ofService: service) else { + throw RPCError(code: .notFound, message: "Requested service unknown.") + } + + var response = Grpc_Health_V1_HealthCheckResponse() + response.status = status + + return ServerResponse.Single(message: response) + } + + func watch( + request: ServerRequest.Single + ) async -> ServerResponse.Stream { + let service = request.message.service + let statuses = AsyncStream.makeStream(of: Grpc_Health_V1_HealthCheckResponse.ServingStatus.self) + + self.state.addContinuation(statuses.continuation, forService: service) + + return ServerResponse.Stream( + of: Grpc_Health_V1_HealthCheckResponse.self + ) { writer in + var response = Grpc_Health_V1_HealthCheckResponse() + + for await status in statuses.stream { + response.status = status + try await writer.write(response) + } + + return [:] + } + } + + func updateStatus( + _ status: Grpc_Health_V1_HealthCheckResponse.ServingStatus, + forService service: String + ) { + self.state.updateStatus(status, forService: service) + } +} + +@available(macOS 15.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension HealthService { + private struct State: Sendable { + // The state of each service keyed by the fully qualified service name. + private let lockedStorage = LockedValueBox([String: ServiceState]()) + + fileprivate func currentStatus( + ofService service: String + ) -> Grpc_Health_V1_HealthCheckResponse.ServingStatus? { + return self.lockedStorage.withLockedValue { $0[service]?.currentStatus } + } + + fileprivate func updateStatus( + _ status: Grpc_Health_V1_HealthCheckResponse.ServingStatus, + forService service: String + ) { + self.lockedStorage.withLockedValue { storage in + storage[service, default: ServiceState(status: status)].updateStatus(status) + } + } + + fileprivate func addContinuation( + _ continuation: AsyncStream.Continuation, + forService service: String + ) { + self.lockedStorage.withLockedValue { storage in + storage[service, default: ServiceState(status: .serviceUnknown)] + .addContinuation(continuation) + } + } + } + + // Encapsulates the current status of a service and the continuations of its watch streams. + private struct ServiceState: Sendable { + private(set) var currentStatus: Grpc_Health_V1_HealthCheckResponse.ServingStatus + private var continuations: + [AsyncStream.Continuation] + + fileprivate mutating func updateStatus( + _ status: Grpc_Health_V1_HealthCheckResponse.ServingStatus + ) { + guard status != self.currentStatus else { + return + } + + self.currentStatus = status + + for continuation in self.continuations { + continuation.yield(status) + } + } + + fileprivate mutating func addContinuation( + _ continuation: AsyncStream.Continuation + ) { + self.continuations.append(continuation) + continuation.yield(self.currentStatus) + } + + fileprivate init(status: Grpc_Health_V1_HealthCheckResponse.ServingStatus = .unknown) { + self.currentStatus = status + self.continuations = [] + } + } +} diff --git a/Sources/Services/Health/ServingStatus.swift b/Sources/Services/Health/ServingStatus.swift new file mode 100644 index 000000000..cc0fd5b15 --- /dev/null +++ b/Sources/Services/Health/ServingStatus.swift @@ -0,0 +1,38 @@ +/* + * 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. + */ + +/// The status of a service. +/// +/// - ``ServingStatus/serving`` indicates that a service is healthy. +/// - ``ServingStatus/notServing`` indicates that a service is unhealthy. +public struct ServingStatus: Sendable, Hashable { + internal enum Value: Sendable, Hashable { + case serving + case notServing + } + + /// A status indicating that a service is healthy. + public static let serving = ServingStatus(.serving) + + /// A status indicating that a service unhealthy. + public static let notServing = ServingStatus(.notServing) + + internal var value: Value + + private init(_ value: Value) { + self.value = value + } +} diff --git a/Tests/Services/HealthTests/HealthTests.swift b/Tests/Services/HealthTests/HealthTests.swift new file mode 100644 index 000000000..8e8517b0c --- /dev/null +++ b/Tests/Services/HealthTests/HealthTests.swift @@ -0,0 +1,287 @@ +/* + * 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 GRPCHealth +import GRPCInProcessTransport +import XCTest + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +final class HealthTests: XCTestCase { + private func withHealthClient( + _ body: @Sendable (Grpc_Health_V1_HealthClient, Health.Provider) async throws -> Void + ) async throws { + let health = Health() + let inProcess = InProcessTransport.makePair() + let server = GRPCServer(transport: inProcess.server, services: [health.service]) + let client = GRPCClient(transport: inProcess.client) + let healthClient = Grpc_Health_V1_HealthClient(client: client) + + try await withThrowingDiscardingTaskGroup { group in + group.addTask { + try await server.run() + } + + group.addTask { + try await client.run() + } + + do { + try await body(healthClient, health.provider) + } catch { + XCTFail("Unexpected error: \(error)") + } + + group.cancelAll() + } + } + + func testCheckOnKnownService() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let testServiceDescriptor = ServiceDescriptor.testService + + healthProvider.updateStatus(.serving, forService: testServiceDescriptor) + + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = testServiceDescriptor.fullyQualifiedService + } + + try await healthClient.check(request: ClientRequest.Single(message: message)) { response in + try XCTAssertEqual(response.message.status, .serving) + } + } + } + + func testCheckOnUnknownService() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = "does.not.Exist" + } + + try await healthClient.check(request: ClientRequest.Single(message: message)) { response in + try XCTAssertThrowsError(ofType: RPCError.self, response.message) { error in + XCTAssertEqual(error.code, .notFound) + } + } + } + } + + func testCheckOnServer() async throws { + try await withHealthClient { (healthClient, healthProvider) in + // An unspecified service refers to the server. + healthProvider.updateStatus(.notServing, forService: "") + + let message = Grpc_Health_V1_HealthCheckRequest() + + try await healthClient.check(request: ClientRequest.Single(message: message)) { response in + try XCTAssertEqual(response.message.status, .notServing) + } + } + } + + func testWatchOnKnownService() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let testServiceDescriptor = ServiceDescriptor.testService + + let statusesToBeSent: [ServingStatus] = [.serving, .notServing, .serving] + + // Before watching the service, make the status of the service known to the Health service. + healthProvider.updateStatus(statusesToBeSent[0], forService: testServiceDescriptor) + + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = testServiceDescriptor.fullyQualifiedService + } + + try await healthClient.watch(request: ClientRequest.Single(message: message)) { response in + var responseStreamIterator = response.messages.makeAsyncIterator() + + for i in 0 ..< statusesToBeSent.count { + let next = try await responseStreamIterator.next() + let message = try XCTUnwrap(next) + let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus(statusesToBeSent[i]) + + XCTAssertEqual(message.status, expectedStatus) + + if i < statusesToBeSent.count - 1 { + healthProvider.updateStatus(statusesToBeSent[i + 1], forService: testServiceDescriptor) + } + } + } + } + } + + func testWatchOnUnknownServiceDoesNotTerminateTheRPC() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let testServiceDescriptor = ServiceDescriptor.testService + + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = testServiceDescriptor.fullyQualifiedService + } + + try await healthClient.watch(request: ClientRequest.Single(message: message)) { response in + var responseStreamIterator = response.messages.makeAsyncIterator() + var next = try await responseStreamIterator.next() + var message = try XCTUnwrap(next) + + // As the service was watched before being updated, the first status received should be + // .serviceUnknown. + XCTAssertEqual(message.status, .serviceUnknown) + + healthProvider.updateStatus(.notServing, forService: testServiceDescriptor) + + next = try await responseStreamIterator.next() + message = try XCTUnwrap(next) + + // The RPC was not terminated and a status update was received successfully. + XCTAssertEqual(message.status, .notServing) + } + } + } + + func testMultipleWatchOnTheSameService() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let testServiceDescriptor = ServiceDescriptor.testService + + let statusesToBeSent: [ServingStatus] = [.serving, .notServing, .serving] + + try await withThrowingTaskGroup( + of: [Grpc_Health_V1_HealthCheckResponse.ServingStatus].self + ) { group in + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = testServiceDescriptor.fullyQualifiedService + } + + // The continuation of this stream will be used to signal when the watch response streams + // are up and ready. + let signal = AsyncStream.makeStream(of: Void.self) + let numberOfWatches = 2 + + for _ in 0 ..< numberOfWatches { + group.addTask { + return try await healthClient.watch( + request: ClientRequest.Single(message: message) + ) { response in + signal.continuation.yield() // Make signal + + var statuses = [Grpc_Health_V1_HealthCheckResponse.ServingStatus]() + var responseStreamIterator = response.messages.makeAsyncIterator() + + // Since responseStreamIterator.next() will never be nil (ideally, as the response + // stream is always open), the iteration cannot be based on when + // responseStreamIterator.next() is nil. Else, the iteration infinitely awaits and the + // test never finishes. Hence, it is based on the expected number of statuses to be + // received. + for _ in 0 ..< statusesToBeSent.count + 1 { + // As the service will be watched before being updated, the first status received + // should be .serviceUnknown. Hence, the range of this iteration is increased by 1. + + let next = try await responseStreamIterator.next() + let message = try XCTUnwrap(next) + statuses.append(message.status) + } + + return statuses + } + } + } + + // Wait until all the watch streams are up and ready. + for await _ in signal.stream.prefix(numberOfWatches) {} + + for status in statusesToBeSent { + healthProvider.updateStatus(status, forService: testServiceDescriptor) + } + + for try await receivedStatuses in group { + XCTAssertEqual(receivedStatuses[0], .serviceUnknown) + + for i in 0 ..< statusesToBeSent.count { + let sentStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus(statusesToBeSent[i]) + XCTAssertEqual(sentStatus, receivedStatuses[i + 1]) + } + } + } + } + } + + func testWatchWithUnchangingStatusUpdates() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let testServiceDescriptor = ServiceDescriptor.testService + + let statusesToBeSent: [ServingStatus] = [.notServing, .notServing, .notServing, .serving] + + // The repeated .notServing updates should be received only once. Also, as the service will + // be watched before being updated, the first status received should be .serviceUnknown. + let expectedStatuses: [Grpc_Health_V1_HealthCheckResponse.ServingStatus] = [ + .serviceUnknown, + .notServing, + .serving, + ] + + let message = Grpc_Health_V1_HealthCheckRequest.with { + $0.service = testServiceDescriptor.fullyQualifiedService + } + + try await healthClient.watch( + request: ClientRequest.Single(message: message) + ) { response in + // Send all status updates. + for status in statusesToBeSent { + healthProvider.updateStatus(status, forService: testServiceDescriptor) + } + + var responseStreamIterator = response.messages.makeAsyncIterator() + + for i in 0 ..< expectedStatuses.count { + let next = try await responseStreamIterator.next() + let message = try XCTUnwrap(next) + + XCTAssertEqual(message.status, expectedStatuses[i]) + } + } + } + } + + func testWatchOnServer() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let statusesToBeSent: [ServingStatus] = [.serving, .notServing, .serving] + + // An unspecified service refers to the server. + healthProvider.updateStatus(statusesToBeSent[0], forService: "") + + let message = Grpc_Health_V1_HealthCheckRequest() + + try await healthClient.watch(request: ClientRequest.Single(message: message)) { response in + var responseStreamIterator = response.messages.makeAsyncIterator() + + for i in 0 ..< statusesToBeSent.count { + let next = try await responseStreamIterator.next() + let message = try XCTUnwrap(next) + let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus(statusesToBeSent[i]) + + XCTAssertEqual(message.status, expectedStatus) + + if i < statusesToBeSent.count - 1 { + healthProvider.updateStatus(statusesToBeSent[i + 1], forService: "") + } + } + } + } + } +} + +extension ServiceDescriptor { + fileprivate static let testService = ServiceDescriptor(package: "test", service: "Service") +} diff --git a/Tests/Services/HealthTests/Test Utilities/XCTest+Utilities.swift b/Tests/Services/HealthTests/Test Utilities/XCTest+Utilities.swift new file mode 100644 index 000000000..3848388bc --- /dev/null +++ b/Tests/Services/HealthTests/Test Utilities/XCTest+Utilities.swift @@ -0,0 +1,30 @@ +/* + * 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 XCTest + +func XCTAssertThrowsError( + ofType: E.Type, + _ expression: @autoclosure () throws -> T, + _ errorHandler: (E) -> Void +) { + XCTAssertThrowsError(try expression()) { error in + guard let error = error as? E else { + return XCTFail("Error had unexpected type '\(type(of: error))'") + } + errorHandler(error) + } +}