Files
coapwithudp/src/main.rs

310 lines
10 KiB
Rust

//! 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");
}
}