Scaffold Rust client workspace #90
Generated
+1308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "mxgateway-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
members = ["crates/mxgw-cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
prost = "0.13.5"
|
||||
prost-types = "0.13.5"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
thiserror = "2.0.17"
|
||||
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
|
||||
tonic = { version = "0.13.1", features = ["transport"] }
|
||||
tonic-build = "0.13.1"
|
||||
|
||||
[dependencies]
|
||||
prost = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tonic = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = { workspace = true }
|
||||
@@ -0,0 +1,53 @@
|
||||
# Rust Client Workspace
|
||||
|
||||
The Rust client workspace contains the MXAccess Gateway client library, a
|
||||
test CLI, and scaffold tests for generated contract wiring. The library uses
|
||||
the shared protobuf inputs documented in
|
||||
`../../docs/client-proto-generation.md` so the Rust bindings compile against
|
||||
the same public gateway and worker contracts as the server.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
clients/rust/
|
||||
Cargo.toml
|
||||
build.rs
|
||||
src/
|
||||
tests/
|
||||
crates/mxgw-cli/
|
||||
```
|
||||
|
||||
`build.rs` reads the `.proto` files from
|
||||
`../../src/MxGateway.Contracts/Protos` and generates `tonic`/`prost` bindings
|
||||
into Cargo build output. `src/generated.rs` declares the Rust modules that
|
||||
include those generated files. `src/generated` remains reserved for checked-in
|
||||
generator output if the crate later changes to source-tree generation.
|
||||
|
||||
## Build And Test
|
||||
|
||||
Run the Rust workspace checks from `clients/rust`:
|
||||
|
||||
```powershell
|
||||
cargo fmt --all --check
|
||||
cargo test --workspace
|
||||
cargo check --workspace
|
||||
```
|
||||
|
||||
The build script uses `protoc` from `PATH` or the Windows path recorded in
|
||||
`../../docs/toolchain-links.md`.
|
||||
|
||||
## CLI
|
||||
|
||||
The scaffold CLI exposes version information:
|
||||
|
||||
```powershell
|
||||
cargo run -p mxgw-cli -- version --json
|
||||
```
|
||||
|
||||
Additional commands are implemented with the client/session wrapper work.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Proto Generation](../../docs/client-proto-generation.md)
|
||||
- [Rust Client Detailed Design](../../docs/clients-rust-design.md)
|
||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::env;
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
configure_protoc();
|
||||
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||
let repo_root = manifest_dir
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or("clients/rust must live two levels below the repository root")?;
|
||||
let proto_root = repo_root.join("src/MxGateway.Contracts/Protos");
|
||||
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
|
||||
let worker_proto = proto_root.join("mxaccess_worker.proto");
|
||||
let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("mxaccessgw-client-v1.protoset");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", gateway_proto.display());
|
||||
println!("cargo:rerun-if-changed={}", worker_proto.display());
|
||||
|
||||
tonic_build::configure()
|
||||
.build_server(false)
|
||||
.build_client(true)
|
||||
.file_descriptor_set_path(descriptor_path)
|
||||
.compile_protos(
|
||||
&[gateway_proto.as_path(), worker_proto.as_path()],
|
||||
&[proto_root.as_path()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_protoc() {
|
||||
if env::var_os("PROTOC").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
for candidate in protoc_candidates() {
|
||||
if candidate.is_file() {
|
||||
env::set_var("PROTOC", candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protoc_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if cfg!(windows) {
|
||||
if let Some(local_app_data) = env::var_os("LOCALAPPDATA") {
|
||||
candidates.push(PathBuf::from(local_app_data).join(
|
||||
"Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(PathBuf::from("protoc"));
|
||||
candidates
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "mxgw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "mxgw"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
mxgateway-client = { path = "../.." }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use mxgateway_client::{CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "mxgw")]
|
||||
#[command(about = "MXAccess Gateway Rust test CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
Version {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
run(cli);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn run(cli: Cli) {
|
||||
match cli.command {
|
||||
Command::Version { json } => print_version(json),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_version(use_json: bool) {
|
||||
if use_json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"clientVersion": CLIENT_VERSION,
|
||||
"gatewayProtocolVersion": GATEWAY_PROTOCOL_VERSION,
|
||||
"workerProtocolVersion": WORKER_PROTOCOL_VERSION,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("mxgw {CLIENT_VERSION}");
|
||||
println!("gateway protocol {GATEWAY_PROTOCOL_VERSION}");
|
||||
println!("worker protocol {WORKER_PROTOCOL_VERSION}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::Cli;
|
||||
|
||||
#[test]
|
||||
fn parses_version_json_command() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "version", "--json"]);
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::fmt;
|
||||
|
||||
/// API key wrapper that avoids exposing raw credentials in formatted output.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ApiKey(String);
|
||||
|
||||
impl ApiKey {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn expose_secret(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ApiKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_tuple("ApiKey")
|
||||
.field(&"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||
use crate::options::ClientOptions;
|
||||
|
||||
/// Thin owner for the generated gateway client.
|
||||
pub struct GatewayClient {
|
||||
inner: MxAccessGatewayClient<Channel>,
|
||||
}
|
||||
|
||||
impl GatewayClient {
|
||||
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
|
||||
let endpoint = Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
|
||||
Error::InvalidEndpoint {
|
||||
endpoint: options.endpoint().to_owned(),
|
||||
detail: source.to_string(),
|
||||
}
|
||||
})?;
|
||||
let channel = endpoint.connect().await?;
|
||||
|
||||
Ok(Self {
|
||||
inner: MxAccessGatewayClient::new(channel),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> MxAccessGatewayClient<Channel> {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
#[error("invalid gateway endpoint `{endpoint}`: {detail}")]
|
||||
InvalidEndpoint { endpoint: String, detail: String },
|
||||
|
||||
#[error("gateway transport error: {0}")]
|
||||
Transport(#[from] tonic::transport::Error),
|
||||
|
||||
#[error("gateway status error: {0}")]
|
||||
Status(#[from] tonic::Status),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod mxaccess_gateway {
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
|
||||
tonic::include_proto!("mxaccess_gateway.v1");
|
||||
}
|
||||
}
|
||||
|
||||
pub mod mxaccess_worker {
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
|
||||
tonic::include_proto!("mxaccess_worker.v1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Rust client scaffold for MXAccess Gateway.
|
||||
//!
|
||||
//! The crate compiles generated `tonic` bindings from the shared gateway
|
||||
//! protobuf contracts and exposes a small handwritten surface for future client
|
||||
//! implementation work.
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod generated;
|
||||
pub mod options;
|
||||
pub mod session;
|
||||
pub mod value;
|
||||
pub mod version;
|
||||
|
||||
pub use auth::ApiKey;
|
||||
pub use client::GatewayClient;
|
||||
pub use error::Error;
|
||||
pub use options::ClientOptions;
|
||||
pub use session::Session;
|
||||
pub use version::{CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::auth::ApiKey;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClientOptions {
|
||||
endpoint: String,
|
||||
api_key: Option<ApiKey>,
|
||||
plaintext: bool,
|
||||
}
|
||||
|
||||
impl ClientOptions {
|
||||
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
api_key: None,
|
||||
plaintext: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_api_key(mut self, api_key: ApiKey) -> Self {
|
||||
self.api_key = Some(api_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn api_key(&self) -> Option<&ApiKey> {
|
||||
self.api_key.as_ref()
|
||||
}
|
||||
|
||||
pub fn plaintext(&self) -> bool {
|
||||
self.plaintext
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
fn default() -> Self {
|
||||
Self::new("http://127.0.0.1:5000")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientOptions {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientOptions")
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("plaintext", &self.plaintext)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Session identifier returned by the gateway.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Session {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self { id: id.into() }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::generated::mxaccess_gateway::v1::MxValue;
|
||||
|
||||
pub fn int32_value(value: i32) -> MxValue {
|
||||
MxValue {
|
||||
data_type: crate::generated::mxaccess_gateway::v1::MxDataType::Integer as i32,
|
||||
kind: Some(crate::generated::mxaccess_gateway::v1::mx_value::Kind::Int32Value(value)),
|
||||
..MxValue::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const WORKER_PROTOCOL_VERSION: u32 = 1;
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
mx_command, mx_value, MxCommand, MxCommandKind, MxCommandRequest, MxDataType, MxEvent,
|
||||
MxEventFamily, MxValue, OpenSessionReply, ProtocolStatusCode, RegisterCommand,
|
||||
};
|
||||
use mxgateway_client::{GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
fn generated_golden_fixtures_are_available() {
|
||||
for fixture_name in [
|
||||
"open-session-reply.ok.json",
|
||||
"register-command-request.json",
|
||||
"on-data-change-event.json",
|
||||
] {
|
||||
let fixture = read_fixture(fixture_name);
|
||||
assert!(
|
||||
fixture.is_object(),
|
||||
"{fixture_name} must remain a protobuf JSON object"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_session_fixture_matches_protocol_versions() {
|
||||
let fixture = read_fixture("open-session-reply.ok.json");
|
||||
let reply = OpenSessionReply {
|
||||
session_id: string_field(&fixture, "sessionId"),
|
||||
backend_name: string_field(&fixture, "backendName"),
|
||||
worker_process_id: i32_field(&fixture, "workerProcessId"),
|
||||
worker_protocol_version: u32_field(&fixture, "workerProtocolVersion"),
|
||||
gateway_protocol_version: u32_field(&fixture, "gatewayProtocolVersion"),
|
||||
protocol_status: Some(
|
||||
mxgateway_client::generated::mxaccess_gateway::v1::ProtocolStatus {
|
||||
code: ProtocolStatusCode::Ok as i32,
|
||||
message: string_field(&fixture["protocolStatus"], "message"),
|
||||
},
|
||||
),
|
||||
..OpenSessionReply::default()
|
||||
};
|
||||
|
||||
assert_eq!(reply.gateway_protocol_version, GATEWAY_PROTOCOL_VERSION);
|
||||
assert_eq!(reply.worker_protocol_version, WORKER_PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fixture_can_build_generated_request() {
|
||||
let fixture = read_fixture("register-command-request.json");
|
||||
let command = &fixture["command"];
|
||||
let request = MxCommandRequest {
|
||||
session_id: string_field(&fixture, "sessionId"),
|
||||
client_correlation_id: string_field(&fixture, "clientCorrelationId"),
|
||||
command: Some(MxCommand {
|
||||
kind: MxCommandKind::Register as i32,
|
||||
payload: Some(mx_command::Payload::Register(RegisterCommand {
|
||||
client_name: string_field(&command["register"], "clientName"),
|
||||
})),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(request.session_id, "session-fixture");
|
||||
assert_eq!(
|
||||
request.command.unwrap().kind,
|
||||
MxCommandKind::Register as i32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_data_change_fixture_can_build_generated_event() {
|
||||
let fixture = read_fixture("on-data-change-event.json");
|
||||
let event = MxEvent {
|
||||
family: MxEventFamily::OnDataChange as i32,
|
||||
session_id: string_field(&fixture, "sessionId"),
|
||||
server_handle: i32_field(&fixture, "serverHandle"),
|
||||
item_handle: i32_field(&fixture, "itemHandle"),
|
||||
value: Some(MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
variant_type: string_field(&fixture["value"], "variantType"),
|
||||
kind: Some(mx_value::Kind::Int32Value(i32_field(
|
||||
&fixture["value"],
|
||||
"int32Value",
|
||||
))),
|
||||
..MxValue::default()
|
||||
}),
|
||||
quality: i32_field(&fixture, "quality"),
|
||||
worker_sequence: u64_field(&fixture, "workerSequence"),
|
||||
..MxEvent::default()
|
||||
};
|
||||
|
||||
assert_eq!(event.family, MxEventFamily::OnDataChange as i32);
|
||||
assert_eq!(event.value.unwrap().data_type, MxDataType::Integer as i32);
|
||||
}
|
||||
|
||||
fn read_fixture(name: &str) -> Value {
|
||||
let path = fixture_root().join(name);
|
||||
let data = fs::read_to_string(&path).unwrap_or_else(|error| {
|
||||
panic!("failed to read fixture {}: {error}", path.display());
|
||||
});
|
||||
|
||||
serde_json::from_str(&data).unwrap_or_else(|error| {
|
||||
panic!("failed to parse fixture {}: {error}", path.display());
|
||||
})
|
||||
}
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto/fixtures/golden")
|
||||
}
|
||||
|
||||
fn string_field(value: &Value, name: &str) -> String {
|
||||
value[name]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("missing string field {name}"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn i32_field(value: &Value, name: &str) -> i32 {
|
||||
value[name]
|
||||
.as_i64()
|
||||
.unwrap_or_else(|| panic!("missing i32 field {name}"))
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| panic!("field {name} does not fit in i32"))
|
||||
}
|
||||
|
||||
fn u32_field(value: &Value, name: &str) -> u32 {
|
||||
value[name]
|
||||
.as_u64()
|
||||
.unwrap_or_else(|| panic!("missing u32 field {name}"))
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| panic!("field {name} does not fit in u32"))
|
||||
}
|
||||
|
||||
fn u64_field(value: &Value, name: &str) -> u64 {
|
||||
if let Some(number) = value[name].as_u64() {
|
||||
return number;
|
||||
}
|
||||
|
||||
value[name]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("missing u64 field {name}"))
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("field {name} does not parse as u64"))
|
||||
}
|
||||
@@ -111,10 +111,21 @@ The script maps both proto files into the internal Go package
|
||||
the source `.proto` files do not carry Go-specific `go_package` options. This
|
||||
keeps language-specific packaging outside the public contract files.
|
||||
|
||||
Rust clients should use `tonic-build` or the selected protobuf generator from
|
||||
the Rust client build script, with generated modules placed under
|
||||
`clients/rust/src/generated` or included from the build output according to the
|
||||
client crate design.
|
||||
Rust clients use `tonic-build` from `clients/rust/build.rs`. The build script
|
||||
reads the shared `.proto` files and emits generated `tonic`/`prost` modules
|
||||
into Cargo build output. `clients/rust/src/generated.rs` contains the module
|
||||
declarations that include those generated files. `clients/rust/src/generated`
|
||||
remains reserved for checked-in generator output if the crate later changes to
|
||||
source-tree generation, and handwritten wrapper code stays outside that
|
||||
directory.
|
||||
|
||||
Run the Rust workspace checks from `clients/rust`:
|
||||
|
||||
```powershell
|
||||
cargo fmt --all --check
|
||||
cargo test --workspace
|
||||
cargo check --workspace
|
||||
```
|
||||
|
||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
||||
|
||||
Reference in New Issue
Block a user