granc/granc-core/tests/granc_client_reflection_test.rs
Víctor Martínez d9001fc87e
[feature] + [refactor] => Allow ALL commands to be executed against a local file descriptor (#28)
This PR makes the `--file-descriptor-set` CLI option to be global for all the commands.
By consequence, the `GrancClient` has been refactored to use a typestate pattern to ensure that there are two separate and decoupled implementations of the behaviour of the client when a file descriptor is loaded and when server reflection is enabled, since both cases have by nature separate error cases and return values invariants.

It also significantly improves the documentation for both the main `README.md` and the `granc-core/README.md`, clarifying usage patterns, command-line options, and the internal architecture of the `GrancClient` API. 

**API and architecture changes:**

* Refactored `granc-core/src/client.rs` to implement the typestate pattern for `GrancClient`, splitting logic into `with_server_reflection` and `with_file_descriptor` modules. Updated documentation comments to explain state transitions and usage.
* Simplified the `DynamicRequest` struct by removing the `file_descriptor_set` field, as schema resolution is now determined by the client's state rather than per-request.

**Documentation improvements:**

* Expanded and reorganized the main `README.md` to clearly explain new and existing features, including local introspection, command-line options, and usage examples for both server reflection and local file descriptor sets. The documentation now covers how to use introspection commands with and without server reflection, and provides concrete example commands and expected output. [[1]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R16-R33) [[2]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L60-R73) [[3]](diffhunk://#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5L84-R159)
* Updated the `granc-core/README.md` to document the typestate pattern of `GrancClient`, provide clear async/sync usage examples for both reflection and file descriptor modes, and clarify schema introspection methods.
2026-01-27 13:08:21 +01:00

214 lines
6.1 KiB
Rust

use echo_service::{EchoServiceServer, FILE_DESCRIPTOR_SET};
use echo_service_impl::EchoServiceImpl;
use granc_core::client::{
Descriptor, DynamicRequest, DynamicResponse, GrancClient, WithServerReflection,
with_file_descriptor, with_server_reflection,
};
use granc_core::reflection::client::ReflectionResolveError;
use tonic::Code;
use tonic::service::Routes;
mod echo_service_impl;
async fn setup_client() -> GrancClient<WithServerReflection<Routes>> {
let reflection_service = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.build_v1()
.unwrap();
let echo_service = EchoServiceServer::new(EchoServiceImpl);
let service = Routes::new(reflection_service).add_service(echo_service);
GrancClient::from_service(service)
}
#[tokio::test]
async fn test_reflection_list_services() {
let mut client = setup_client().await;
let mut services = client.list_services().await.unwrap();
services.sort();
assert_eq!(
services.as_slice(),
["echo.EchoService", "grpc.reflection.v1.ServerReflection"]
);
}
#[tokio::test]
async fn test_reflection_describe_descriptors() {
let mut client = setup_client().await;
let desc = client
.get_descriptor_by_symbol("echo.EchoService")
.await
.unwrap();
assert!(matches!(
desc,
Descriptor::ServiceDescriptor(s)
if s.name() == "EchoService"
&& s.methods().any(|m| m.name() == "UnaryEcho")
));
let desc = client
.get_descriptor_by_symbol("echo.EchoRequest")
.await
.unwrap();
assert!(matches!(
desc,
Descriptor::MessageDescriptor(m)
if m.name() == "EchoRequest"
&& m.fields().any(|f| f.name() == "message")
));
}
#[tokio::test]
async fn test_reflection_describe_error() {
let mut client = setup_client().await;
let result = client.get_descriptor_by_symbol("echo.Ghost").await;
assert!(matches!(
result,
Err(with_server_reflection::GetDescriptorError::NotFound(name)) if name == "echo.Ghost"
));
}
#[tokio::test]
async fn test_reflection_dynamic_calls() {
let mut client = setup_client().await;
// Unary Call
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
body: serde_json::json!({ "message": "hello" }),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "hello"));
// Server Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ServerStreamingEcho".to_string(),
body: serde_json::json!({ "message": "stream" }),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Streaming(Ok(stream)) if stream.len() == 3));
// Client Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
body: serde_json::json!([
{ "message": "A" },
{ "message": "B" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res, DynamicResponse::Unary(Ok(val)) if val["message"] == "AB"));
// Bidirectional Streaming
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "BidirectionalEcho".to_string(),
body: serde_json::json!([
{ "message": "Ping" }
]),
headers: vec![],
};
let res = client.dynamic(req).await.unwrap();
assert!(matches!(res,
DynamicResponse::Streaming(Ok(stream))
if stream.len() == 1
&& stream[0].as_ref().unwrap()["message"] == "echo: Ping"
));
}
#[tokio::test]
async fn test_reflection_dynamic_error_cases() {
let mut client = setup_client().await;
// Invalid Service Name
let req = DynamicRequest {
service: "echo.GhostService".to_string(),
method: "UnaryEcho".to_string(),
body: serde_json::json!({}),
headers: vec![],
};
let result = client.dynamic(req).await;
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::ReflectionResolve(
ReflectionResolveError::ServerStreamFailure(status)
)) if status.code() == Code::NotFound
));
// Invalid Method Name
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "GhostMethod".to_string(),
body: serde_json::json!({}),
headers: vec![],
};
let result = client.dynamic(req).await;
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::DynamicCallError(
with_file_descriptor::DynamicCallError::MethodNotFound(name)
)) if name == "GhostMethod"
));
// Invalid JSON Structure (Streaming requires Array, Object provided)
// This triggers `DynamicCallError::InvalidInput` before the request is sent.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "ClientStreamingEcho".to_string(),
body: serde_json::json!({ "message": "I should be an array" }),
headers: vec![],
};
let result = client.dynamic(req).await;
assert!(matches!(
result,
Err(with_server_reflection::DynamicCallError::DynamicCallError(
with_file_descriptor::DynamicCallError::InvalidInput(_)
))
));
// Schema Mismatch (Unary)
// Passing a field that doesn't exist. This fails at encoding time inside the Codec.
// Tonic wraps encoding errors as Code::Internal.
let req = DynamicRequest {
service: "echo.EchoService".to_string(),
method: "UnaryEcho".to_string(),
body: serde_json::json!({ "non_existent_field": "oops" }),
headers: vec![],
};
let result = client.dynamic(req).await;
assert!(matches!(
result,
Ok(DynamicResponse::Unary(Err(status))) if status.code() == Code::Internal
));
}