From a8a528c7b5fbe8ca8edae097dc320bd16d0f8b88 Mon Sep 17 00:00:00 2001 From: Yigit Yazicilar Date: Fri, 27 Jun 2025 13:54:18 +0100 Subject: [PATCH 1/9] Add list endpoint to health service Motivation: With gRFC A90 a `List` endpoint was added to the health service. Modifications: * Update the health.proto file to the new version * Update the generated files using protoc * Add the new `List` endpoint to the HealthService * Add testing for the list endpoint Result: HealthService is now fully compliant with gRFC A90. --- .../Generated/health.grpc.swift | 305 +++++++++++++++++- .../Generated/health.pb.swift | 74 +++++ .../HealthService+Service.swift | 22 ++ .../GRPCHealthServiceTests/HealthTests.swift | 69 ++++ .../Generated/DescriptorSets/health.pb | Bin 3431 -> 4362 bytes .../upstream/grpc/health/v1/health.proto | 23 +- 6 files changed, 477 insertions(+), 16 deletions(-) diff --git a/Sources/GRPCHealthService/Generated/health.grpc.swift b/Sources/GRPCHealthService/Generated/health.grpc.swift index 9eb2972..25d80bd 100644 --- a/Sources/GRPCHealthService/Generated/health.grpc.swift +++ b/Sources/GRPCHealthService/Generated/health.grpc.swift @@ -49,6 +49,18 @@ package enum Grpc_Health_V1_Health { method: "Check" ) } + /// Namespace for "List" metadata. + package enum List { + /// Request type for "List". + package typealias Input = Grpc_Health_V1_HealthListRequest + /// Response type for "List". + package typealias Output = Grpc_Health_V1_HealthListResponse + /// Descriptor for "List". + package static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "grpc.health.v1.Health"), + method: "List" + ) + } /// Namespace for "Watch" metadata. package enum Watch { /// Request type for "Watch". @@ -64,6 +76,7 @@ package enum Grpc_Health_V1_Health { /// Descriptors for all methods in the "grpc.health.v1.Health" service. package static let descriptors: [GRPCCore.MethodDescriptor] = [ Check.descriptor, + List.descriptor, Watch.descriptor ] } @@ -107,8 +120,6 @@ extension Grpc_Health_V1_Health { /// > /// > 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. /// /// - Parameters: /// - request: A streaming request of `Grpc_Health_V1_HealthCheckRequest` messages. @@ -122,6 +133,34 @@ extension Grpc_Health_V1_Health { context: GRPCCore.ServerContext ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A streaming request of `Grpc_Health_V1_HealthListRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Grpc_Health_V1_HealthListResponse` messages. + func list( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "Watch" method. /// /// > Source IDL Documentation: @@ -180,8 +219,6 @@ extension Grpc_Health_V1_Health { /// > /// > 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. /// /// - Parameters: /// - request: A request containing a single `Grpc_Health_V1_HealthCheckRequest` message. @@ -195,6 +232,34 @@ extension Grpc_Health_V1_Health { context: GRPCCore.ServerContext ) async throws -> GRPCCore.ServerResponse + /// Handle the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A request containing a single `Grpc_Health_V1_HealthListRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Grpc_Health_V1_HealthListResponse` message. + func list( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + /// Handle the "Watch" method. /// /// > Source IDL Documentation: @@ -251,8 +316,6 @@ extension Grpc_Health_V1_Health { /// > /// > 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. /// /// - Parameters: /// - request: A `Grpc_Health_V1_HealthCheckRequest` message. @@ -266,6 +329,34 @@ extension Grpc_Health_V1_Health { context: GRPCCore.ServerContext ) async throws -> Grpc_Health_V1_HealthCheckResponse + /// Handle the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A `Grpc_Health_V1_HealthListRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Grpc_Health_V1_HealthListResponse` to respond with. + func list( + request: Grpc_Health_V1_HealthListRequest, + context: GRPCCore.ServerContext + ) async throws -> Grpc_Health_V1_HealthListResponse + /// Handle the "Watch" method. /// /// > Source IDL Documentation: @@ -316,6 +407,17 @@ extension Grpc_Health_V1_Health.StreamingServiceProtocol { ) } ) + router.registerHandler( + forMethod: Grpc_Health_V1_Health.Method.List.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.list( + request: request, + context: context + ) + } + ) router.registerHandler( forMethod: Grpc_Health_V1_Health.Method.Watch.descriptor, deserializer: GRPCProtobuf.ProtobufDeserializer(), @@ -344,6 +446,17 @@ extension Grpc_Health_V1_Health.ServiceProtocol { return GRPCCore.StreamingServerResponse(single: response) } + package func list( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.list( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + package func watch( request: GRPCCore.StreamingServerRequest, context: GRPCCore.ServerContext @@ -372,6 +485,19 @@ extension Grpc_Health_V1_Health.SimpleServiceProtocol { ) } + package func list( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.list( + request: request.message, + context: context + ), + metadata: [:] + ) + } + package func watch( request: GRPCCore.ServerRequest, context: GRPCCore.ServerContext @@ -416,8 +542,6 @@ extension Grpc_Health_V1_Health { /// > /// > 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. /// /// - Parameters: /// - request: A request containing a single `Grpc_Health_V1_HealthCheckRequest` message. @@ -436,6 +560,39 @@ extension Grpc_Health_V1_Health { onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result ) async throws -> Result where Result: Sendable + /// Call the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A request containing a single `Grpc_Health_V1_HealthListRequest` message. + /// - serializer: A serializer for `Grpc_Health_V1_HealthListRequest` messages. + /// - deserializer: A deserializer for `Grpc_Health_V1_HealthListResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func list( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + /// Call the "Watch" method. /// /// > Source IDL Documentation: @@ -507,8 +664,6 @@ extension Grpc_Health_V1_Health { /// > /// > 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. /// /// - Parameters: /// - request: A request containing a single `Grpc_Health_V1_HealthCheckRequest` message. @@ -538,6 +693,50 @@ extension Grpc_Health_V1_Health { ) } + /// Call the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A request containing a single `Grpc_Health_V1_HealthListRequest` message. + /// - serializer: A serializer for `Grpc_Health_V1_HealthListRequest` messages. + /// - deserializer: A deserializer for `Grpc_Health_V1_HealthListResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + package func list( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Grpc_Health_V1_Health.Method.List.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + /// Call the "Watch" method. /// /// > Source IDL Documentation: @@ -600,8 +799,6 @@ extension Grpc_Health_V1_Health.ClientProtocol { /// > /// > 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. /// /// - Parameters: /// - request: A request containing a single `Grpc_Health_V1_HealthCheckRequest` message. @@ -626,6 +823,45 @@ extension Grpc_Health_V1_Health.ClientProtocol { ) } + /// Call the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - request: A request containing a single `Grpc_Health_V1_HealthListRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + package func list( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.list( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + /// Call the "Watch" method. /// /// > Source IDL Documentation: @@ -682,8 +918,6 @@ extension Grpc_Health_V1_Health.ClientProtocol { /// > /// > 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. /// /// - Parameters: /// - message: request message to send. @@ -712,6 +946,49 @@ extension Grpc_Health_V1_Health.ClientProtocol { ) } + /// Call the "List" method. + /// + /// > Source IDL Documentation: + /// > + /// > List provides a non-atomic snapshot of the health of all the available + /// > services. + /// > + /// > The server may respond with a RESOURCE_EXHAUSTED error if too many services + /// > exist. + /// > + /// > Clients should set a deadline when calling List, and can declare the server + /// > unhealthy if they do not receive a timely response. + /// > + /// > Clients should keep in mind that the list of health services exposed by an + /// > application can change over the lifetime of the process. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + package func list( + _ message: Grpc_Health_V1_HealthListRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.list( + request: request, + options: options, + onResponse: handleResponse + ) + } + /// Call the "Watch" method. /// /// > Source IDL Documentation: diff --git a/Sources/GRPCHealthService/Generated/health.pb.swift b/Sources/GRPCHealthService/Generated/health.pb.swift index ea2cde5..6b6028f 100644 --- a/Sources/GRPCHealthService/Generated/health.pb.swift +++ b/Sources/GRPCHealthService/Generated/health.pb.swift @@ -105,6 +105,29 @@ package struct Grpc_Health_V1_HealthCheckResponse: Sendable { package init() {} } +package struct Grpc_Health_V1_HealthListRequest: 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. + + package var unknownFields = SwiftProtobuf.UnknownStorage() + + package init() {} +} + +package struct Grpc_Health_V1_HealthListResponse: 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. + + /// statuses contains all the services and their respective status. + package var statuses: Dictionary = [:] + + package var unknownFields = SwiftProtobuf.UnknownStorage() + + package init() {} +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "grpc.health.v1" @@ -181,3 +204,54 @@ extension Grpc_Health_V1_HealthCheckResponse.ServingStatus: SwiftProtobuf._Proto 3: .same(proto: "SERVICE_UNKNOWN"), ] } + +extension Grpc_Health_V1_HealthListRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + package static let protoMessageName: String = _protobuf_package + ".HealthListRequest" + package static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + package mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + package func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + package static func ==(lhs: Grpc_Health_V1_HealthListRequest, rhs: Grpc_Health_V1_HealthListRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Grpc_Health_V1_HealthListResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + package static let protoMessageName: String = _protobuf_package + ".HealthListResponse" + package static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "statuses"), + ] + + 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 + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMessageMap.self, value: &self.statuses) }() + default: break + } + } + } + + package func traverse(visitor: inout V) throws { + if !self.statuses.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMessageMap.self, value: self.statuses, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + package static func ==(lhs: Grpc_Health_V1_HealthListResponse, rhs: Grpc_Health_V1_HealthListResponse) -> Bool { + if lhs.statuses != rhs.statuses {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index be158b8..5901b77 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -42,6 +42,24 @@ extension HealthService.Service { return ServerResponse(message: response) } + func list( + request: ServerRequest, + context: ServerContext + ) async throws -> ServerResponse { + let serviceStatuses = self.state.listStatuses() + + var listResponse = Grpc_Health_V1_HealthListResponse() + + for (service, status) in serviceStatuses { + var checkResponse = Grpc_Health_V1_HealthCheckResponse() + checkResponse.status = status + + listResponse.statuses[service] = checkResponse + } + + return ServerResponse(message: listResponse) + } + func watch( request: ServerRequest, context: ServerContext @@ -91,6 +109,10 @@ extension HealthService.Service { storage[service, default: ServiceState(status: status)].updateStatus(status) } } + + fileprivate func listStatuses() -> [String: Grpc_Health_V1_HealthCheckResponse.ServingStatus] { + self.lockedStorage.withLock { $0.mapValues(\.currentStatus) } + } fileprivate func addContinuation( _ continuation: AsyncStream.Continuation, diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index 640b282..222a2dc 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -285,6 +285,75 @@ final class HealthTests: XCTestCase { } } } + + func testListServices() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let message = Grpc_Health_V1_HealthListRequest() + + // Empty case + try await healthClient.list(request: ClientRequest(message: message)) { response in + let statuses = try response.message.statuses + XCTAssertEqual(statuses, [:]) + } + + // Service descriptors and their randomly generated status. + let testServiceDescriptors: [(ServiceDescriptor, ServingStatus)] = Array(0 ..< 10).map { i in + ( + ServiceDescriptor(package: "test", service: "Service\(i)"), + Int.random(in: 0 ... 1) == 0 ? .notServing : .serving + ) + } + + for i in 0 ..< 10 { + healthProvider.updateStatus( + testServiceDescriptors[i].1, + forService: testServiceDescriptors[i].0 + ) + + try await healthClient.list(message) { response in + let statuses = try response.message.statuses + XCTAssertTrue(statuses.count == i + 1) + + for j in 0 ... i { + let receivedStatus = statuses[testServiceDescriptors[j].0.fullyQualifiedService]?.status + XCTAssertNotNil(receivedStatus) + + let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus( + testServiceDescriptors[j].1 + ) + + XCTAssertEqual(receivedStatus!, expectedStatus) + } + } + } + } + } + + func testListOnServer() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let message = Grpc_Health_V1_HealthListRequest() + + healthProvider.updateStatus(.notServing, forService: "") + + try await healthClient.list(message) { response in + let statuses = try response.message.statuses + let receivedServerStatus = statuses[""]?.status + XCTAssertNotNil(receivedServerStatus) + + XCTAssertEqual(receivedServerStatus!, .notServing) + } + + healthProvider.updateStatus(.serving, forService: "") + + try await healthClient.list(message) { response in + let statuses = try response.message.statuses + let receivedServerStatus = statuses[""]?.status + XCTAssertNotNil(receivedServerStatus) + + XCTAssertEqual(receivedServerStatus!, .serving) + } + } + } } @available(gRPCSwiftExtras 2.0, *) diff --git a/Tests/GRPCReflectionServiceTests/Generated/DescriptorSets/health.pb b/Tests/GRPCReflectionServiceTests/Generated/DescriptorSets/health.pb index ff3b6a7cc8fedb6ae5051d20717fe0f676a369c2..05219e7e34ad254cc67665873334e4b4f4c7459e 100644 GIT binary patch delta 1440 zcmY*YOK;p%6!yKo<9N>GRZj@*B&}{H6OxADJlaJ=M-^2V(V+6sq%9~d0>^X9T-C8% z+Zj5mSt21e$ZG^*$u_@$3W+^G0I@^tx?l_9Tzl+JcmB@#?sp#F@n4N^nicrE2}|jp zZ0S4x&pXXJT(~0~FX`Wvancn}&qSOweV`1F|Gftf3EEe%a!u5^X-cI@> zS^Ok_nv?9KF{9%r(Xd-SXnX-PucWMb(4UAQ3XzR!SAAOhP#HMhnYc(XR@+N|Ag!5h z9(tSJAbbC&i{w%D17ND4wW4azOtjr-sz{bhCHahm>2KuY&7bJ>dr|1-<2=~?na(O` z=h%L*`~LS!U|8&e_V9d<-qJ@V5Yy5d+9p(inyvg3fHKWu%i0wJ06No%r7xQer~zXd z)hzuoeGR6eqCiCw1ergDhsCab-~6!Gj_Nyqf+1YMO{ouCt>uTIdfA#SmSQjQ|O#pYgO zmAC4!J?;i4T1&q*c1LThUXpYqtu4Nut%sM0rF_$r7pZKI}S6v8Rn(^m?FqV20~_(jj&BlD2n zY@F*{-8c`UU?6*{ckT!NTTT+3N|(pJ6UO}@;lbl@h>CnHZlIX}+G z?~V5%B7W)&vpod3m9J#d#}eJn(MR{Y2c6G5pWeBB|L9)lT`r<1h`7W$L4Z&EVJQb( zJi`{1>VYSPpTrz{I`ewSNKmXNoL>6Ev{uw0TiNnm$H%biIgu!x5fP-V>W$H>5>JE( zx%By|%sg|FjPI$H;FRNeOJl{eFo;EupA4}Ka3>5s={kuFe6C8meaHV&@L+&btAp_5 zW0A;HjO6RXCA&989Op-X4yoVe(8x|gA@u98wmkNWpkIgU`|yX6`GvXT^WUqF^nG=( f$EC?sB}$Vi-v9B{?Xd(!iQ}Dv69Wq;!L|PZRvINc delta 666 zcmYk2&rcdb6vuaF9s@iSa6y{xS{;$nm>8^@kThw$m}W^&9@JAW2tyzR7GZm9!o9a< z&;18_=!O0R`WJXNF)?YV|4st`Ip~ItbbqP`zW*n@XJ5i! z{Dd{bdw%Tx=2>q%YSkLL*$=w4v%SxMODJtvu^O|!I22Pv4wenB^@bs$0E^keO*xoB z0E=6;SmUd>gb4!)L#c>JTubL85=4a}MQ~s?Y*_D};uu*QDVHn&W*RB}R?PnpQEjS{ zMO;Q{il*BpJ3JHJw|-llYt?J( z!N?D&q?*IFnh-19>F8FVI%BPCXj+?G8l)~W5z+khrS*tttorsxyp{72$n^Lp=US@c m+4tpB@xh$bi~pK~dhuUI>w4R}&q<7uy!S`vay0e>|KJh-YEdu% diff --git a/dev/protos/upstream/grpc/health/v1/health.proto b/dev/protos/upstream/grpc/health/v1/health.proto index 13b03f5..288ce7c 100644 --- a/dev/protos/upstream/grpc/health/v1/health.proto +++ b/dev/protos/upstream/grpc/health/v1/health.proto @@ -24,6 +24,7 @@ option go_package = "google.golang.org/grpc/health/grpc_health_v1"; option java_multiple_files = true; option java_outer_classname = "HealthProto"; option java_package = "io.grpc.health.v1"; +option objc_class_prefix = "GrpcHealthV1"; message HealthCheckRequest { string service = 1; @@ -39,6 +40,13 @@ message HealthCheckResponse { ServingStatus status = 1; } +message HealthListRequest {} + +message HealthListResponse { + // statuses contains all the services and their respective status. + map statuses = 1; +} + // 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. @@ -50,10 +58,21 @@ service Health { // // 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. rpc Check(HealthCheckRequest) returns (HealthCheckResponse); + // List provides a non-atomic snapshot of the health of all the available + // services. + // + // The server may respond with a RESOURCE_EXHAUSTED error if too many services + // exist. + // + // Clients should set a deadline when calling List, and can declare the server + // unhealthy if they do not receive a timely response. + // + // Clients should keep in mind that the list of health services exposed by an + // application can change over the lifetime of the process. + rpc List(HealthListRequest) returns (HealthListResponse); + // 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 From 06661a29d5c11a08deff2d695b39a620277b00ff Mon Sep 17 00:00:00 2001 From: Yigit Yazicilar Date: Fri, 27 Jun 2025 14:14:10 +0100 Subject: [PATCH 2/9] Fix formatting issues --- .../HealthService+Service.swift | 10 +++++----- Tests/GRPCHealthServiceTests/HealthTests.swift | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index 5901b77..fa7afeb 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -47,16 +47,16 @@ extension HealthService.Service { context: ServerContext ) async throws -> ServerResponse { let serviceStatuses = self.state.listStatuses() - + var listResponse = Grpc_Health_V1_HealthListResponse() - + for (service, status) in serviceStatuses { var checkResponse = Grpc_Health_V1_HealthCheckResponse() checkResponse.status = status - + listResponse.statuses[service] = checkResponse } - + return ServerResponse(message: listResponse) } @@ -109,7 +109,7 @@ extension HealthService.Service { storage[service, default: ServiceState(status: status)].updateStatus(status) } } - + fileprivate func listStatuses() -> [String: Grpc_Health_V1_HealthCheckResponse.ServingStatus] { self.lockedStorage.withLock { $0.mapValues(\.currentStatus) } } diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index 222a2dc..dac5542 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -303,7 +303,7 @@ final class HealthTests: XCTestCase { Int.random(in: 0 ... 1) == 0 ? .notServing : .serving ) } - + for i in 0 ..< 10 { healthProvider.updateStatus( testServiceDescriptors[i].1, @@ -317,39 +317,39 @@ final class HealthTests: XCTestCase { for j in 0 ... i { let receivedStatus = statuses[testServiceDescriptors[j].0.fullyQualifiedService]?.status XCTAssertNotNil(receivedStatus) - + let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus( testServiceDescriptors[j].1 ) - + XCTAssertEqual(receivedStatus!, expectedStatus) } } } } } - + func testListOnServer() async throws { try await withHealthClient { (healthClient, healthProvider) in let message = Grpc_Health_V1_HealthListRequest() healthProvider.updateStatus(.notServing, forService: "") - + try await healthClient.list(message) { response in let statuses = try response.message.statuses let receivedServerStatus = statuses[""]?.status XCTAssertNotNil(receivedServerStatus) - + XCTAssertEqual(receivedServerStatus!, .notServing) } - + healthProvider.updateStatus(.serving, forService: "") - + try await healthClient.list(message) { response in let statuses = try response.message.statuses let receivedServerStatus = statuses[""]?.status XCTAssertNotNil(receivedServerStatus) - + XCTAssertEqual(receivedServerStatus!, .serving) } } From 54a99e550d2401ce0eb0bc219661925c3e51ef2a Mon Sep 17 00:00:00 2001 From: Orhan Yigit Yazicilar Date: Fri, 27 Jun 2025 15:32:17 +0100 Subject: [PATCH 3/9] Update Sources/GRPCHealthService/HealthService+Service.swift Co-authored-by: George Barnett --- Sources/GRPCHealthService/HealthService+Service.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index fa7afeb..65e331a 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -51,10 +51,9 @@ extension HealthService.Service { var listResponse = Grpc_Health_V1_HealthListResponse() for (service, status) in serviceStatuses { - var checkResponse = Grpc_Health_V1_HealthCheckResponse() - checkResponse.status = status - - listResponse.statuses[service] = checkResponse + listResponse.statuses[service] = .with { response in + response.status = status + } } return ServerResponse(message: listResponse) From 94ea52ad9f7801d885ac067b385b7e0732a5b264 Mon Sep 17 00:00:00 2001 From: Orhan Yigit Yazicilar Date: Fri, 27 Jun 2025 15:32:29 +0100 Subject: [PATCH 4/9] Update Sources/GRPCHealthService/HealthService+Service.swift Co-authored-by: George Barnett --- Sources/GRPCHealthService/HealthService+Service.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index 65e331a..160231b 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -110,7 +110,7 @@ extension HealthService.Service { } fileprivate func listStatuses() -> [String: Grpc_Health_V1_HealthCheckResponse.ServingStatus] { - self.lockedStorage.withLock { $0.mapValues(\.currentStatus) } + self.lockedStorage.withLock { $0.mapValues { $0.currentStatus } } } fileprivate func addContinuation( From c3caf9988641182a16ce702f14cd005ad4b5605f Mon Sep 17 00:00:00 2001 From: Orhan Yigit Yazicilar Date: Fri, 27 Jun 2025 15:33:08 +0100 Subject: [PATCH 5/9] Update Tests/GRPCHealthServiceTests/HealthTests.swift Co-authored-by: George Barnett --- Tests/GRPCHealthServiceTests/HealthTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index dac5542..71dfe63 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -297,7 +297,7 @@ final class HealthTests: XCTestCase { } // Service descriptors and their randomly generated status. - let testServiceDescriptors: [(ServiceDescriptor, ServingStatus)] = Array(0 ..< 10).map { i in + let testServiceDescriptors: [(ServiceDescriptor, ServingStatus)] = (0 ..< 10).map { i in ( ServiceDescriptor(package: "test", service: "Service\(i)"), Int.random(in: 0 ... 1) == 0 ? .notServing : .serving From 86eac9e08fb134528f2580ccc1621fe6a59c40e9 Mon Sep 17 00:00:00 2001 From: Orhan Yigit Yazicilar Date: Fri, 27 Jun 2025 15:33:16 +0100 Subject: [PATCH 6/9] Update Tests/GRPCHealthServiceTests/HealthTests.swift Co-authored-by: George Barnett --- Tests/GRPCHealthServiceTests/HealthTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index 71dfe63..55c735b 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -300,7 +300,7 @@ final class HealthTests: XCTestCase { let testServiceDescriptors: [(ServiceDescriptor, ServingStatus)] = (0 ..< 10).map { i in ( ServiceDescriptor(package: "test", service: "Service\(i)"), - Int.random(in: 0 ... 1) == 0 ? .notServing : .serving + Bool.random() ? .notServing : .serving ) } From 7ea02ed0da3f724ff916958d3a09f7fe1faedfd1 Mon Sep 17 00:00:00 2001 From: Yigit Yazicilar Date: Fri, 27 Jun 2025 16:04:01 +0100 Subject: [PATCH 7/9] Added the SwiftProtobuf import to use the static with method --- Sources/GRPCHealthService/HealthService+Service.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index 160231b..8ee0510 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -15,6 +15,7 @@ */ internal import GRPCCore +internal import SwiftProtobuf private import Synchronization @available(gRPCSwiftExtras 2.0, *) From 353acff4d462b1f1ec650791ccdb4a744b16b331 Mon Sep 17 00:00:00 2001 From: Yigit Yazicilar Date: Fri, 27 Jun 2025 16:08:16 +0100 Subject: [PATCH 8/9] Refactor the health service tests * Moved out the empty case to its own test * Removed the usages of `!` * Stopped using direct indexing * Changed to the shorthand API --- .../GRPCHealthServiceTests/HealthTests.swift | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index 55c735b..36d60ca 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -286,15 +286,18 @@ final class HealthTests: XCTestCase { } } - func testListServices() async throws { + func testListServicesEmpty() async throws { try await withHealthClient { (healthClient, healthProvider) in let message = Grpc_Health_V1_HealthListRequest() - // Empty case - try await healthClient.list(request: ClientRequest(message: message)) { response in - let statuses = try response.message.statuses - XCTAssertEqual(statuses, [:]) - } + let response = try await healthClient.list(message) + XCTAssertEqual(response.statuses, [:]) + } + } + + func testListServices() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let message = Grpc_Health_V1_HealthListRequest() // Service descriptors and their randomly generated status. let testServiceDescriptors: [(ServiceDescriptor, ServingStatus)] = (0 ..< 10).map { i in @@ -304,26 +307,23 @@ final class HealthTests: XCTestCase { ) } - for i in 0 ..< 10 { + for (index, (descriptor, status)) in testServiceDescriptors.enumerated() { healthProvider.updateStatus( - testServiceDescriptors[i].1, - forService: testServiceDescriptors[i].0 + status, + forService: descriptor ) - try await healthClient.list(message) { response in - let statuses = try response.message.statuses - XCTAssertTrue(statuses.count == i + 1) - - for j in 0 ... i { - let receivedStatus = statuses[testServiceDescriptors[j].0.fullyQualifiedService]?.status - XCTAssertNotNil(receivedStatus) + let response = try await healthClient.list(message) + let statuses = response.statuses + XCTAssertTrue(statuses.count == index + 1) - let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus( - testServiceDescriptors[j].1 - ) + for (descriptor, status) in testServiceDescriptors.prefix(index + 1) { + let receivedStatus = try XCTUnwrap(statuses[descriptor.fullyQualifiedService]?.status) + let expectedStatus = Grpc_Health_V1_HealthCheckResponse.ServingStatus( + status + ) - XCTAssertEqual(receivedStatus!, expectedStatus) - } + XCTAssertEqual(receivedStatus, expectedStatus) } } } @@ -335,23 +335,13 @@ final class HealthTests: XCTestCase { healthProvider.updateStatus(.notServing, forService: "") - try await healthClient.list(message) { response in - let statuses = try response.message.statuses - let receivedServerStatus = statuses[""]?.status - XCTAssertNotNil(receivedServerStatus) - - XCTAssertEqual(receivedServerStatus!, .notServing) - } + var response = try await healthClient.list(message) + XCTAssertEqual(try XCTUnwrap(response.statuses[""]?.status), .notServing) healthProvider.updateStatus(.serving, forService: "") - try await healthClient.list(message) { response in - let statuses = try response.message.statuses - let receivedServerStatus = statuses[""]?.status - XCTAssertNotNil(receivedServerStatus) - - XCTAssertEqual(receivedServerStatus!, .serving) - } + response = try await healthClient.list(message) + XCTAssertEqual(try XCTUnwrap(response.statuses[""]?.status), .serving) } } } From be748ca72e1a0e17c6a6082bb8bb587b4e32cda3 Mon Sep 17 00:00:00 2001 From: Yigit Yazicilar Date: Fri, 27 Jun 2025 16:58:02 +0100 Subject: [PATCH 9/9] Add and test the `List`endpoint limit --- .../HealthService+Service.swift | 10 +++++++ .../GRPCHealthServiceTests/HealthTests.swift | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/GRPCHealthService/HealthService+Service.swift b/Sources/GRPCHealthService/HealthService+Service.swift index 8ee0510..2dd0d60 100644 --- a/Sources/GRPCHealthService/HealthService+Service.swift +++ b/Sources/GRPCHealthService/HealthService+Service.swift @@ -22,6 +22,9 @@ private import Synchronization extension HealthService { internal struct Service: Grpc_Health_V1_Health.ServiceProtocol { private let state = Self.State() + /// Defines the maximum number of resources a `List` request can return. + /// An `RPCError` with the code `ResourceExhaused` is thrown if this limit is exceeded. + private let listMaxAllowedServices = 100 } } @@ -49,6 +52,13 @@ extension HealthService.Service { ) async throws -> ServerResponse { let serviceStatuses = self.state.listStatuses() + guard serviceStatuses.count <= listMaxAllowedServices else { + throw RPCError( + code: .resourceExhausted, + message: "Server health list exceeds maximum capacity: \(listMaxAllowedServices)." + ) + } + var listResponse = Grpc_Health_V1_HealthListResponse() for (service, status) in serviceStatuses { diff --git a/Tests/GRPCHealthServiceTests/HealthTests.swift b/Tests/GRPCHealthServiceTests/HealthTests.swift index 36d60ca..fe2e214 100644 --- a/Tests/GRPCHealthServiceTests/HealthTests.swift +++ b/Tests/GRPCHealthServiceTests/HealthTests.swift @@ -344,6 +344,36 @@ final class HealthTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(response.statuses[""]?.status), .serving) } } + + func testListExceedingMaxAllowedServices() async throws { + try await withHealthClient { (healthClient, healthProvider) in + let message = Grpc_Health_V1_HealthListRequest() + let listMaxAllowedServices = 100 + + for index in 1 ... listMaxAllowedServices { + healthProvider.updateStatus( + .notServing, + forService: ServiceDescriptor(package: "test", service: "Service\(index)") + ) + + let response = try await healthClient.list(message) + XCTAssertTrue(response.statuses.count == index) + } + + healthProvider.updateStatus(.notServing, forService: ServiceDescriptor.testService) + + do { + _ = try await healthClient.list(message) + XCTFail("should error") + } catch { + let resolvedError = try XCTUnwrap( + error as? RPCError, + "health client list throws unexpected error: \(error)" + ) + XCTAssertEqual(resolvedError.code, .resourceExhausted) + } + } + } } @available(gRPCSwiftExtras 2.0, *)