Add HTTP bridge so the CoAP server can be checked in a browser
Browsers speak HTTP over TCP and cannot open coap:// URLs, which made the server awkward to try without the CLI client. The bridge accepts an ordinary HTTP request, reissues it as CoAP over UDP, and maps the response code back onto an HTTP status (the raw code is also returned in X-Coap-Code). It serves a test console at / with a name field and a button per route, so GET, POST, 4.04 and 4.05 can all be exercised by clicking. The CoAP request logic moves from src/bin/client.rs into src/lib.rs so the client and the bridge share one implementation. The bridge is a development convenience: a small HTTP/1.1 subset with no keep-alive, chunked bodies or TLS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -8,6 +8,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coap-hello-udp"
|
name = "coap-hello-udp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -145,6 +151,7 @@ version = "1.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
coap-lite = "0.13.3"
|
coap-lite = "0.13.3"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time", "io-util"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -32,7 +32,44 @@ Listens on `udp://0.0.0.0:5683` (CoAP's IANA-assigned port).
|
|||||||
(`{"name":"Imran"}`). An empty payload returns `4.00`. An unknown path returns
|
(`{"name":"Imran"}`). An empty payload returns `4.00`. An unknown path returns
|
||||||
`4.04`; a known path with the wrong method returns `4.05`.
|
`4.04`; a known path with the wrong method returns `4.05`.
|
||||||
|
|
||||||
## Try it
|
## Check it in a browser
|
||||||
|
|
||||||
|
Browsers speak HTTP over TCP; this server speaks CoAP over UDP, so `coap://`
|
||||||
|
URLs cannot be opened directly. `src/bin/bridge.rs` is a small HTTP -> CoAP
|
||||||
|
proxy that closes the gap. Run both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release --bin coap-hello-udp
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --release --bin bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8080/> for a test console with a name field and a
|
||||||
|
button per route. Every response shows both the CoAP code and the HTTP status
|
||||||
|
it was mapped to; the raw CoAP code is also returned in an `X-Coap-Code` header.
|
||||||
|
|
||||||
|
These are now ordinary URLs — clickable, curl-able, bookmarkable:
|
||||||
|
|
||||||
|
| URL | Response |
|
||||||
|
|--------------------------------------------------|------------------------------|
|
||||||
|
| `http://localhost:8080/` | the test console |
|
||||||
|
| `http://localhost:8080/hello` | `Hello, World!` |
|
||||||
|
| `http://localhost:8080/hello?name=Imran` | `Hello, Imran!` |
|
||||||
|
| `http://localhost:8080/health` | `{"status":"ok","uptime":12}`|
|
||||||
|
|
||||||
|
POST needs a body, so it is easiest from the console page or curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -d "Imran" http://localhost:8080/hello
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge is a development convenience: a deliberately small HTTP/1.1 subset
|
||||||
|
with no keep-alive, no chunked bodies and no TLS. Do not expose it. Set
|
||||||
|
`HTTP_PORT` if 8080 is taken.
|
||||||
|
|
||||||
|
## Try it from the CLI
|
||||||
|
|
||||||
With the bundled client:
|
With the bundled client:
|
||||||
|
|
||||||
@@ -64,15 +101,19 @@ decoding.
|
|||||||
| Path | What it is |
|
| Path | What it is |
|
||||||
|---------------------|---------------------------------------------------------|
|
|---------------------|---------------------------------------------------------|
|
||||||
| `src/main.rs` | The server: socket, read loop, route table, helpers. |
|
| `src/main.rs` | The server: socket, read loop, route table, helpers. |
|
||||||
|
| `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. |
|
||||||
| `src/bin/client.rs` | CLI client for smoke tests. |
|
| `src/bin/client.rs` | CLI client for smoke tests. |
|
||||||
|
| `src/bin/bridge.rs` | HTTP -> CoAP proxy so a browser can reach the server. |
|
||||||
|
| `static/index.html` | The browser test console the bridge serves at `/`. |
|
||||||
| `nodejs/` | The earlier Node.js version, kept for reference. |
|
| `nodejs/` | The earlier Node.js version, kept for reference. |
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
| Env var | Default |
|
| Env var | Default | Used by |
|
||||||
|-------------|-----------|
|
|-------------|-----------|----------------|
|
||||||
| `COAP_PORT` | `5683` |
|
| `COAP_PORT` | `5683` | server, client, bridge |
|
||||||
| `COAP_HOST` | `0.0.0.0` |
|
| `COAP_HOST` | `0.0.0.0` | server, client, bridge |
|
||||||
|
| `HTTP_PORT` | `8080` | bridge |
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
|
|||||||
181
src/bin/bridge.rs
Normal file
181
src/bin/bridge.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
//! HTTP -> CoAP bridge, so a browser can exercise the CoAP server.
|
||||||
|
//!
|
||||||
|
//! Browsers speak HTTP over TCP; the server speaks CoAP over UDP. This sits in
|
||||||
|
//! the middle: it accepts an ordinary HTTP request, reissues it as CoAP, and
|
||||||
|
//! returns the reply with the CoAP response code mapped onto an HTTP status
|
||||||
|
//! (and echoed verbatim in the `X-Coap-Code` header).
|
||||||
|
//!
|
||||||
|
//! Development convenience only — it is a deliberately small HTTP/1.1 subset
|
||||||
|
//! (no keep-alive, no chunked bodies, no TLS). Do not expose it.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use coap_hello_udp::{parse_method, request};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
const MAX_REQUEST: usize = 64 * 1024;
|
||||||
|
const PAGE: &str = include_str!("../../static/index.html");
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
let http_port = std::env::var("HTTP_PORT").unwrap_or_else(|_| "8080".into());
|
||||||
|
let coap_host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into());
|
||||||
|
let coap_port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(format!("0.0.0.0:{http_port}")).await?;
|
||||||
|
println!("HTTP -> CoAP bridge on http://localhost:{http_port}");
|
||||||
|
println!(" forwarding to coap://{coap_host}:{coap_port}");
|
||||||
|
println!(" open http://localhost:{http_port}/ in a browser");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = match listener.accept().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("accept error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each connection blocks on a CoAP round trip, so give it its own task.
|
||||||
|
let (host, port) = (coap_host.clone(), coap_port.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = serve(stream, peer, &host, &port).await {
|
||||||
|
eprintln!("connection error from {peer}: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
coap_host: &str,
|
||||||
|
coap_port: &str,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let Some((method, target, body)) = read_request(&mut stream).await? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{method} {target} from {peer}");
|
||||||
|
|
||||||
|
let (path, query) = match target.split_once('?') {
|
||||||
|
Some((p, q)) => (p, Some(q)),
|
||||||
|
None => (target.as_str(), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match (method.as_str(), path) {
|
||||||
|
("GET", "/") => http_response(200, "text/html; charset=utf-8", PAGE, None),
|
||||||
|
(_, "/favicon.ico") => http_response(204, "text/plain", "", None),
|
||||||
|
|
||||||
|
// Anything else is proxied straight through to the CoAP server.
|
||||||
|
_ => match parse_method(&method) {
|
||||||
|
Err(e) => http_response(405, "text/plain; charset=utf-8", &e, None),
|
||||||
|
Ok(coap_method) => {
|
||||||
|
match request(coap_host, coap_port, coap_method, path, query, &body).await {
|
||||||
|
Ok(reply) => {
|
||||||
|
let status = reply.http_status();
|
||||||
|
let content_type = if reply.payload.starts_with('{') {
|
||||||
|
"application/json"
|
||||||
|
} else {
|
||||||
|
"text/plain; charset=utf-8"
|
||||||
|
};
|
||||||
|
http_response(status, content_type, &reply.payload, Some(&reply.code))
|
||||||
|
}
|
||||||
|
Err(e) => http_response(
|
||||||
|
502,
|
||||||
|
"text/plain; charset=utf-8",
|
||||||
|
&format!("Bad Gateway: {e}\nIs the CoAP server running on {coap_host}:{coap_port}?"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.write_all(&response).await?;
|
||||||
|
stream.flush().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one HTTP/1.1 request: the request line, the headers, and a
|
||||||
|
/// `Content-Length` body if present. Returns `(method, target, body)`.
|
||||||
|
async fn read_request(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
) -> std::io::Result<Option<(String, String, Vec<u8>)>> {
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
|
||||||
|
// Read until the blank line that ends the headers.
|
||||||
|
let header_end = loop {
|
||||||
|
if let Some(pos) = find(&buf, b"\r\n\r\n") {
|
||||||
|
break pos + 4;
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_REQUEST {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match stream.read(&mut chunk).await? {
|
||||||
|
0 => return Ok(None), // client hung up
|
||||||
|
n => buf.extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let head = String::from_utf8_lossy(&buf[..header_end]).into_owned();
|
||||||
|
let mut lines = head.lines();
|
||||||
|
|
||||||
|
let mut request_line = lines.next().unwrap_or("").split_whitespace();
|
||||||
|
let (Some(method), Some(target)) = (request_line.next(), request_line.next()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_length: usize = lines
|
||||||
|
.find(|l| l.to_ascii_lowercase().starts_with("content-length:"))
|
||||||
|
.and_then(|l| l.split(':').nth(1))
|
||||||
|
.and_then(|v| v.trim().parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut body = buf[header_end..].to_vec();
|
||||||
|
while body.len() < content_length.min(MAX_REQUEST) {
|
||||||
|
match stream.read(&mut chunk).await? {
|
||||||
|
0 => break,
|
||||||
|
n => body.extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.truncate(content_length);
|
||||||
|
|
||||||
|
Ok(Some((method.to_uppercase(), target.to_string(), body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_response(status: u16, content_type: &str, body: &str, coap_code: Option<&str>) -> Vec<u8> {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
201 => "Created",
|
||||||
|
204 => "No Content",
|
||||||
|
400 => "Bad Request",
|
||||||
|
404 => "Not Found",
|
||||||
|
405 => "Method Not Allowed",
|
||||||
|
502 => "Bad Gateway",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
let coap_header = coap_code
|
||||||
|
.map(|c| format!("X-Coap-Code: {c}\r\n"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut response = format!(
|
||||||
|
"HTTP/1.1 {status} {reason}\r\n\
|
||||||
|
Content-Type: {content_type}\r\n\
|
||||||
|
Content-Length: {}\r\n\
|
||||||
|
{coap_header}\
|
||||||
|
Connection: close\r\n\
|
||||||
|
\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
|
||||||
|
response.extend_from_slice(body.as_bytes());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
@@ -4,83 +4,25 @@
|
|||||||
//! cargo run --bin client -- post /hello Imran
|
//! cargo run --bin client -- post /hello Imran
|
||||||
//! cargo run --bin client -- get /health
|
//! cargo run --bin client -- get /health
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use coap_hello_udp::{parse_method, request};
|
||||||
|
|
||||||
use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType};
|
|
||||||
use tokio::net::UdpSocket;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let method = args.first().map(String::as_str).unwrap_or("get");
|
let method = parse_method(args.first().map(String::as_str).unwrap_or("get"))?;
|
||||||
let target = args.get(1).map(String::as_str).unwrap_or("/hello");
|
let target = args.get(1).map(String::as_str).unwrap_or("/hello");
|
||||||
let payload = args.get(2).map(String::as_str).unwrap_or("");
|
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 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 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('?') {
|
let (path, query) = match target.split_once('?') {
|
||||||
Some((p, q)) => (p, Some(q)),
|
Some((p, q)) => (p, Some(q)),
|
||||||
None => (target, None),
|
None => (target, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut packet = Packet::new();
|
let reply = request(&host, &port, method, path, query, payload.as_bytes()).await?;
|
||||||
packet.header.set_version(1);
|
println!("<- {} {}", reply.code, reply.payload);
|
||||||
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(())
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
104
src/lib.rs
Normal file
104
src/lib.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
//! Shared CoAP client used by the CLI (`src/bin/client.rs`) and the HTTP
|
||||||
|
//! bridge (`src/bin/bridge.rs`).
|
||||||
|
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
/// What a CoAP server sent back.
|
||||||
|
pub struct CoapReply {
|
||||||
|
/// Response code in CoAP's `class.detail` form, e.g. `"2.05"`.
|
||||||
|
pub code: String,
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoapReply {
|
||||||
|
/// Map a CoAP code onto the nearest HTTP status, for the bridge.
|
||||||
|
/// CoAP's `4.04` and HTTP's `404` are the same idea in different notation.
|
||||||
|
pub fn http_status(&self) -> u16 {
|
||||||
|
let (class, detail) = match self.code.split_once('.') {
|
||||||
|
Some((c, d)) => (
|
||||||
|
c.parse::<u16>().unwrap_or(5),
|
||||||
|
d.parse::<u16>().unwrap_or(0),
|
||||||
|
),
|
||||||
|
None => (5, 0),
|
||||||
|
};
|
||||||
|
match class {
|
||||||
|
2 => match detail {
|
||||||
|
1 => 201, // Created
|
||||||
|
4 => 204, // Changed -> No Content is the closest fit
|
||||||
|
_ => 200, // Content / Valid / Deleted
|
||||||
|
},
|
||||||
|
4 => 400 + detail,
|
||||||
|
_ => 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_method(name: &str) -> Result<RequestType, String> {
|
||||||
|
match name.to_ascii_lowercase().as_str() {
|
||||||
|
"get" => Ok(RequestType::Get),
|
||||||
|
"post" => Ok(RequestType::Post),
|
||||||
|
"put" => Ok(RequestType::Put),
|
||||||
|
"delete" => Ok(RequestType::Delete),
|
||||||
|
other => Err(format!("unsupported method: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one confirmable CoAP request and wait for the reply.
|
||||||
|
///
|
||||||
|
/// A CoAP URI is carried as repeated options rather than one string: each path
|
||||||
|
/// segment becomes its own Uri-Path option, each `k=v` its own Uri-Query.
|
||||||
|
pub async fn request(
|
||||||
|
host: &str,
|
||||||
|
port: &str,
|
||||||
|
method: RequestType,
|
||||||
|
path: &str,
|
||||||
|
query: Option<&str>,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Result<CoapReply, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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.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 CoAP response".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = Packet::from_bytes(&buf[..len])?;
|
||||||
|
let raw: u8 = response.header.code.into();
|
||||||
|
|
||||||
|
Ok(CoapReply {
|
||||||
|
code: format!("{}.{:02}", raw >> 5, raw & 0x1f),
|
||||||
|
payload: String::from_utf8_lossy(&response.payload).into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonce() -> u32 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.subsec_nanos())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
134
static/index.html
Normal file
134
static/index.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CoAP Hello — Test Console</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #fbfbfa; --fg: #1a1a19; --muted: #6b6b68;
|
||||||
|
--card: #ffffff; --border: #e4e4e1; --accent: #b05730;
|
||||||
|
--ok: #2f7d4f; --err: #b3402f; --code-bg: #f4f4f2;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a19; --fg: #edecea; --muted: #9a9a96;
|
||||||
|
--card: #232322; --border: #353533; --accent: #e08a5f;
|
||||||
|
--ok: #6bbd8a; --err: #e8806c; --code-bg: #2b2b29;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 2rem 1.25rem; background: var(--bg); color: var(--fg);
|
||||||
|
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
main { max-width: 640px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 .35rem; letter-spacing: -.01em; }
|
||||||
|
.sub { color: var(--muted); margin: 0 0 1.75rem; font-size: .9rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 1.25rem; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
label { display: block; font-weight: 600; font-size: .82rem; margin-bottom: .4rem; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: .6rem .7rem; font: inherit; border-radius: 7px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.row { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .85rem; }
|
||||||
|
button {
|
||||||
|
font: inherit; font-weight: 600; padding: .55rem 1rem; border-radius: 7px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg); color: var(--fg); cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
button.primary:hover { opacity: .88; color: #fff; }
|
||||||
|
pre {
|
||||||
|
background: var(--code-bg); border: 1px solid var(--border); border-radius: 7px;
|
||||||
|
padding: .8rem; overflow-x: auto; margin: 0;
|
||||||
|
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.status { font-weight: 600; }
|
||||||
|
.status.ok { color: var(--ok); }
|
||||||
|
.status.err { color: var(--err); }
|
||||||
|
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .06em;
|
||||||
|
color: var(--muted); margin: 0 0 .7rem; }
|
||||||
|
.hint { color: var(--muted); font-size: .82rem; margin: .8rem 0 0; }
|
||||||
|
code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px;
|
||||||
|
font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>CoAP Hello — Test Console</h1>
|
||||||
|
<p class="sub">This page talks HTTP to a local bridge, which reissues each
|
||||||
|
call as CoAP over UDP and shows you the raw response code.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" value="Imran" autocomplete="off" placeholder="leave blank for "World"">
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary" data-act="get">GET /hello</button>
|
||||||
|
<button data-act="post">POST /hello</button>
|
||||||
|
<button data-act="json">POST (JSON body)</button>
|
||||||
|
<button data-act="health">GET /health</button>
|
||||||
|
<button data-act="notfound">GET /nope</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Or hit an endpoint directly:
|
||||||
|
<a href="/hello?name=Imran">/hello?name=Imran</a> ·
|
||||||
|
<a href="/health">/health</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Response</h2>
|
||||||
|
<p id="status" class="status">—</p>
|
||||||
|
<pre id="out">Pick an action above.</pre>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
async function call(method, path, body) {
|
||||||
|
$('status').textContent = '…';
|
||||||
|
$('status').className = 'status';
|
||||||
|
$('out').textContent = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers: body ? { 'Content-Type': 'text/plain' } : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const coap = res.headers.get('X-Coap-Code') || '—';
|
||||||
|
$('status').textContent = `CoAP ${coap} · HTTP ${res.status}`;
|
||||||
|
$('status').className = 'status ' + (res.ok ? 'ok' : 'err');
|
||||||
|
$('out').textContent = `${method} ${path}\n${body ? 'body: ' + body + '\n' : ''}\n${text || '(empty)'}`;
|
||||||
|
} catch (err) {
|
||||||
|
$('status').textContent = 'Request failed';
|
||||||
|
$('status').className = 'status err';
|
||||||
|
$('out').textContent = String(err) + '\n\nIs the bridge still running?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('button[data-act]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const name = $('name').value.trim();
|
||||||
|
switch (btn.dataset.act) {
|
||||||
|
case 'get':
|
||||||
|
return call('GET', '/hello' + (name ? '?name=' + encodeURIComponent(name) : ''));
|
||||||
|
case 'post':
|
||||||
|
return call('POST', '/hello', name || '');
|
||||||
|
case 'json':
|
||||||
|
return call('POST', '/hello', JSON.stringify({ name }));
|
||||||
|
case 'health':
|
||||||
|
return call('GET', '/health');
|
||||||
|
case 'notfound':
|
||||||
|
return call('GET', '/nope');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user