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:
2026-09-03 10:28:24 +06:00
commit 137b9d9547
10 changed files with 1167 additions and 0 deletions

86
src/bin/client.rs Normal file
View 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)
}