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:
308
src/main.rs
Normal file
308
src/main.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
//! A minimal CoAP server over UDP.
|
||||
//!
|
||||
//! CoAP (RFC 7252) is "HTTP semantics on a UDP datagram": same methods, paths
|
||||
//! and query strings, but each request/response is a single datagram carrying a
|
||||
//! compact binary header. `coap-lite` handles that binary codec; the UDP socket
|
||||
//! and the routing are right here so nothing is hidden.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
use coap_lite::{
|
||||
CoapOption, CoapRequest, CoapResponse, ContentFormat, Packet, RequestType,
|
||||
ResponseType,
|
||||
};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// Max UDP payload we accept. RFC 7252 recommends staying under the ~1152 B
|
||||
/// that fits an unfragmented IPv6 datagram; anything larger needs block-wise
|
||||
/// transfer (RFC 7959).
|
||||
const MAX_DATAGRAM: usize = 1152;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "0.0.0.0".into());
|
||||
let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
|
||||
let addr = format!("{host}:{port}");
|
||||
|
||||
let socket = UdpSocket::bind(&addr).await?;
|
||||
let started = Instant::now();
|
||||
|
||||
println!("CoAP server (UDP) listening on coap://{addr}");
|
||||
println!(" GET /hello?name=Imran");
|
||||
println!(" POST /hello (payload: Imran)");
|
||||
println!(" GET /health");
|
||||
|
||||
let mut buf = [0u8; MAX_DATAGRAM];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
println!("\nshutting down");
|
||||
return Ok(());
|
||||
}
|
||||
result = socket.recv_from(&mut buf) => {
|
||||
let (len, peer) = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => { eprintln!("recv error: {e}"); continue; }
|
||||
};
|
||||
|
||||
// Handling is pure CPU work with no awaits, so doing it inline
|
||||
// keeps the hot path allocation-light and ordered. Spawn a task
|
||||
// here instead once a handler needs to do real I/O.
|
||||
if let Some(reply) = handle(&buf[..len], peer, started)
|
||||
&& let Err(e) = socket.send_to(&reply, peer).await
|
||||
{
|
||||
eprintln!("send error to {peer}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one datagram, route it, and encode the reply.
|
||||
/// Returns `None` when no reply should be sent (unparsable or non-request).
|
||||
fn handle(datagram: &[u8], peer: SocketAddr, started: Instant) -> Option<Vec<u8>> {
|
||||
let packet = match Packet::from_bytes(datagram) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("malformed packet from {peer}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut request = CoapRequest::from_packet(packet, peer);
|
||||
|
||||
// `None` means the datagram was an ACK/RST, not something we answer.
|
||||
let mut response = CoapResponse::new(&request.message)?;
|
||||
|
||||
let path = format!("/{}", request.get_path());
|
||||
let method = *request.get_method();
|
||||
println!("{method:?} {}{} from {peer}", path, query_string(&request.message));
|
||||
|
||||
route(method, &path, &mut request, &mut response, started);
|
||||
|
||||
match response.message.to_bytes() {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
eprintln!("encode error: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn route(
|
||||
method: RequestType,
|
||||
path: &str,
|
||||
request: &mut CoapRequest<SocketAddr>,
|
||||
response: &mut CoapResponse,
|
||||
started: Instant,
|
||||
) {
|
||||
match (method, path) {
|
||||
(RequestType::Get, "/hello") => {
|
||||
let name = query_param(&request.message, "name");
|
||||
reply(response, ResponseType::Content, ContentFormat::TextPlain, greet(name.as_deref()));
|
||||
}
|
||||
|
||||
(RequestType::Post, "/hello") => {
|
||||
let name = name_from_payload(&request.message.payload)
|
||||
.or_else(|| query_param(&request.message, "name"));
|
||||
|
||||
match name {
|
||||
Some(name) => reply(
|
||||
response,
|
||||
ResponseType::Created,
|
||||
ContentFormat::TextPlain,
|
||||
greet(Some(&name)),
|
||||
),
|
||||
None => reply(
|
||||
response,
|
||||
ResponseType::BadRequest,
|
||||
ContentFormat::TextPlain,
|
||||
"Bad Request: send a name as the payload".into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
(RequestType::Get, "/health") => {
|
||||
let body = format!(
|
||||
r#"{{"status":"ok","uptime":{}}}"#,
|
||||
started.elapsed().as_secs()
|
||||
);
|
||||
reply(response, ResponseType::Content, ContentFormat::ApplicationJSON, body);
|
||||
}
|
||||
|
||||
// Path exists but the method doesn't: 4.05, per RFC 7252.
|
||||
(_, "/hello") | (_, "/health") => reply(
|
||||
response,
|
||||
ResponseType::MethodNotAllowed,
|
||||
ContentFormat::TextPlain,
|
||||
format!("Method Not Allowed: {method:?} {path}"),
|
||||
),
|
||||
|
||||
_ => reply(
|
||||
response,
|
||||
ResponseType::NotFound,
|
||||
ContentFormat::TextPlain,
|
||||
format!("Not Found: {path}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn greet(name: Option<&str>) -> String {
|
||||
let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("World");
|
||||
format!("Hello, {name}!")
|
||||
}
|
||||
|
||||
fn reply(
|
||||
response: &mut CoapResponse,
|
||||
status: ResponseType,
|
||||
format: ContentFormat,
|
||||
body: String,
|
||||
) {
|
||||
response.set_status(status);
|
||||
response.message.set_content_format(format);
|
||||
response.message.payload = body.into_bytes();
|
||||
}
|
||||
|
||||
/// Re-join the URI-Query options for logging, e.g. `?name=Imran`.
|
||||
fn query_string(packet: &Packet) -> String {
|
||||
match packet.get_option(CoapOption::UriQuery) {
|
||||
Some(options) if !options.is_empty() => {
|
||||
let pairs: Vec<String> = options
|
||||
.iter()
|
||||
.map(|o| String::from_utf8_lossy(o).into_owned())
|
||||
.collect();
|
||||
format!("?{}", pairs.join("&"))
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `?key=value` pair out of the URI-Query options.
|
||||
fn query_param(packet: &Packet, key: &str) -> Option<String> {
|
||||
let options = packet.get_option(CoapOption::UriQuery)?;
|
||||
for option in options.iter() {
|
||||
let pair = String::from_utf8_lossy(option);
|
||||
if let Some(value) = pair.strip_prefix(&format!("{key}=")) {
|
||||
return Some(percent_decode(value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Accept a raw-text payload, or `{"name":"Imran"}`.
|
||||
fn name_from_payload(payload: &[u8]) -> Option<String> {
|
||||
let body = String::from_utf8_lossy(payload).trim().to_string();
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if body.starts_with('{') {
|
||||
return json_string_field(&body, "name");
|
||||
}
|
||||
Some(body)
|
||||
}
|
||||
|
||||
/// Pull one top-level string field out of flat JSON. Enough for an MVP body
|
||||
/// like `{"name":"Imran"}`; swap in `serde_json` when the shape grows.
|
||||
fn json_string_field(body: &str, field: &str) -> Option<String> {
|
||||
let after_key = body.split_once(&format!("\"{field}\""))?.1;
|
||||
let after_colon = after_key.split_once(':')?.1;
|
||||
let start = after_colon.find('"')? + 1;
|
||||
let value = &after_colon[start..];
|
||||
let end = value.find('"')?;
|
||||
Some(value[..end].to_string())
|
||||
}
|
||||
|
||||
/// A CoAP client is supposed to percent-decode before splitting a URI into
|
||||
/// options, but not all of them do — decode leniently so `?name=Md%20Imran`
|
||||
/// works either way. Invalid escapes are left as-is.
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
Some(byte) => {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
}
|
||||
None => {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
byte => {
|
||||
out.push(byte);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn greets_world_when_no_name() {
|
||||
assert_eq!(greet(None), "Hello, World!");
|
||||
assert_eq!(greet(Some("")), "Hello, World!");
|
||||
assert_eq!(greet(Some(" ")), "Hello, World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greets_the_given_name() {
|
||||
assert_eq!(greet(Some("Imran")), "Hello, Imran!");
|
||||
assert_eq!(greet(Some(" Imran ")), "Hello, Imran!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_name_from_text_and_json_payloads() {
|
||||
assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran"));
|
||||
assert_eq!(
|
||||
name_from_payload(br#"{"name":"Imran"}"#).as_deref(),
|
||||
Some("Imran")
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_payload(br#"{ "name" : "Md Imran" }"#).as_deref(),
|
||||
Some("Md Imran")
|
||||
);
|
||||
assert_eq!(name_from_payload(b""), None);
|
||||
assert_eq!(name_from_payload(b" "), None);
|
||||
assert_eq!(name_from_payload(br#"{"other":"x"}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_escapes() {
|
||||
assert_eq!(percent_decode("Md%20Imran"), "Md Imran");
|
||||
assert_eq!(percent_decode("Md+Imran"), "Md Imran");
|
||||
assert_eq!(percent_decode("Imran"), "Imran");
|
||||
// Invalid escapes are passed through rather than dropped.
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
assert_eq!(percent_decode("%zz"), "%zz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_query_param_from_uri_query_options() {
|
||||
let mut packet = Packet::new();
|
||||
packet.add_option(CoapOption::UriQuery, b"name=Md%20Imran".to_vec());
|
||||
packet.add_option(CoapOption::UriQuery, b"lang=en".to_vec());
|
||||
|
||||
assert_eq!(query_param(&packet, "name").as_deref(), Some("Md Imran"));
|
||||
assert_eq!(query_param(&packet, "lang").as_deref(), Some("en"));
|
||||
assert_eq!(query_param(&packet, "missing"), None);
|
||||
assert_eq!(query_string(&packet), "?name=Md%20Imran&lang=en");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user