Add CoAP-over-UDP hello server in Rust
A minimal CoAP (RFC 7252) server built on coap-lite for message encoding and a tokio UdpSocket for transport. The socket, read loop and routing are kept in src/main.rs rather than behind a framework. Routes: GET /hello[?name=X] -> 2.05 "Hello, X!" (defaults to World) POST /hello -> 2.01, name from a raw text or JSON body GET /health -> 2.05 status + uptime Unknown paths return 4.04, known paths with the wrong method 4.05. Includes a CLI client (src/bin/client.rs) for smoke tests and unit tests covering the greeting, payload parsing and query decoding. Verified against node-coap as an independent implementation to confirm wire compatibility. The earlier Node.js prototype is kept under nodejs/ for reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
86
src/bin/client.rs
Normal file
86
src/bin/client.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! Tiny CoAP client for smoke-testing the server.
|
||||
//!
|
||||
//! cargo run --bin client -- get "/hello?name=Imran"
|
||||
//! cargo run --bin client -- post /hello Imran
|
||||
//! cargo run --bin client -- get /health
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let method = args.first().map(String::as_str).unwrap_or("get");
|
||||
let target = args.get(1).map(String::as_str).unwrap_or("/hello");
|
||||
let payload = args.get(2).map(String::as_str).unwrap_or("");
|
||||
|
||||
let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into());
|
||||
let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
|
||||
|
||||
let method = match method.to_ascii_lowercase().as_str() {
|
||||
"get" => RequestType::Get,
|
||||
"post" => RequestType::Post,
|
||||
"put" => RequestType::Put,
|
||||
"delete" => RequestType::Delete,
|
||||
other => return Err(format!("unsupported method: {other}").into()),
|
||||
};
|
||||
|
||||
// A CoAP URI is carried as repeated options, not one string: each path
|
||||
// segment is its own Uri-Path option, each `k=v` its own Uri-Query.
|
||||
let (path, query) = match target.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (target, None),
|
||||
};
|
||||
|
||||
let mut packet = Packet::new();
|
||||
packet.header.set_version(1);
|
||||
packet.header.set_type(MessageType::Confirmable);
|
||||
packet.header.code = MessageClass::Request(method);
|
||||
packet.header.message_id = nonce() as u16;
|
||||
packet.set_token(nonce().to_be_bytes()[..4].to_vec());
|
||||
|
||||
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||
packet.add_option(CoapOption::UriPath, segment.as_bytes().to_vec());
|
||||
}
|
||||
for pair in query.unwrap_or("").split('&').filter(|s| !s.is_empty()) {
|
||||
packet.add_option(CoapOption::UriQuery, pair.as_bytes().to_vec());
|
||||
}
|
||||
if !payload.is_empty() {
|
||||
packet.payload = payload.as_bytes().to_vec();
|
||||
}
|
||||
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.connect(format!("{host}:{port}")).await?;
|
||||
socket.send(&packet.to_bytes()?).await?;
|
||||
|
||||
let mut buf = [0u8; 1152];
|
||||
// CoAP's default response window for a confirmable request is ~2s.
|
||||
let len = match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Err("timed out waiting for a response".into()),
|
||||
};
|
||||
|
||||
let response = Packet::from_bytes(&buf[..len])?;
|
||||
println!(
|
||||
"<- {} {}",
|
||||
status_text(&response),
|
||||
String::from_utf8_lossy(&response.payload)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render the response code in CoAP's `class.detail` form, e.g. `2.05`.
|
||||
fn status_text(packet: &Packet) -> String {
|
||||
let raw: u8 = packet.header.code.into();
|
||||
format!("{}.{:02}", raw >> 5, raw & 0x1f)
|
||||
}
|
||||
|
||||
fn nonce() -> u32 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
Reference in New Issue
Block a user