use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingCode {
pub code: String,
pub expires_in: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingClaim {
pub node_id: String,
pub device_secret: String,
pub owner: String,
pub pairing_code: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum PairingAwait {
Pending {
expires_in: i64,
},
Claimed {
#[serde(flatten)]
claim: PairingClaim,
},
Unknown,
}
#[derive(Debug, Clone)]
pub struct PairingClient {
base: String,
}
impl PairingClient {
pub fn new(base: &str) -> Self {
Self {
base: base.trim().trim_end_matches('/').to_string(),
}
}
pub fn endpoint(&self) -> String {
format!("{}/api/ws", self.base)
}
pub fn request(
&self,
node_id: &str,
name: Option<&str>,
tier: Option<u8>,
) -> Result<PairingCode, String> {
let mut params = serde_json::json!({ "node_id": node_id });
if let Some(name) = name {
params["name"] = serde_json::json!(name);
}
if let Some(tier) = tier {
params["tier"] = serde_json::json!(tier);
}
let answer = self.post("pairing.request", ¶ms)?;
let code = answer["code"]
.as_str()
.ok_or_else(|| "gateway minted no code".to_string())?
.to_string();
let expires_in = answer["expires_in"].as_i64().unwrap_or(300);
Ok(PairingCode { code, expires_in })
}
pub fn await_code(&self, node_id: &str, code: &str) -> Result<PairingAwait, String> {
let answer = self.post(
"pairing.await",
&serde_json::json!({ "node_id": node_id, "code": code }),
)?;
match answer["status"].as_str().unwrap_or("") {
"claimed" => {
let claim = PairingClaim {
node_id: answer["node_id"].as_str().unwrap_or(node_id).to_string(),
device_secret: answer["device_secret"]
.as_str()
.ok_or_else(|| "claim carried no device secret".to_string())?
.to_string(),
owner: answer["owner"].as_str().unwrap_or("").to_string(),
pairing_code: answer["pairing_code"].as_str().unwrap_or(code).to_string(),
};
Ok(PairingAwait::Claimed { claim })
}
"pending" => Ok(PairingAwait::Pending {
expires_in: answer["expires_in"].as_i64().unwrap_or(0),
}),
_ => Ok(PairingAwait::Unknown),
}
}
fn post(&self, method: &str, params: &serde_json::Value) -> Result<serde_json::Value, String> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(45))
.build();
let response = agent
.post(&self.endpoint())
.timeout(Duration::from_secs(45))
.send_json(serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
}))
.map_err(|e| format!("{method}: {e}"))?;
let body = response
.into_string()
.map_err(|e| format!("{method} body: {e}"))?;
let value: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("{method} json: {e}"))?;
if let Some(error) = value.get("error").filter(|e| !e.is_null()) {
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("pairing rejected");
return Err(format!("{method}: {message}"));
}
Ok(value.get("result").cloned().unwrap_or(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Read as _, Write};
use std::net::TcpListener;
fn mock(script: &'static str) -> (String, std::thread::JoinHandle<serde_json::Value>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap(); let mut length = 0usize;
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
if header.trim().is_empty() {
break;
}
if let Some(value) = header
.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(str::trim)
{
length = value.parse().unwrap();
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body).unwrap();
let request: serde_json::Value = serde_json::from_slice(&body).unwrap();
let mut stream = stream;
write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", script.len(), script).unwrap();
stream.flush().unwrap();
request
});
(format!("http://{addr}/server"), handle)
}
#[test]
fn request_sends_the_device_params_and_parses_the_code() {
let (base, seen) =
mock(r#"{"jsonrpc":"2.0","id":1,"result":{"code":"7QK2M4XP","expires_in":300}}"#);
let client = PairingClient::new(&base);
let code = client
.request("node-1", Some("Corridor dryer"), Some(2))
.unwrap();
assert_eq!(code.code, "7QK2M4XP");
assert_eq!(code.expires_in, 300);
let request = seen.join().unwrap();
assert_eq!(request["method"], "pairing.request");
assert_eq!(request["params"]["node_id"], "node-1");
assert_eq!(request["params"]["name"], "Corridor dryer");
assert_eq!(request["params"]["tier"], 2);
assert_eq!(
request["params"].as_object().unwrap().len(),
3,
"no stray params"
);
assert_eq!(
client.endpoint(),
format!("{}/api/ws", base.trim_end_matches('/')),
"the endpoint is the /api/ws JSON-RPC route"
);
}
#[test]
fn request_omits_unset_optionals() {
let (base, seen) = mock(r#"{"result":{"code":"AAAA","expires_in":60}}"#);
PairingClient::new(&base)
.request("node-1", None, None)
.unwrap();
let params = seen.join().unwrap()["params"].clone();
assert_eq!(params.as_object().unwrap().len(), 1, "only node_id is sent");
}
#[test]
fn await_maps_pending_claimed_and_unknown() {
let (base, _) = mock(r#"{"result":{"status":"pending","expires_in":291}}"#);
assert_eq!(
PairingClient::new(&base)
.await_code("node-1", "7QK2M4XP")
.unwrap(),
PairingAwait::Pending { expires_in: 291 }
);
let (base, _) = mock(
r#"{"result":{"status":"claimed","node_id":"node-1","device_secret":"s3cr3t",
"owner":"ops@Example","pairing_code":"7QK2M4XP"}}"#,
);
match PairingClient::new(&base)
.await_code("node-1", "7QK2M4XP")
.unwrap()
{
PairingAwait::Claimed { claim } => {
assert_eq!(claim.device_secret, "s3cr3t");
assert_eq!(claim.owner, "ops@Example");
}
other => panic!("expected a claim, got {other:?}"),
}
let (base, _) = mock(r#"{"result":{"status":"unknown"}}"#);
assert_eq!(
PairingClient::new(&base)
.await_code("node-1", "dead")
.unwrap(),
PairingAwait::Unknown
);
}
#[test]
fn gateway_errors_surface_their_message() {
let (base, _) = mock(r#"{"error":{"code":-32001,"message":"rate limited"}}"#);
let err = PairingClient::new(&base)
.request("node-1", None, None)
.unwrap_err();
assert!(err.contains("rate limited"), "{err}");
}
#[test]
fn await_answers_round_trip_through_serde() {
let claimed: PairingAwait = serde_json::from_str(
r#"{"status":"claimed","node_id":"n1","device_secret":"s",
"owner":"ops","pairing_code":"C0DE"}"#,
)
.unwrap();
assert_eq!(
claimed,
PairingAwait::Claimed {
claim: PairingClaim {
node_id: "n1".into(),
device_secret: "s".into(),
owner: "ops".into(),
pairing_code: "C0DE".into(),
},
}
);
let pending: PairingAwait =
serde_json::from_str(r#"{"status":"pending","expires_in":9}"#).unwrap();
assert_eq!(pending, PairingAwait::Pending { expires_in: 9 });
let unknown: PairingAwait = serde_json::from_str(r#"{"status":"unknown"}"#).unwrap();
assert_eq!(unknown, PairingAwait::Unknown);
}
#[test]
fn a_null_error_still_succeeds() {
let (base, _) = mock(
r#"{"jsonrpc":"2.0","id":1,"result":{"code":"7QK2M4XP","expires_in":300},"error":null}"#,
);
let code = PairingClient::new(&base)
.request("node-1", None, None)
.expect("a null error must not reject a success");
assert_eq!(code.code, "7QK2M4XP");
}
#[test]
fn a_bare_object_answer_is_accepted() {
let (base, _) = mock(r#"{"code":"BARE1","expires_in":60}"#);
let code = PairingClient::new(&base)
.request("node-1", None, None)
.unwrap();
assert_eq!(code.code, "BARE1");
assert_eq!(code.expires_in, 60);
}
#[test]
fn missing_expires_in_defaults_to_five_minutes() {
let (base, _) = mock(r#"{"result":{"code":"NODEF"}}"#);
let code = PairingClient::new(&base)
.request("node-1", None, None)
.unwrap();
assert_eq!(code.expires_in, 300, "the documented mint default");
}
#[test]
fn new_trims_whitespace_and_trailing_slashes() {
let client = PairingClient::new(" https://gateway.example/server/ ");
assert_eq!(client.endpoint(), "https://gateway.example/server/api/ws");
}
#[test]
fn claimed_answers_without_a_pairing_code_echo_keep_the_displayed_one() {
let (base, _) =
mock(r#"{"result":{"status":"claimed","node_id":"n1","device_secret":"s"}}"#);
match PairingClient::new(&base)
.await_code("n1", "SHOWN1")
.unwrap()
{
PairingAwait::Claimed { claim } => {
assert_eq!(claim.pairing_code, "SHOWN1");
assert_eq!(claim.owner, "");
}
other => panic!("expected a claim, got {other:?}"),
}
}
#[test]
fn a_codeless_answer_is_rejected() {
let (base, _) = mock(r#"{"result":{"expires_in":300}}"#);
assert!(
PairingClient::new(&base)
.request("node-1", None, None)
.is_err()
);
}
}