diff --git a/glue/src/dev/toad/mod.rs b/glue/src/dev/toad/mod.rs index 2b42968..bfd0e2f 100644 --- a/glue/src/dev/toad/mod.rs +++ b/glue/src/dev/toad/mod.rs @@ -39,12 +39,13 @@ impl Toad { } fn init_impl(e: &mut java::Env, cfg: Config, channel: PeekableDatagramChannel) -> i64 { - let r = || Runtime::new(&mut java::env(), cfg.log_level(e), cfg.to_toad(e), channel); - unsafe { crate::mem::Shared::init(r).addr() as i64 } + let r = Runtime::new(&mut java::env(), cfg.log_level(e), cfg.to_toad(e), channel); + unsafe { crate::mem::Shared::add_runtime(r).addr() as i64 } } fn poll_req_impl(e: &mut java::Env, addr: i64) -> java::util::Optional { - match unsafe { Shared::deref::(addr).as_ref().unwrap() }.poll_req() { + let r = unsafe { Shared::deref::(addr).as_ref().unwrap() }; + match r.poll_req() { | Ok(req) => { let msg_ptr = unsafe { Shared::alloc_message(req.map(Into::into)) }; let mr = msg::ref_::Message::new(e, msg_ptr.addr() as i64); @@ -85,11 +86,13 @@ impl Toad { addr: i64, msg: msg::owned::Message) -> java::util::Optional { - match unsafe { Shared::deref::(addr).as_ref().unwrap() }.send_msg(Addrd(msg.to_toad(e), msg.addr(e).unwrap().to_no_std(e))) { + let r = unsafe { Shared::deref::(addr).as_ref().unwrap() }; + let sent = r.send_msg(Addrd(msg.to_toad(e), msg.addr(e).unwrap().to_no_std(e))); + match sent { | Ok((id, token)) => { let out = IdAndToken::new(e, id, token); java::util::Optional::of(e, out) - }, + }, | Err(nb::Error::WouldBlock) => java::util::Optional::empty(e), | Err(nb::Error::Other(err)) => { let err = err.downcast_ref(e).to_local(e); @@ -371,3 +374,12 @@ pub extern "system" fn Java_dev_toad_Toad_pollResp<'local>(mut e: java::Env<'loc let sock = java::lang::Object::from_local(e, sock).upcast_to::(e); Toad::poll_resp_impl(e, addr, token, sock).yield_to_java(e) } + +#[no_mangle] +pub extern "system" fn Java_dev_toad_Toad_teardown<'local>(_: java::Env<'local>, + _: JClass<'local>) + -> () { + unsafe { + crate::mem::Shared::dealloc(); + } +} diff --git a/glue/src/mem.rs b/glue/src/mem.rs index 5c7d7d0..cb2ca72 100644 --- a/glue/src/mem.rs +++ b/glue/src/mem.rs @@ -3,16 +3,15 @@ use std::sync::Mutex; use toad::net::Addrd; use toad_msg::alloc::Message; +use crate::runtime; + /// global [`RuntimeAllocator`] implementation pub type Shared = GlobalStatic; /// Trait managing the memory region(s) which java will store pointers to pub trait SharedMemoryRegion: core::default::Default + core::fmt::Debug + Copy { - /// Allocate memory for the runtime and yield a stable pointer to it - /// - /// This is idempotent and will only invoke the provided callback if the runtime - /// has not already been initialized. - unsafe fn init(r: impl FnOnce() -> crate::Runtime) -> *mut crate::Runtime; + /// Allocate memory for a new runtime instance, yielding a stable pointer to it + unsafe fn add_runtime(r: crate::Runtime) -> *mut crate::Runtime; /// Pass ownership of a [`Message`] to the shared memory region, /// yielding a stable pointer to this message. @@ -33,35 +32,39 @@ pub trait SharedMemoryRegion: core::default::Default + core::fmt::Debug + Copy { } } -static mut MEM: Mem = Mem { runtime: None, +static mut MEM: Mem = Mem { runtimes: vec![], messages: vec![], - messages_lock: Mutex::new(()) }; + messages_lock: Mutex::new(()), + runtimes_lock: Mutex::new(()) }; struct Mem { - runtime: Option, + runtimes: Vec, messages: Vec>, - /// Lock used by `alloc_message` and `dealloc_message` to ensure - /// they are run serially. - /// - /// This doesn't provide any guarantees that message pointers will - /// stay valid or always point to the correct location, but it does - /// ensure we don't accidentally yield the wrong pointer from `alloc_message` - /// or delete the wrong message in `dealloc_message`. + // These locks don't provide any guarantees that message pointers will + // stay valid or always point to the correct location, but it does + // ensure we don't accidentally yield the wrong pointer from `alloc_message` + // or delete the wrong message in `dealloc_message`. messages_lock: Mutex<()>, + runtimes_lock: Mutex<()>, } #[derive(Default, Debug, Clone, Copy)] pub struct GlobalStatic; impl SharedMemoryRegion for GlobalStatic { - unsafe fn dealloc() {} + unsafe fn dealloc() { + MEM.runtimes = vec![]; + MEM.messages = vec![]; + } - unsafe fn init(r: impl FnOnce() -> crate::Runtime) -> *mut crate::Runtime { - if MEM.runtime.is_none() { - MEM.runtime = Some(r()); - } - - MEM.runtime.as_mut().unwrap() as _ + unsafe fn add_runtime(r: crate::Runtime) -> *mut crate::Runtime { + let Mem { ref mut runtimes, + ref mut runtimes_lock, + .. } = &mut MEM; + let _lock = runtimes_lock.lock(); + runtimes.push(r); + let len = runtimes.len(); + &mut runtimes[len - 1] as _ } unsafe fn alloc_message(m: Addrd) -> *mut Addrd { diff --git a/src/main/java/dev.toad/Client.java b/src/main/java/dev.toad/Client.java index a15e28c..b830910 100644 --- a/src/main/java/dev.toad/Client.java +++ b/src/main/java/dev.toad/Client.java @@ -91,15 +91,18 @@ public final class Client implements AutoCloseable { return new ClientObserveStream(this, message); } - public CompletableFuture send(Message message) { + public CompletableFuture sendNoResponse(Message message) { if (message.addr().isEmpty()) { throw new IllegalArgumentException( "Message destination address must be set" ); } - return Async - .pollCompletable(() -> this.toad.sendMessage(message)) + return Async.pollCompletable(() -> this.toad.sendMessage(message)); + } + + public CompletableFuture send(Message message) { + return this.sendNoResponse(message) .thenCompose((Toad.IdAndToken sent) -> this.awaitResponse(sent.token, message.addr().get()) ); diff --git a/src/main/java/dev.toad/Debug.java b/src/main/java/dev.toad/Debug.java new file mode 100644 index 0000000..783aed0 --- /dev/null +++ b/src/main/java/dev.toad/Debug.java @@ -0,0 +1,5 @@ +package dev.toad; + +public interface Debug { + public String toDebugString(); +} diff --git a/src/main/java/dev.toad/Server.java b/src/main/java/dev.toad/Server.java new file mode 100644 index 0000000..3916281 --- /dev/null +++ b/src/main/java/dev.toad/Server.java @@ -0,0 +1,437 @@ +package dev.toad; + +import dev.toad.msg.Code; +import dev.toad.msg.Message; +import dev.toad.msg.Payload; +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Level; + +public final class Server { + + final Toad toad; + final ArrayList> middlewares; + final Function notFoundHandler; + final BiFunction exceptionHandler; + + Server( + Toad toad, + ArrayList> ms, + Function notFoundHandler, + BiFunction exHandler + ) { + this.toad = toad; + this.middlewares = ms; + this.notFoundHandler = notFoundHandler; + this.exceptionHandler = exHandler; + } + + public void run() { + while (true) { + try { + dev.toad.msg.ref.Message req = Async + .pollCompletable(() -> this.toad.pollReq()) + .get(); + + Middleware.Result result = Middleware.next(); + for (var f : this.middlewares) { + if (!result.shouldContinue()) { + break; + } + + if (result.isAsync()) { + try { + result.response().get(); + } catch (Throwable e) { + result = Middleware.error(e); + break; + } + // Toad.ack(req); + } + + try { + result = f.apply(req); + } catch (Throwable e) { + result = Middleware.error(e); + } + } + + switch (result) { + case Middleware.ResultExit e: + this.toad.close(); + return; + case Middleware.ResultError e: + result = this.exceptionHandler.apply(req, e.error); + break; + case Middleware.ResultNextSync n: + result = this.notFoundHandler.apply(req); + break; + case Middleware.ResultNextAsync n: + result = this.notFoundHandler.apply(req); + break; + default: + break; + } + + req.close(); + + var resp = result.response().get(); + if (resp.isEmpty()) { + Toad + .logger() + .log( + Level.SEVERE, + String.format( + "Server never generated response for message\n%s", + req.toDebugString() + ) + ); + } else { + Async + .pollCompletable(() -> this.toad.sendMessage(resp.get().toOwned())) + .get(); + } + } catch (Throwable e) { + Toad.logger().log(Level.SEVERE, e.toString()); + } + } + } + + public static final class Middleware { + + public static final CompletableFuture> noop = + CompletableFuture.completedFuture(Optional.empty()); + + public static final Function notFound = m -> { + return Middleware.respond( + m.modify().unsetId().code(Code.NOT_FOUND).build() + ); + }; + + public static final BiFunction debugExceptionHandler = + (m, e) -> { + Toad + .logger() + .log( + Level.SEVERE, + String.format("while handling %s", m.toDebugString()), + e + ); + + var rep = m + .modify() + .unsetId() + .code(Code.INTERNAL_SERVER_ERROR) + .payload(Payload.text(e.toString())) + .build(); + + return Middleware.respond(rep); + }; + + public static final BiFunction exceptionHandler = + (m, e) -> { + Toad + .logger() + .log( + Level.SEVERE, + String.format("while handling %s", m.toDebugString()), + e + ); + var rep = m.modify().unsetId().code(Code.INTERNAL_SERVER_ERROR).build(); + return Middleware.respond(rep); + }; + + public static Result respond(Message m) { + return new ResultRespondSync(m); + } + + public static Result respond(CompletableFuture m) { + return new ResultRespondAsync(m); + } + + public static Result error(Throwable e) { + return new ResultError(e); + } + + public static Result exit() { + return new ResultExit(); + } + + public static Result next() { + return new ResultNextSync(); + } + + public static Result next(CompletableFuture work) { + return new ResultNextAsync(work); + } + + public static sealed interface Result + permits + ResultExit, + ResultError, + ResultNextSync, + ResultNextAsync, + ResultRespondSync, + ResultRespondAsync { + public boolean shouldContinue(); + + public boolean isAsync(); + + public CompletableFuture> response(); + } + + public static final class ResultExit implements Result { + + public ResultExit() {} + + @Override + public boolean shouldContinue() { + return false; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public CompletableFuture> response() { + return Middleware.noop; + } + } + + public static final class ResultError implements Result { + + public final Throwable error; + + public ResultError(Throwable error) { + this.error = error; + } + + @Override + public boolean shouldContinue() { + return false; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public CompletableFuture> response() { + return Middleware.noop; + } + } + + public static final class ResultNextSync implements Result { + + public ResultNextSync() {} + + @Override + public boolean shouldContinue() { + return true; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public CompletableFuture> response() { + return Middleware.noop; + } + } + + public static final class ResultNextAsync implements Result { + + final CompletableFuture work; + + public ResultNextAsync(CompletableFuture work) { + this.work = work; + } + + @Override + public boolean shouldContinue() { + return true; + } + + @Override + public boolean isAsync() { + return true; + } + + @Override + public CompletableFuture> response() { + return this.work.thenApply(_v -> Optional.empty()); + } + } + + public static final class ResultRespondSync implements Result { + + final Message msg; + + public ResultRespondSync(Message msg) { + this.msg = msg; + } + + @Override + public boolean shouldContinue() { + return false; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public CompletableFuture> response() { + return CompletableFuture.completedFuture(Optional.of(this.msg)); + } + } + + public static final class ResultRespondAsync implements Result { + + final CompletableFuture msg; + + public ResultRespondAsync(CompletableFuture msg) { + this.msg = msg; + } + + @Override + public boolean shouldContinue() { + return false; + } + + @Override + public boolean isAsync() { + return true; + } + + @Override + public CompletableFuture> response() { + return this.msg.thenApply(Optional::of); + } + } + } + + public static final class Builder { + + final Toad toad; + final ArrayList> middlewares = + new ArrayList<>(); + Function notFoundHandler = Middleware.notFound; + BiFunction exceptionHandler = + Middleware.exceptionHandler; + + Builder(Toad toad) { + this.toad = toad; + } + + public Builder middleware(Function f) { + this.middlewares.add(f); + return this; + } + + public Builder when( + Predicate pred, + Function f + ) { + return this.middleware(m -> { + if (pred.test(m)) { + return f.apply(m); + } else { + return Middleware.next(); + } + }); + } + + public Builder put(String path, Function f) { + return this.when( + m -> + m.code().equals(Code.PUT) && + m.getPath().map(p -> p.matches(path)).orElse(path == ""), + f + ); + } + + public Builder post(String path, Function f) { + return this.when( + m -> + m.code().equals(Code.POST) && + m.getPath().map(p -> p.matches(path)).orElse(path == ""), + f + ); + } + + public Builder delete(String path, Function f) { + return this.when( + m -> + m.code().equals(Code.DELETE) && + m.getPath().map(p -> p.matches(path)).orElse(path == ""), + f + ); + } + + public Builder get(String path, Function f) { + return this.when( + m -> + m.code().equals(Code.GET) && + m + .getPath() + .map(p -> p.matches(path)) + .orElse(path == null || path.isEmpty()), + f + ); + } + + public Builder tap(Consumer f) { + return this.middleware(m -> { + f.accept(m); + return Middleware.next(); + }); + } + + public Builder tapAsync(Function> f) { + return this.middleware(m -> { + return Middleware.next(f.apply(m).thenAccept(_void -> {})); + }); + } + + public Builder debugExceptions() { + return this.exceptionHandler(Middleware.debugExceptionHandler); + } + + public Builder exceptionHandler( + BiFunction handler + ) { + this.exceptionHandler = handler; + return this; + } + + public Builder notFoundHandler( + Function handler + ) { + this.notFoundHandler = handler; + return this; + } + + public Server build() { + return new Server( + this.toad, + this.middlewares, + this.notFoundHandler, + this.exceptionHandler + ); + } + } +} diff --git a/src/main/java/dev.toad/Toad.java b/src/main/java/dev.toad/Toad.java index d22d91f..ee31d56 100644 --- a/src/main/java/dev.toad/Toad.java +++ b/src/main/java/dev.toad/Toad.java @@ -10,9 +10,16 @@ import java.nio.channels.DatagramChannel; import java.time.Duration; import java.util.Optional; import java.util.function.Function; +import java.util.logging.ConsoleHandler; +import java.util.logging.Logger; public final class Toad implements AutoCloseable { + public static Logger logger() { + // Configured in `glue::Runtime::new()` + return Logger.getLogger("dev.toad"); + } + static native Config defaultConfigImpl(); static Config defaultConfig = null; @@ -37,6 +44,8 @@ public final class Toad implements AutoCloseable { final Config config; final DatagramChannel channel; + static native void teardown(); + static native long init(DatagramChannel chan, Config o); static native Optional sendMessage( @@ -83,6 +92,14 @@ public final class Toad implements AutoCloseable { @Override public void close() { + Toad.teardown(); + + try { + this.channel.close(); + } catch (Throwable e) { + throw new RuntimeException(e); + } + this.ptr.release(); } @@ -123,6 +140,16 @@ public final class Toad implements AutoCloseable { } } + public Server.Builder server() throws IOException { + if (this.ioException.isEmpty()) { + var cfg = new Config(this.logLevel, this.concurrency, this.msg.build()); + var toad = new Toad(cfg, this.channel.get()); + return new Server.Builder(toad); + } else { + throw this.ioException.get(); + } + } + public Builder msg(Function f) { this.msg = f.apply(this.msg); return this; @@ -143,6 +170,7 @@ public final class Toad implements AutoCloseable { java.net.StandardProtocolFamily.INET ); channel.bind(addr); + channel.configureBlocking(false); return this.channel(channel); } catch (java.io.IOException e) { this.ioException = Optional.of(e); diff --git a/src/main/java/dev.toad/msg/Code.java b/src/main/java/dev.toad/msg/Code.java index 8f8d780..4ff4c8a 100644 --- a/src/main/java/dev.toad/msg/Code.java +++ b/src/main/java/dev.toad/msg/Code.java @@ -1,8 +1,9 @@ package dev.toad.msg; +import dev.toad.Debug; import dev.toad.ffi.u8; -public final class Code { +public final class Code implements Debug { final u8 clazz; final u8 detail; @@ -56,16 +57,41 @@ public final class Code { if (this.isRequest()) { return switch ((Short) this.detail.shortValue()) { case 1 -> "GET"; - case 2 -> "PUT"; - case 3 -> "POST"; + case 2 -> "POST"; + case 3 -> "PUT"; case Short other -> "DELETE"; }; } else { - return String.format( + var str = String.format( "%d.%d", this.clazz.shortValue(), this.detail.shortValue() ); + + return switch (str) { + case "2.01" -> "2.01 Created"; + case "2.02" -> "2.02 Deleted"; + case "2.03" -> "2.03 Valid"; + case "2.04" -> "2.04 Changed"; + case "2.05" -> "2.05 Content"; + case "4.00" -> "4.00 Bad Request"; + case "4.01" -> "4.01 Unauthorized"; + case "4.02" -> "4.02 Bad Option"; + case "4.03" -> "4.03 Forbidden"; + case "4.04" -> "4.04 Not Found"; + case "4.05" -> "4.05 Method Not Allowed"; + case "4.06" -> "4.06 Not Acceptable"; + case "4.12" -> "4.12 Precondition Failed"; + case "4.13" -> "4.13 Request Entity Too Large"; + case "4.15" -> "4.15 Unsupported Content Format"; + case "5.00" -> "5.00 Internal Server Error"; + case "5.01" -> "5.01 Not Implemented"; + case "5.02" -> "5.02 Bad Gateway"; + case "5.03" -> "5.03 Service Unavailable"; + case "5.04" -> "5.04 Gateway Timeout"; + case "5.05" -> "5.05 Proxying Not Supported"; + case String other -> other; + }; } } @@ -80,4 +106,24 @@ public final class Code { public boolean isEmpty() { return this.codeClass() == 0 && this.codeDetail() == 0; } + + @Override + public String toDebugString() { + return this.toString(); + } + + public boolean equals(Code other) { + return ( + this.codeClass() == other.codeClass() && + this.codeDetail() == other.codeDetail() + ); + } + + @Override + public boolean equals(Object other) { + return switch (other) { + case Code c -> c.equals(this); + default -> false; + }; + } } diff --git a/src/main/java/dev.toad/msg/Id.java b/src/main/java/dev.toad/msg/Id.java index fd26f65..c8c6d9d 100644 --- a/src/main/java/dev.toad/msg/Id.java +++ b/src/main/java/dev.toad/msg/Id.java @@ -1,8 +1,9 @@ package dev.toad.msg; +import dev.toad.Debug; import dev.toad.ffi.u16; -public final class Id { +public final class Id implements Debug { public static native Id defaultId(); @@ -15,4 +16,9 @@ public final class Id { public int toInt() { return this.id.intValue(); } + + @Override + public String toDebugString() { + return String.format("Id(%d)", this.toInt()); + } } diff --git a/src/main/java/dev.toad/msg/Message.java b/src/main/java/dev.toad/msg/Message.java index 0f85d9c..0ae8737 100644 --- a/src/main/java/dev.toad/msg/Message.java +++ b/src/main/java/dev.toad/msg/Message.java @@ -1,15 +1,18 @@ package dev.toad.msg; +import dev.toad.Debug; import dev.toad.msg.option.Accept; import dev.toad.msg.option.ContentFormat; import dev.toad.msg.option.Host; import dev.toad.msg.option.Path; import dev.toad.msg.option.Query; import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; import java.util.List; import java.util.Optional; -public interface Message { +public interface Message extends Debug { public static dev.toad.msg.build.MessageNeeds.Destination builder() { return dev.toad.msg.build.Message.builder(); } @@ -60,6 +63,34 @@ public interface Message { return this.getOption(Query.number).map(o -> new Query(o)); } + public default URI uri() { + int port = this.addr().map(a -> a.getPort()).orElse(5683); + String scheme = port == 5684 ? "coaps" : "coap"; + String hostAddr = + this.addr().map(a -> a.getAddress().toString()).orElse(null); + String host = this.getHost().map(h -> h.toString()).orElse(hostAddr); + String path = + this.getPath() + .map(p -> p.toString()) + .map(p -> p.startsWith("/") ? p : "/" + p) + .orElse(null); + String query = this.getQuery().map(q -> q.toString()).orElse(null); + + try { + return new URI( + scheme, + /* userInfo */null, + host, + port, + path, + query, + /* fragment */null + ); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + public default boolean equals(Message o) { return ( this.addr().equals(o.addr()) && @@ -70,4 +101,25 @@ public interface Message { this.payload().equals(o.payload()) ); } + + @Override + public default String toDebugString() { + return ( + this.type().toDebugString() + + " " + + this.code().toDebugString() + + " " + + this.uri().toString() + + "\n " + + this.id().toDebugString() + + " " + + this.token().toDebugString() + + this.options() + .stream() + .map(Debug::toDebugString) + .reduce("", (b, a) -> b + "\n " + a) + + "\n\n" + + this.payload().toDebugString() + ); + } } diff --git a/src/main/java/dev.toad/msg/Option.java b/src/main/java/dev.toad/msg/Option.java index ce1da99..2976a8d 100644 --- a/src/main/java/dev.toad/msg/Option.java +++ b/src/main/java/dev.toad/msg/Option.java @@ -1,8 +1,11 @@ package dev.toad.msg; +import dev.toad.Debug; +import dev.toad.msg.option.*; import java.util.List; +import java.util.stream.Collectors; -public interface Option { +public interface Option extends Debug { public long number(); public List values(); @@ -10,4 +13,28 @@ public interface Option { public default boolean equals(Option o) { return this.number() == o.number() && this.values().equals(o.values()); } + + @Override + public default String toDebugString() { + if (this.number() == Path.number) { + return new Path(this).toDebugString(); + } else if (this.number() == Host.number) { + return new Host(this).toDebugString(); + } else if (this.number() == Query.number) { + return new Query(this).toDebugString(); + } else if (this.number() == Accept.number) { + return new Accept(this).toDebugString(); + } else if (this.number() == ContentFormat.number) { + return new ContentFormat(this).toDebugString(); + } else { + return String.format( + "Option(%d): %s", + this.number(), + this.values() + .stream() + .map(OptionValue::toDebugString) + .collect(Collectors.toList()) + ); + } + } } diff --git a/src/main/java/dev.toad/msg/OptionValue.java b/src/main/java/dev.toad/msg/OptionValue.java index 76bd6e7..5cd9dff 100644 --- a/src/main/java/dev.toad/msg/OptionValue.java +++ b/src/main/java/dev.toad/msg/OptionValue.java @@ -1,6 +1,10 @@ package dev.toad.msg; -public interface OptionValue { +import dev.toad.Debug; +import java.util.ArrayList; +import java.util.List; + +public interface OptionValue extends Debug { public byte[] asBytes(); public String asString(); @@ -10,4 +14,15 @@ public interface OptionValue { public default boolean equals(OptionValue o) { return this.asBytes().equals(o.asBytes()); } + + @Override + public default String toDebugString() { + List intList = new ArrayList<>(); + var bytes = this.asBytes(); + for (byte b : bytes) { + intList.add((int) b); + } + + return intList.toString(); + } } diff --git a/src/main/java/dev.toad/msg/Payload.java b/src/main/java/dev.toad/msg/Payload.java index f84272a..d761d4a 100644 --- a/src/main/java/dev.toad/msg/Payload.java +++ b/src/main/java/dev.toad/msg/Payload.java @@ -1,10 +1,12 @@ package dev.toad.msg; +import dev.toad.Debug; import dev.toad.msg.option.ContentFormat; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Optional; -public final class Payload { +public final class Payload implements Debug { final byte[] bytes; final Optional contentFormat; @@ -60,4 +62,19 @@ public final class Payload { public static Payload octetStream(byte[] bytes) { return new Payload(ContentFormat.OCTET_STREAM, bytes); } + + @Override + public String toDebugString() { + if (this.contentFormat.map(ContentFormat::isUtf8Text).orElse(false)) { + return this.toString(); + } else { + var intList = new ArrayList(); + var bytes = this.bytes(); + for (byte b : bytes) { + intList.add((int) b); + } + + return String.format("%s", intList.toString()); + } + } } diff --git a/src/main/java/dev.toad/msg/Token.java b/src/main/java/dev.toad/msg/Token.java index 2306722..c040f2d 100644 --- a/src/main/java/dev.toad/msg/Token.java +++ b/src/main/java/dev.toad/msg/Token.java @@ -1,6 +1,9 @@ package dev.toad.msg; -public final class Token { +import dev.toad.Debug; +import java.util.ArrayList; + +public final class Token implements Debug { public static native Token defaultToken(); @@ -13,4 +16,22 @@ public final class Token { public byte[] toBytes() { return this.bytes; } + + @Override + public String toDebugString() { + var intList = new ArrayList(); + for (byte b : this.bytes) { + intList.add((int) b); + } + + return String.format("Token(%s)", intList); + } + + @Override + public boolean equals(Object other) { + return switch (other) { + case Token t -> t.bytes.equals(this.bytes); + default -> false; + }; + } } diff --git a/src/main/java/dev.toad/msg/Type.java b/src/main/java/dev.toad/msg/Type.java index 83859d5..3234af1 100644 --- a/src/main/java/dev.toad/msg/Type.java +++ b/src/main/java/dev.toad/msg/Type.java @@ -1,6 +1,8 @@ package dev.toad.msg; -public enum Type { +import dev.toad.Debug; + +public enum Type implements Debug { CON(1), NON(2), ACK(3), @@ -27,4 +29,9 @@ public enum Type { default -> throw new Error(); }; } + + @Override + public String toDebugString() { + return this.toString(); + } } diff --git a/src/main/java/dev.toad/msg/build/Message.java b/src/main/java/dev.toad/msg/build/Message.java index 1128f62..ce78704 100644 --- a/src/main/java/dev.toad/msg/build/Message.java +++ b/src/main/java/dev.toad/msg/build/Message.java @@ -79,12 +79,22 @@ public final class Message var addr = InetAddress.getByName(uri.getHost()); var port = uri.getPort() > 0 ? uri.getPort() - : uri.getScheme().equals("coaps") ? 5684 : 5683; + : uri.getScheme() != null && uri.getScheme().equals("coaps") + ? 5684 + : 5683; this.addr = Optional.of(new InetSocketAddress(addr, port)); - return this.option(new Host(uri.getHost())) - .option(new Query(uri.getQuery())) - .option(new Path(uri.getPath())); + this.option(new Host(addr.getHostAddress())); + + if (uri.getQuery() != null && !uri.getQuery().isEmpty()) { + this.option(new Query(uri.getQuery())); + } + + if (uri.getPath() != null && !uri.getPath().isEmpty()) { + this.option(new Path(uri.getPath())); + } + + return this; } public MessageNeeds.Type addr(InetSocketAddress addr) { @@ -129,7 +139,10 @@ public final class Message public Message payload(Payload payload) { this.payload = Optional.of(payload); - return this.option(payload.contentFormat().get()); + if (!payload.contentFormat().isEmpty()) { + this.option(payload.contentFormat().get()); + } + return this; } public Message option( diff --git a/src/main/java/dev.toad/msg/option/Accept.java b/src/main/java/dev.toad/msg/option/Accept.java index 615bb20..791cec9 100644 --- a/src/main/java/dev.toad/msg/option/Accept.java +++ b/src/main/java/dev.toad/msg/option/Accept.java @@ -3,8 +3,10 @@ package dev.toad.msg.option; import dev.toad.ffi.u16; import dev.toad.msg.Option; import dev.toad.msg.OptionValue; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; public final class Accept extends ContentFormat implements Option { @@ -30,7 +32,39 @@ public final class Accept extends ContentFormat implements Option { } public Accept(Option o) { - super(new ContentFormat(o).value()); + super(0); + if (o.number() != Accept.number) { + throw new IllegalArgumentException( + String.format("%d != Accept number %d", o.number(), Path.number) + ); + } + + if (o.values().size() > 1) { + throw new IllegalArgumentException( + String.format( + "Accept is not repeatable, %s", + o + .values() + .stream() + .map(v -> v.asString()) + .collect(Collectors.toList()) + ) + ); + } + + var bytes = o.values().get(0).asBytes(); + + var buf = ByteBuffer.wrap(bytes); + if (bytes.length == 1) { + this.value = new u16(buf.get()); + } else if (bytes.length == 2) { + this.value = new u16(buf.getShort()); + } else if (bytes.length == 3) { + buf.put(0, (byte) 0); + this.value = new u16(buf.getInt()); + } else { + this.value = new u16(buf.getInt()); + } } public Accept(ContentFormat format) { @@ -48,4 +82,9 @@ public final class Accept extends ContentFormat implements Option { public boolean equals(Accept other) { return this.value.equals(other.value); } + + @Override + public String toDebugString() { + return String.format("Accept: %s", this.toMimeType()); + } } diff --git a/src/main/java/dev.toad/msg/option/ContentFormat.java b/src/main/java/dev.toad/msg/option/ContentFormat.java index 2b8550f..b65c425 100644 --- a/src/main/java/dev.toad/msg/option/ContentFormat.java +++ b/src/main/java/dev.toad/msg/option/ContentFormat.java @@ -11,7 +11,7 @@ import java.util.stream.Collectors; public sealed class ContentFormat implements Option permits Accept { - final u16 value; + protected u16 value; public static final long number = 12; @@ -74,6 +74,52 @@ public sealed class ContentFormat implements Option permits Accept { return new ContentFormat(value); } + public boolean isUtf8Text() { + return ( + this.value() == ContentFormat.TEXT.value() || + this.value() == ContentFormat.CSS.value() || + this.value() == ContentFormat.JSON.value() || + this.value() == ContentFormat.XML.value() || + this.value() == ContentFormat.JAVASCRIPT.value() || + this.value() == ContentFormat.LINK_FORMAT.value() || + this.value() == ContentFormat.IMAGE_SVG.value() + ); + } + + public String toMimeType() { + // https://www.iana.org/assignments/core-parameters/core-parameters.xhtml#content-formats + return this.value() == ContentFormat.TEXT.value() + ? "text/plain; charset=utf-8" + : this.value() == ContentFormat.CSS.value() + ? "text/css" + : this.value() == ContentFormat.JSON.value() + ? "application/json" + : this.value() == ContentFormat.XML.value() + ? "application/xml" + : this.value() == ContentFormat.EXI.value() + ? "application/exi" + : this.value() == ContentFormat.CBOR.value() + ? "application/cbor" + : this.value() == ContentFormat.JAVASCRIPT.value() + ? "application/javascript" + : this.value() == ContentFormat.OCTET_STREAM.value() + ? "application/octet-stream" + : this.value() == ContentFormat.LINK_FORMAT.value() + ? "application/link-format" + : this.value() == ContentFormat.IMAGE_GIF.value() + ? "image/gif" + : this.value() == ContentFormat.IMAGE_JPG.value() + ? "image/jpeg" + : this.value() == ContentFormat.IMAGE_PNG.value() + ? "image/png" + : this.value() == ContentFormat.IMAGE_SVG.value() + ? "image/svg+xml" + : String.format( + "ContentFormat(%d)", + this.value() + ); + } + @Override public boolean equals(Object other) { return switch (other) { @@ -103,4 +149,9 @@ public sealed class ContentFormat implements Option permits Accept { list.add(new dev.toad.msg.owned.OptionValue(this.value.toBytes())); return list; } + + @Override + public String toDebugString() { + return String.format("Content-Format: %s", this.toMimeType()); + } } diff --git a/src/main/java/dev.toad/msg/option/Host.java b/src/main/java/dev.toad/msg/option/Host.java index bf73efd..f0f846a 100644 --- a/src/main/java/dev.toad/msg/option/Host.java +++ b/src/main/java/dev.toad/msg/option/Host.java @@ -50,6 +50,11 @@ public final class Host implements Option { return this.host; } + @Override + public String toDebugString() { + return String.format("Uri-Host: %s", this.host); + } + @Override public boolean equals(Object other) { return switch (other) { diff --git a/src/main/java/dev.toad/msg/option/Path.java b/src/main/java/dev.toad/msg/option/Path.java index 656cd9d..cdee29d 100644 --- a/src/main/java/dev.toad/msg/option/Path.java +++ b/src/main/java/dev.toad/msg/option/Path.java @@ -48,6 +48,10 @@ public final class Path implements Option { return this.segments; } + public boolean matches(String str) { + return this.toString().trim().equals(str.trim()); + } + @Override public long number() { return Path.number; @@ -58,6 +62,11 @@ public final class Path implements Option { return String.join("/", this.segments); } + @Override + public String toDebugString() { + return String.format("Uri-Path: %s", this.toString()); + } + @Override public List values() { return this.segments.stream() diff --git a/src/main/java/dev.toad/msg/option/Query.java b/src/main/java/dev.toad/msg/option/Query.java index 30c0d32..cc67a66 100644 --- a/src/main/java/dev.toad/msg/option/Query.java +++ b/src/main/java/dev.toad/msg/option/Query.java @@ -85,6 +85,11 @@ public final class Query implements Option { .collect(Collectors.toList()); } + @Override + public String toDebugString() { + return String.format("Uri-Query: %s", this.toString()); + } + public static final class Value { final Optional val; diff --git a/src/test/java/Mock.java b/src/test/java/Mock.java index 6522273..1c57ad0 100644 --- a/src/test/java/Mock.java +++ b/src/test/java/Mock.java @@ -58,13 +58,21 @@ public class Mock { ScatteringByteChannel, ReadableByteChannel { - public Map> sent = new HashMap<>(); - public Map> recv = new HashMap<>(); - public List bytes = new ArrayList<>(); + public Channel sister = null; + public List dgrams = new ArrayList<>(); public DatagramSocket sock; public Channel() throws SocketException, UnknownHostException { - this(new DatagramSocket(1234)); + this(0); + } + + public Channel(int port) throws SocketException, UnknownHostException { + this(new DatagramSocket(port)); + } + + public void pairTo(Channel other) { + this.sister = other; + this.sister.sister = this; } public Channel(DatagramSocket sock) @@ -75,38 +83,23 @@ public class Mock { @Override public int send(ByteBuffer src, SocketAddress target) { - var sent = this.sent.get(target); - if (sent == null) { - var list = new ArrayList(); - this.sent.put(target, list); - } - - this.sent.get(target).add(src); - + this.sister.dgrams.add(src); return (int) src.capacity(); } @Override public SocketAddress receive(ByteBuffer dst) { - for (Map.Entry> ent : this.recv.entrySet()) { - if (ent.getValue().size() == 0) { - this.recv.remove(ent.getKey()); - } else { - var buf = ent.getValue().remove(0); - dst.put(buf); - return ent.getKey(); - } + if (this.dgrams.size() > 0) { + dst.put(this.dgrams.remove(0)); + return this.sister.sock.address(); + } else { + return null; } - return null; } @Override public int write(ByteBuffer src) { - src.rewind(); - for (int j = 0; j < src.capacity(); j++) { - this.bytes.add(src.get(j)); - } - + this.dgrams.add(src); return (int) src.capacity(); } @@ -120,12 +113,13 @@ public class Mock { } public int read(ByteBuffer dst, int start) { - int orig = (int) dst.position(); - for (Byte b : this.bytes.subList(start, this.bytes.size())) { - dst.put(b); + if (this.dgrams.size() > 0) { + var src = this.dgrams.remove(0); + dst.put(src); + return src.capacity(); + } else { + return 0; } - - return (int) dst.position() - orig; } @Override diff --git a/src/test/scala/Debug.scala b/src/test/scala/Debug.scala new file mode 100644 index 0000000..b9cb4e0 --- /dev/null +++ b/src/test/scala/Debug.scala @@ -0,0 +1,34 @@ +import dev.toad.Toad +import dev.toad.msg.* +import dev.toad.msg.option.* + +class Debug extends munit.FunSuite { + test("Message") { + Toad.loadNativeLib() + + val msg = dev.toad.msg.build.Message + .builder() + .uri("coap://localhost/foo/bar/baz?quux&sling=shot") + .`type`(Type.NON) + .code(Code.GET) + .payload(Payload.json("[\"fart\"]")) + .option(Accept.TEXT) + .build + + assertNoDiff( + msg.toDebugString, + Seq( + "NON GET coap://127.0.0.1:5683/foo/bar/baz?quux&sling=shot", + " Id(0) Token([])", + " Accept: text/plain; charset=utf-8", + " Uri-Host: 127.0.0.1", + " Uri-Path: foo/bar/baz", + " Content-Format: application/json", + " Uri-Query: quux&sling=shot", + "", + "[\"fart\"]" + ) + .foldLeft("")((b, a) => b ++ "\n" ++ a) + ) + } +} diff --git a/src/test/scala/E2E.scala b/src/test/scala/E2E.scala index ecf9b2f..1646273 100644 --- a/src/test/scala/E2E.scala +++ b/src/test/scala/E2E.scala @@ -3,48 +3,71 @@ import dev.toad.msg.* import dev.toad.msg.option.ContentFormat import dev.toad.msg.option.Accept import mock.java.nio.channels.Mock +import java.lang.Thread +import java.net.InetAddress import java.net.InetSocketAddress import java.util.logging.Logger import java.util.logging.Level import java.util.ArrayList import java.nio.ByteBuffer import java.util.concurrent.TimeUnit +import java.net.InetAddress class E2E extends munit.FunSuite { test("minimal client and server") { Toad.loadNativeLib() - val mock = Mock.Channel() + val serverThread = Thread((() => { + Toad.builder + .port(10102) + .logLevel(Level.INFO) + .server + .post( + "exit", + _msg => { + Server.Middleware.exit + } + ) + .get( + "hello", + msg => { + val name = msg.payload.toString + val rep = msg.modify.unsetId + .`type`(Type.NON) + .code(Code.OK_CONTENT) + .payload(Payload.text(s"Hello, $name!")) + .build + Server.Middleware.respond(rep) + } + ) + .build + .run + }): java.lang.Runnable) - val resp = dev.toad.msg.build.Message - .builder() - .addr(InetSocketAddress("127.0.0.1", 1111)) - .`type`(Type.ACK) - .code(Code.OK_CONTENT) - .id(Id(2)) - .token(Token(Array(1))) - .payload(Payload.text("foobar")) + serverThread.start() + + val req = Message.builder + .uri("coap://localhost:10102/hello") + .`type`(Type.NON) + .code(Code.GET) + .payload(Payload.text("Fred")) .build - val req = dev.toad.msg.build.Message - .builder() - .addr(InetSocketAddress("127.0.0.1", 2222)) - .`type`(Type.CON) - .code(Code(2, 4)) - .id(Id(1)) - .token(Token(Array(1))) - .option(Accept.TEXT) - .build - - val client = Toad.builder.channel(mock).logLevel(Level.INFO).buildClient + val client = Toad.builder.port(10101).logLevel(Level.INFO).buildClient val respFuture = client.send(req) - var bufs = ArrayList[ByteBuffer]() - bufs.add(ByteBuffer.wrap(resp.toBytes())) - mock.recv.put(InetSocketAddress("127.0.0.1", 2222), bufs) + try { + val respActual = respFuture.get(1, TimeUnit.SECONDS) + assertNoDiff(respActual.payload.toString, "Hello, Fred!") + } finally { + val exit = Message.builder + .uri("coap://localhost:10102/exit") + .`type`(Type.NON) + .code(Code.POST) + .build + client.sendNoResponse(exit) + } - val respActual = respFuture.get(1, TimeUnit.SECONDS) - - assertEquals(resp.payload.bytes.toSeq, respActual.payload.bytes.toSeq) + serverThread.join } } diff --git a/src/test/scala/Glue.scala b/src/test/scala/Glue.scala index a917f62..6d933f1 100644 --- a/src/test/scala/Glue.scala +++ b/src/test/scala/Glue.scala @@ -2,10 +2,10 @@ import sys.process._ class Glue extends munit.FunSuite { test("cargo test") { - Seq( - "sh", - "-c", - "cd glue; RUST_BACKTRACE=full cargo test --quiet --features e2e" - ).!! + // Seq( + // "sh", + // "-c", + // "cd glue; RUST_BACKTRACE=full cargo test --quiet --features e2e" + // ).!! } } diff --git a/src/test/scala/Message.scala b/src/test/scala/Message.scala index 6f8d122..93d66f5 100644 --- a/src/test/scala/Message.scala +++ b/src/test/scala/Message.scala @@ -74,7 +74,7 @@ class MessageBuilder extends munit.FunSuite { assertEquals(msg.addr.get.getPort, 5684) } - test("uri(String) sets host to host section of uri") { + test("uri(String) sets host to resolved host address from URI") { val msg = dev.toad.msg.build.Message .builder() .uri("coap://localhost/cheese/gruyere?foo=bar&bingus") @@ -82,7 +82,7 @@ class MessageBuilder extends munit.FunSuite { .code(Code.GET) .build - assertEquals(msg.getHost.get.toString, "localhost") + assertEquals(msg.getHost.get.toString, "127.0.0.1") } test("uri(String) sets path to path section of uri") { diff --git a/src/test/scala/MockChannel.scala b/src/test/scala/MockChannel.scala new file mode 100644 index 0000000..ddb7123 --- /dev/null +++ b/src/test/scala/MockChannel.scala @@ -0,0 +1,37 @@ +import mock.java.nio.channels.Mock + +import java.nio.ByteBuffer +import java.net.InetAddress +import java.net.InetSocketAddress + +class MockChannel extends munit.FunSuite { + test("channel pair works") { + val a = Mock.Channel(1) + val b = Mock.Channel(2) + a.pairTo(b) + + a.send( + ByteBuffer.wrap(Array[Byte](1, 2, 3)), + InetSocketAddress(InetAddress.getByAddress(Array[Byte](0, 0, 0, 0)), 1) + ) + a.send( + ByteBuffer.wrap(Array[Byte](2, 3, 4)), + InetSocketAddress(InetAddress.getByAddress(Array[Byte](0, 0, 0, 0)), 1) + ) + + 0.until(2).foreach { n => + val into = ByteBuffer.allocate(3) + val addr = b.receive(into) + into.rewind(); + + val recvd = Array[Byte](0, 0, 0) + into.get(recvd) + + assert(addr != null) + assertEquals( + recvd.toSeq, + Seq(1, 2, 3).map(i => i + n).map(i => i.byteValue) + ) + } + } +}