From 6295db718aee38211121024e28ce71029ab58767 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 12 Jan 2026 09:42:51 -0300 Subject: [PATCH 01/65] WIP: GRPC: nothing work --- dump.txt | 423 ++++++++++++++++++ jooby/src/main/java/io/jooby/Context.java | 16 + .../main/java/io/jooby/DefaultContext.java | 5 + .../main/java/io/jooby/ForwardingContext.java | 11 + jooby/src/main/java/io/jooby/Sender.java | 9 + .../java/io/jooby/internal/HeadContext.java | 5 + list.txt | 11 + modules/jooby-bom/pom.xml | 5 + modules/jooby-grpc/pom.xml | 57 +++ .../main/java/io/jooby/grpc/GrpcDeframer.java | 53 +++ .../main/java/io/jooby/grpc/GrpcHandler.java | 229 ++++++++++ .../io/jooby/grpc/GrpcMethodRegistry.java | 28 ++ .../main/java/io/jooby/grpc/GrpcModule.java | 54 +++ .../java/io/jooby/grpc/GrpcRequestBridge.java | 79 ++++ .../java/io/jooby/grpc/UnifiedGrpcBridge.java | 253 +++++++++++ .../io/jooby/internal/jetty/JettyContext.java | 23 +- .../internal/jetty/JettyGrpcHandler.java | 39 ++ .../io/jooby/internal/jetty/JettyHandler.java | 12 +- .../internal/jetty/JettyRequestPublisher.java | 137 ++++++ .../io/jooby/internal/jetty/JettySender.java | 58 ++- .../jetty/http2/JettyHttp2Configurer.java | 20 +- .../src/main/java/module-info.java | 1 + .../internal/netty/NettyByteBufBody.java | 97 ++++ .../io/jooby/internal/netty/NettyContext.java | 26 +- .../io/jooby/internal/netty/NettyHandler.java | 16 +- .../io/jooby/internal/netty/NettySender.java | 21 +- .../netty/http2/NettyHttp2Configurer.java | 2 +- .../src/main/java/module-info.java | 1 - .../main/java/io/jooby/test/MockContext.java | 17 +- .../internal/undertow/UndertowContext.java | 16 +- .../undertow/UndertowGrpcHandler.java | 56 +++ .../internal/undertow/UndertowHandler.java | 21 +- .../undertow/UndertowOutputCallback.java | 44 -- .../undertow/UndertowRequestPublisher.java | 88 ++++ .../internal/undertow/UndertowSender.java | 44 +- .../io/jooby/undertow/UndertowServer.java | 2 +- modules/pom.xml | 2 + pom.xml | 1 + tests/pom.xml | 55 +++ tests/src/main/proto/chat.proto | 17 + tests/src/main/proto/hello.proto | 20 + .../test/java/examples/grpc/ChatClient.java | 81 ++++ .../java/examples/grpc/ChatServiceImpl.java | 46 ++ .../java/examples/grpc/GreeterService.java | 20 + .../test/java/examples/grpc/GrpcClient.java | 26 ++ .../test/java/examples/grpc/GrpcServer.java | 49 ++ .../java/examples/grpc/ReflectionClient.java | 68 +++ .../src/test/java/io/jooby/test/GrpcTest.java | 55 +++ tests/src/test/resources/logback.xml | 2 +- 49 files changed, 2329 insertions(+), 92 deletions(-) create mode 100644 dump.txt create mode 100644 list.txt create mode 100644 modules/jooby-grpc/pom.xml create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java create mode 100644 modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java create mode 100644 modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java delete mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java create mode 100644 modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java create mode 100644 tests/src/main/proto/chat.proto create mode 100644 tests/src/main/proto/hello.proto create mode 100644 tests/src/test/java/examples/grpc/ChatClient.java create mode 100644 tests/src/test/java/examples/grpc/ChatServiceImpl.java create mode 100644 tests/src/test/java/examples/grpc/GreeterService.java create mode 100644 tests/src/test/java/examples/grpc/GrpcClient.java create mode 100644 tests/src/test/java/examples/grpc/GrpcServer.java create mode 100644 tests/src/test/java/examples/grpc/ReflectionClient.java create mode 100644 tests/src/test/java/io/jooby/test/GrpcTest.java diff --git a/dump.txt b/dump.txt new file mode 100644 index 0000000000..1fb5ca0768 --- /dev/null +++ b/dump.txt @@ -0,0 +1,423 @@ +2026-01-02 19:02:09 +Full thread dump OpenJDK 64-Bit Server VM (24.0.2+12 mixed mode, sharing): + +Threads class SMR info: +_java_thread_list=0x0000600001982ce0, length=31, elements={ +0x000000014a00e200, 0x000000014a012000, 0x000000014a012800, 0x000000014a013000, +0x000000014a013800, 0x000000014a014000, 0x000000014a01c800, 0x000000014981d600, +0x000000014a122e00, 0x000000014a123600, 0x000000014a9e8800, 0x000000014aa54e00, +0x000000014aa55600, 0x000000014a9e9000, 0x00000001038d6000, 0x000000014c059c00, +0x000000014c057800, 0x000000014c058000, 0x000000012fb59200, 0x000000014a1f7000, +0x000000012fb5bc00, 0x000000013f810200, 0x000000013f819400, 0x000000012f1d5800, +0x000000012f1d6000, 0x000000013e998400, 0x000000014a1f7800, 0x000000014aa71e00, +0x000000012e03be00, 0x000000013e875000, 0x000000012e045800 +} + +"Reference Handler" #15 [29443] daemon prio=10 os_prio=31 cpu=0.26ms elapsed=24.14s tid=0x000000014a00e200 nid=29443 waiting on condition [0x000000016e5c2000] + java.lang.Thread.State: RUNNABLE + at java.lang.ref.Reference.waitForReferencePendingList(java.base@24.0.2/Native Method) + at java.lang.ref.Reference.processPendingReferences(java.base@24.0.2/Reference.java:246) + at java.lang.ref.Reference$ReferenceHandler.run(java.base@24.0.2/Reference.java:208) + + Locked ownable synchronizers: + - None + +"Finalizer" #16 [24835] daemon prio=8 os_prio=31 cpu=0.05ms elapsed=24.14s tid=0x000000014a012000 nid=24835 in Object.wait() [0x000000016e7ce000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@24.0.2/Native Method) + - waiting on <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.Object.wait(java.base@24.0.2/Object.java:389) + at java.lang.Object.wait(java.base@24.0.2/Object.java:351) + at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:138) + at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:229) + - locked <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.ref.Finalizer$FinalizerThread.run(java.base@24.0.2/Finalizer.java:165) + + Locked ownable synchronizers: + - None + +"Signal Dispatcher" #17 [29187] daemon prio=9 os_prio=31 cpu=0.12ms elapsed=24.14s tid=0x000000014a012800 nid=29187 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"Service Thread" #18 [25603] daemon prio=9 os_prio=31 cpu=0.84ms elapsed=24.14s tid=0x000000014a013000 nid=25603 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"Monitor Deflation Thread" #19 [26115] daemon prio=9 os_prio=31 cpu=2.54ms elapsed=24.14s tid=0x000000014a013800 nid=26115 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"C2 CompilerThread0" #20 [28931] daemon prio=9 os_prio=31 cpu=208.92ms elapsed=24.14s tid=0x000000014a014000 nid=28931 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + + Locked ownable synchronizers: + - None + +"C1 CompilerThread0" #28 [26627] daemon prio=9 os_prio=31 cpu=100.30ms elapsed=24.14s tid=0x000000014a01c800 nid=26627 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + + Locked ownable synchronizers: + - None + +"Common-Cleaner" #32 [27395] daemon prio=8 os_prio=31 cpu=0.04ms elapsed=24.12s tid=0x000000014981d600 nid=27395 in Object.wait() [0x000000016f82e000] + java.lang.Thread.State: TIMED_WAITING (on object monitor) + at java.lang.Object.wait0(java.base@24.0.2/Native Method) + - waiting on <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) + at java.lang.Object.wait(java.base@24.0.2/Object.java:389) + at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:124) + at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:215) + - locked <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) + at jdk.internal.ref.CleanerImpl.run(java.base@24.0.2/CleanerImpl.java:140) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + at jdk.internal.misc.InnocuousThread.run(java.base@24.0.2/InnocuousThread.java:148) + + Locked ownable synchronizers: + - None + +"Monitor Ctrl-Break" #33 [43011] daemon prio=5 os_prio=31 cpu=8.19ms elapsed=24.08s tid=0x000000014a122e00 nid=43011 runnable [0x000000016fc46000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.SocketDispatcher.read0(java.base@24.0.2/Native Method) + at sun.nio.ch.SocketDispatcher.read(java.base@24.0.2/SocketDispatcher.java:47) + at sun.nio.ch.NioSocketImpl.tryRead(java.base@24.0.2/NioSocketImpl.java:255) + at sun.nio.ch.NioSocketImpl.implRead(java.base@24.0.2/NioSocketImpl.java:306) + at sun.nio.ch.NioSocketImpl.read(java.base@24.0.2/NioSocketImpl.java:345) + at sun.nio.ch.NioSocketImpl$1.read(java.base@24.0.2/NioSocketImpl.java:790) + at java.net.Socket$SocketInputStream.implRead(java.base@24.0.2/Socket.java:983) + at java.net.Socket$SocketInputStream.read(java.base@24.0.2/Socket.java:970) + at sun.nio.cs.StreamDecoder.readBytes(java.base@24.0.2/StreamDecoder.java:279) + at sun.nio.cs.StreamDecoder.implRead(java.base@24.0.2/StreamDecoder.java:322) + at sun.nio.cs.StreamDecoder.read(java.base@24.0.2/StreamDecoder.java:186) + - locked <0x000000052b0266a0> (a java.io.InputStreamReader) + at java.io.InputStreamReader.read(java.base@24.0.2/InputStreamReader.java:175) + at java.io.BufferedReader.fill(java.base@24.0.2/BufferedReader.java:166) + at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:333) + - locked <0x000000052b0266a0> (a java.io.InputStreamReader) + at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:400) + at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:31) + + Locked ownable synchronizers: + - <0x000000052b3d2888> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) + +"Notification Thread" #34 [42499] daemon prio=9 os_prio=31 cpu=0.01ms elapsed=24.08s tid=0x000000014a123600 nid=42499 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"worker I/O-1" #49 [38915] prio=5 os_prio=31 cpu=0.35ms elapsed=23.78s tid=0x000000014a9e8800 nid=38915 runnable [0x00000003220ba000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0333c8> (a sun.nio.ch.Util$2) + - locked <0x000000052b033370> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-2" #50 [38403] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa54e00 nid=38403 runnable [0x00000003222c6000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b040080> (a sun.nio.ch.Util$2) + - locked <0x000000052b040028> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-3" #51 [37891] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa55600 nid=37891 runnable [0x00000003224d2000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b04cd38> (a sun.nio.ch.Util$2) + - locked <0x000000052b04cce0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-4" #52 [43523] prio=5 os_prio=31 cpu=0.24ms elapsed=23.78s tid=0x000000014a9e9000 nid=43523 runnable [0x00000003226de000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0599f0> (a sun.nio.ch.Util$2) + - locked <0x000000052b059998> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-5" #53 [44035] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x00000001038d6000 nid=44035 runnable [0x00000003228ea000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b073360> (a sun.nio.ch.Util$2) + - locked <0x000000052b073308> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-6" #54 [44291] prio=5 os_prio=31 cpu=0.25ms elapsed=23.78s tid=0x000000014c059c00 nid=44291 runnable [0x0000000322af6000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b0666a8> (a sun.nio.ch.Util$2) + - locked <0x000000052b066650> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-7" #55 [64771] prio=5 os_prio=31 cpu=0.23ms elapsed=23.78s tid=0x000000014c057800 nid=64771 runnable [0x0000000322d02000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b000158> (a sun.nio.ch.Util$2) + - locked <0x000000052b000100> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-8" #56 [44803] prio=5 os_prio=31 cpu=28.09ms elapsed=23.78s tid=0x000000014c058000 nid=44803 runnable [0x0000000322f0e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b080018> (a sun.nio.ch.Util$2) + - locked <0x000000052b07ffc0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:142) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:563) + + Locked ownable synchronizers: + - None + +"worker I/O-9" #57 [64515] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000012fb59200 nid=64515 runnable [0x000000032311a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b08ccd0> (a sun.nio.ch.Util$2) + - locked <0x000000052b08cc78> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-10" #58 [45571] prio=5 os_prio=31 cpu=0.05ms elapsed=23.78s tid=0x000000014a1f7000 nid=45571 runnable [0x0000000323326000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b019b18> (a sun.nio.ch.Util$2) + - locked <0x000000052b019ac0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-11" #59 [64003] prio=5 os_prio=31 cpu=0.12ms elapsed=23.78s tid=0x000000012fb5bc00 nid=64003 runnable [0x0000000323532000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b00ce58> (a sun.nio.ch.Util$2) + - locked <0x000000052b00ce00> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-12" #60 [46083] prio=5 os_prio=31 cpu=0.06ms elapsed=23.78s tid=0x000000013f810200 nid=46083 runnable [0x000000032373e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b02cd58> (a sun.nio.ch.Util$2) + - locked <0x000000052b02cd00> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-13" #61 [46339] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000013f819400 nid=46339 runnable [0x000000032394a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b099988> (a sun.nio.ch.Util$2) + - locked <0x000000052b099930> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-14" #62 [63235] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d5800 nid=63235 runnable [0x0000000323b56000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b059b70> (a sun.nio.ch.Util$2) + - locked <0x000000052b059b18> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-15" #63 [46595] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d6000 nid=46595 runnable [0x0000000323d62000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b033548> (a sun.nio.ch.Util$2) + - locked <0x000000052b0334f0> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker I/O-16" #64 [47107] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000013e998400 nid=47107 runnable [0x0000000323f6e000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b02ced8> (a sun.nio.ch.Util$2) + - locked <0x000000052b02ce80> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"worker Accept" #65 [62467] prio=5 os_prio=31 cpu=4.29ms elapsed=23.78s tid=0x000000014a1f7800 nid=62467 runnable [0x000000032417a000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) + at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) + - locked <0x000000052b019d30> (a sun.nio.ch.Util$2) + - locked <0x000000052b019cd8> (a sun.nio.ch.KQueueSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) + at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) + + Locked ownable synchronizers: + - None + +"DestroyJavaVM" #67 [5635] prio=5 os_prio=31 cpu=510.86ms elapsed=23.63s tid=0x000000014aa71e00 nid=5635 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"worker task-1" #68 [27911] prio=5 os_prio=31 cpu=70.58ms elapsed=16.81s tid=0x000000012e03be00 nid=27911 waiting on condition [0x000000016f622000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) + - parking to wait for <0x000000052b3de1c8> (a org.jboss.threads.EnhancedQueueExecutor) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) + at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1421) + at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + + Locked ownable synchronizers: + - None + +"grpc-timer-0" #69 [27143] daemon prio=5 os_prio=31 cpu=0.12ms elapsed=16.81s tid=0x000000013e875000 nid=27143 waiting on condition [0x000000016fa3a000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) + - parking to wait for <0x000000052b059c88> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@24.0.2/AbstractQueuedSynchronizer.java:1802) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:1166) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:883) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@24.0.2/ThreadPoolExecutor.java:1021) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@24.0.2/ThreadPoolExecutor.java:1081) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@24.0.2/ThreadPoolExecutor.java:619) + at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) + at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) + + Locked ownable synchronizers: + - None + +"Attach Listener" #72 [41995] daemon prio=9 os_prio=31 cpu=0.34ms elapsed=0.11s tid=0x000000012e045800 nid=41995 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + + Locked ownable synchronizers: + - None + +"G1 Conc#2" os_prio=31 cpu=0.96ms elapsed=16.77s tid=0x000000014b1148c0 nid=34055 runnable + +"G1 Conc#1" os_prio=31 cpu=2.36ms elapsed=16.77s tid=0x0000000103616eb0 nid=33799 runnable + +"GC Thread#12" os_prio=31 cpu=2.93ms elapsed=23.83s tid=0x000000014971c4b0 nid=37379 runnable + +"GC Thread#11" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971bf30 nid=36867 runnable + +"GC Thread#10" os_prio=31 cpu=2.92ms elapsed=23.83s tid=0x000000014971b9b0 nid=36611 runnable + +"GC Thread#9" os_prio=31 cpu=2.97ms elapsed=23.83s tid=0x000000014971b430 nid=36355 runnable + +"GC Thread#8" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971aeb0 nid=36099 runnable + +"GC Thread#7" os_prio=31 cpu=3.09ms elapsed=23.83s tid=0x000000014971a930 nid=40451 runnable + +"GC Thread#6" os_prio=31 cpu=3.04ms elapsed=23.83s tid=0x000000014971a3b0 nid=35587 runnable + +"GC Thread#5" os_prio=31 cpu=2.95ms elapsed=23.83s tid=0x0000000149719e30 nid=35075 runnable + +"GC Thread#4" os_prio=31 cpu=2.86ms elapsed=23.83s tid=0x00000001497198b0 nid=34819 runnable + +"GC Thread#3" os_prio=31 cpu=3.13ms elapsed=23.83s tid=0x000000014b01c6a0 nid=41219 runnable + +"GC Thread#2" os_prio=31 cpu=3.11ms elapsed=23.83s tid=0x0000000149719330 nid=41731 runnable + +"GC Thread#1" os_prio=31 cpu=3.06ms elapsed=23.83s tid=0x000000012e812cd0 nid=34307 runnable + +"VM Thread" os_prio=31 cpu=5.25ms elapsed=24.15s tid=0x000000012df04560 nid=19715 runnable + +"VM Periodic Task Thread" os_prio=31 cpu=13.48ms elapsed=24.15s tid=0x00000001497086d0 nid=20743 waiting on condition + +"G1 Service" os_prio=31 cpu=1.69ms elapsed=24.15s tid=0x00000001497065d0 nid=21251 runnable + +"G1 Refine#0" os_prio=31 cpu=0.02ms elapsed=24.15s tid=0x000000014b860600 nid=16643 runnable + +"G1 Conc#0" os_prio=31 cpu=1.46ms elapsed=24.15s tid=0x0000000149705e30 nid=13827 runnable + +"G1 Main Marker" os_prio=31 cpu=0.12ms elapsed=24.15s tid=0x000000014b107a60 nid=13315 runnable + +"GC Thread#0" os_prio=31 cpu=3.00ms elapsed=24.15s tid=0x000000014b1072b0 nid=13059 runnable + +JNI global refs: 23, weak refs: 0 + diff --git a/jooby/src/main/java/io/jooby/Context.java b/jooby/src/main/java/io/jooby/Context.java index 1f5be9f676..1088474719 100644 --- a/jooby/src/main/java/io/jooby/Context.java +++ b/jooby/src/main/java/io/jooby/Context.java @@ -1088,6 +1088,15 @@ default Value lookup(String name) { */ Context setResponseHeader(@NonNull String name, @NonNull String value); + /** + * Set response trailer header. + * + * @param name Header name. + * @param value Header value. + * @return This context. + */ + Context setResponseTrailer(@NonNull String name, @NonNull String value); + /** * Remove a response header. * @@ -1238,6 +1247,13 @@ Context responseStream( * * @return HTTP channel as chunker. Usually for chunked response. */ + Sender responseSender(boolean startResponse); + + /** + * HTTP response channel as chunker. Mark the response as started. + * + * @return HTTP channel as chunker. Usually for chunked response. + */ Sender responseSender(); /** diff --git a/jooby/src/main/java/io/jooby/DefaultContext.java b/jooby/src/main/java/io/jooby/DefaultContext.java index 40edf0cb25..8bd8800395 100644 --- a/jooby/src/main/java/io/jooby/DefaultContext.java +++ b/jooby/src/main/java/io/jooby/DefaultContext.java @@ -554,6 +554,11 @@ default Context render(@NonNull Object value) { } } + @Override + default Sender responseSender() { + return responseSender(true); + } + @Override default OutputStream responseStream(@NonNull MediaType contentType) { setResponseType(contentType); diff --git a/jooby/src/main/java/io/jooby/ForwardingContext.java b/jooby/src/main/java/io/jooby/ForwardingContext.java index 5d2a8fcfe6..3efc766080 100644 --- a/jooby/src/main/java/io/jooby/ForwardingContext.java +++ b/jooby/src/main/java/io/jooby/ForwardingContext.java @@ -1091,6 +1091,12 @@ public Context setResponseHeader(@NonNull String name, @NonNull Date value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + ctx.setResponseHeader(name, value); + return this; + } + @Override public Context setResponseHeader(@NonNull String name, @NonNull Instant value) { ctx.setResponseHeader(name, value); @@ -1217,6 +1223,11 @@ public Sender responseSender() { return ctx.responseSender(); } + @Override + public Sender responseSender(boolean startResponse) { + return ctx.responseSender(startResponse); + } + @Override public PrintWriter responseWriter() { return ctx.responseWriter(); diff --git a/jooby/src/main/java/io/jooby/Sender.java b/jooby/src/main/java/io/jooby/Sender.java index 7db97c811d..90dae3eef8 100644 --- a/jooby/src/main/java/io/jooby/Sender.java +++ b/jooby/src/main/java/io/jooby/Sender.java @@ -73,6 +73,15 @@ default Sender write(@NonNull String data, @NonNull Callback callback) { return write(data, StandardCharsets.UTF_8, callback); } + /** + * Set response trailer header. + * + * @param name Header name. + * @param value Header value. + * @return This context. + */ + Sender setTrailer(@NonNull String name, @NonNull String value); + /** * Write a string chunk. Chunk is flushed immediately. * diff --git a/jooby/src/main/java/io/jooby/internal/HeadContext.java b/jooby/src/main/java/io/jooby/internal/HeadContext.java index c4dc99aec9..06f66e9d22 100644 --- a/jooby/src/main/java/io/jooby/internal/HeadContext.java +++ b/jooby/src/main/java/io/jooby/internal/HeadContext.java @@ -190,6 +190,11 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { return this; } + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + return this; + } + @Override public void close() {} } diff --git a/list.txt b/list.txt new file mode 100644 index 0000000000..bb31be4d70 --- /dev/null +++ b/list.txt @@ -0,0 +1,11 @@ +INFO [2026-01-07 18:40:39,944] [worker-91] JettySubscription read data started +INFO [2026-01-07 18:40:39,945] [worker-91] JettySubscription byte read: 00000000033a012a +INFO [2026-01-07 18:40:39,945] [worker-91] GrpcRequestBridge deframe 3a012a +INFO [2026-01-07 18:40:39,946] [worker-91] GrpcRequestBridge asking for more request(1) +INFO [2026-01-07 18:40:39,984] [grpc-default-executor-1] UnifiedGrpcBridge onNext Send 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 +INFO [2026-01-07 18:40:44,114] [grpc-default-executor-0] UnifiedGrpcBridge error io.grpc.StatusRuntimeException: UNAVAILABLE: Channel shutdownNow invoked + +INFO [2026-01-07 18:40:44,114] [Thread-0] GrpcServer Stopped GrpcServer +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription read data started +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription last reach +INFO [2026-01-07 18:40:44,117] [worker-88] JettySubscription handle complete diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 7bc3f91a4b..cef12bd69c 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -115,6 +115,11 @@ jooby-graphql ${project.version} + + io.jooby + jooby-grpc + ${project.version} + io.jooby jooby-gson diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml new file mode 100644 index 0000000000..a9427cf56a --- /dev/null +++ b/modules/jooby-grpc/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.0.14-SNAPSHOT + + jooby-grpc + jooby-grpc + + + + io.jooby + jooby + ${jooby.version} + + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-inprocess + ${grpc.version} + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.mockito + mockito-core + test + + + diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java new file mode 100644 index 0000000000..d05e20cef0 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcDeframer.java @@ -0,0 +1,53 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.nio.ByteBuffer; +import java.util.function.Consumer; + +public class GrpcDeframer { + private enum State { + HEADER, + PAYLOAD + } + + private State state = State.HEADER; + private final ByteBuffer headerBuffer = ByteBuffer.allocate(5); + private ByteBuffer payloadBuffer; + + public void process(byte[] data, Consumer onMessage) { + ByteBuffer input = ByteBuffer.wrap(data); + while (input.hasRemaining()) { + if (state == State.HEADER) { + while (headerBuffer.hasRemaining() && input.hasRemaining()) { + headerBuffer.put(input.get()); + } + if (!headerBuffer.hasRemaining()) { + headerBuffer.flip(); + headerBuffer.get(); // skip compressed flag + int length = headerBuffer.getInt(); + if (length == 0) { + onMessage.accept(new byte[0]); + headerBuffer.clear(); + } else { + payloadBuffer = ByteBuffer.allocate(length); + state = State.PAYLOAD; + } + } + } else if (state == State.PAYLOAD) { + while (payloadBuffer.hasRemaining() && input.hasRemaining()) { + payloadBuffer.put(input.get()); + } + if (!payloadBuffer.hasRemaining()) { + onMessage.accept(payloadBuffer.array()); + headerBuffer.clear(); + payloadBuffer = null; + state = State.HEADER; + } + } + } + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java new file mode 100644 index 0000000000..9073718b5a --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcHandler.java @@ -0,0 +1,229 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.CompletableFuture; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.*; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.StreamObserver; +import io.jooby.Context; +import io.jooby.Route; +import io.jooby.Sender; +import io.jooby.exception.BadRequestException; + +public class GrpcHandler implements Route.Handler { + // Minimal Marshaller to pass raw bytes through the bridge + private static class RawMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + try { + return stream.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private final ManagedChannel channel; + private final GrpcMethodRegistry methodRegistry; + + public GrpcHandler(ManagedChannel channel, GrpcMethodRegistry methodRegistry) { + this.channel = channel; + this.methodRegistry = methodRegistry; + } + + @Override + public Object apply(@NonNull Context ctx) throws Exception { + // Detect gRPC content type + String contentType = ctx.header("Content-Type").value(); + if (contentType == null || !contentType.contains("application/grpc")) { + throw new BadRequestException(String.format("Content-Type: %s not supported", contentType)); + } + + // Setup gRPC response headers + ctx.setResponseType(contentType); + + var path = ctx.path("service").value() + "/" + ctx.path("method").value(); + var descriptor = methodRegistry.get(path); + if (descriptor == null) { + return ctx.setResponseCode(404).send("Service not found"); + } + var method = + MethodDescriptor.newBuilder() + .setType(descriptor.getType()) + .setFullMethodName(descriptor.getFullMethodName()) + .setRequestMarshaller(new RawMarshaller()) + .setResponseMarshaller(new RawMarshaller()) + .build(); + + // 1. Initiate the internal gRPC call + // We use byte[] marshallers to keep it raw and fast + CompletableFuture future = + switch (method.getType()) { + case UNARY -> handleUnary(ctx, method); + case BIDI_STREAMING -> handleBidi(ctx, method); + // case SERVER_STREAMING -> handleServerStreaming(ctx, method); + // case CLIENT_STREAMING -> handleClientStreaming(ctx, method); + default -> + CompletableFuture.failedFuture(new UnsupportedOperationException("Unknown type")); + }; + return future; + } + + private CompletableFuture handleBidi(Context ctx, MethodDescriptor method) + throws IOException { + CompletableFuture future = new CompletableFuture<>(); + + var sender = ctx.responseSender(false); + StreamObserver requestObserver = + ClientCalls.asyncBidiStreamingCall( + channel.newCall(method, CallOptions.DEFAULT), + new StreamObserver() { + @Override + public void onNext(byte[] value) { + ctx.setResponseTrailer("grpc-status", "0"); + sender.write( + addGrpcHeader(value), + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) {} + }); + } + + @Override + public void onError(Throwable t) { + ctx.setResponseTrailer( + "grpc-status", Integer.toString(Status.fromThrowable(t).getCode().value())); + future.completeExceptionally(t); + } + + @Override + public void onCompleted() { + sender.close(); + future.complete(ctx); + } + }); + + var is = ctx.body().stream(); + byte[] frame; + while ((frame = readOneGrpcFrame(is)) != null) { + requestObserver.onNext(frame); + } + requestObserver.onCompleted(); + return future; + } + + private CompletableFuture handleUnary( + Context ctx, MethodDescriptor method) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + byte[] requestPayload = readOneGrpcFrame(ctx.body().stream()); + ClientCalls.asyncUnaryCall( + channel.newCall(method, CallOptions.DEFAULT), + requestPayload, + new StreamObserver() { + @Override + public void onNext(byte[] value) { + ctx.setResponseTrailer("grpc-status", "0"); + ctx.send(addGrpcHeader(value)); + } + + @Override + public void onError(Throwable t) { + ctx.setResponseTrailer( + "grpc-status", Integer.toString(Status.fromThrowable(t).getCode().value())); + future.completeExceptionally(t); + } + + @Override + public void onCompleted() { + future.complete(ctx); + } + }); + return future; + } + + /** + * Prepends the 5-byte gRPC header to the payload. * @param payload The raw binary message from + * the internal gRPC service. + * + * @return A new byte array containing [Flag][Length][Payload]. + */ + private byte[] addGrpcHeader(byte[] payload) { + int length = payload.length; + byte[] framedMessage = new byte[5 + length]; + + // 1. Compression Flag (0 = none) + // We pass 0 because our bridge usually handles raw uncompressed bytes + // or handles already-compressed payloads transparently. + framedMessage[0] = 0; + + // 2. Encode Length as 4-byte Big Endian integer + framedMessage[1] = (byte) ((length >> 24) & 0xFF); + framedMessage[2] = (byte) ((length >> 16) & 0xFF); + framedMessage[3] = (byte) ((length >> 8) & 0xFF); + framedMessage[4] = (byte) (length & 0xFF); + + // 3. Copy the actual payload after the 5-byte header + System.arraycopy(payload, 0, framedMessage, 5, length); + + return framedMessage; + } + + /** + * Reads exactly one gRPC frame from the input stream. * @param is The Jooby/Servlet input stream + * + * @return The raw protobuf payload (without the 5-byte header), or null if the end of the stream + * is reached. + */ + private byte[] readOneGrpcFrame(InputStream is) throws IOException { + // 1. Read the 5-byte gRPC header + // Byte 0: Compression flag + // Bytes 1-4: Message length (Big Endian) + byte[] header = new byte[5]; + int bytesRead = is.readNBytes(header, 0, 5); + + if (bytesRead == 0) { + return null; // Normal End of Stream (Half-close) + } + + if (bytesRead < 5) { + throw new IOException("Incomplete gRPC header. Expected 5 bytes, got " + bytesRead); + } + + // 2. Extract the length (Big Endian) + // We mask with 0xFF to treat bytes as unsigned + int length = + ((header[1] & 0xFF) << 24) + | ((header[2] & 0xFF) << 16) + | ((header[3] & 0xFF) << 8) + | ((header[4] & 0xFF)); + + if (length < 0) { + throw new IOException("Invalid gRPC frame length: " + length); + } + + // 3. Read exactly 'length' bytes for the payload + byte[] payload = is.readNBytes(length); + + if (payload.length < length) { + throw new IOException( + "Incomplete gRPC payload. Expected " + length + " bytes, got " + payload.length); + } + + return payload; + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java new file mode 100644 index 0000000000..2d7e39ba12 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcMethodRegistry.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.HashMap; +import java.util.Map; + +import io.grpc.BindableService; +import io.grpc.MethodDescriptor; + +public class GrpcMethodRegistry { + private final Map> registry = new HashMap<>(); + + public void registerService(BindableService service) { + var serviceDef = service.bindService(); + for (var methodDef : serviceDef.getMethods()) { + MethodDescriptor descriptor = methodDef.getMethodDescriptor(); + registry.put(descriptor.getFullMethodName(), descriptor); + } + } + + public MethodDescriptor get(String fullMethodName) { + return registry.get(fullMethodName); + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java new file mode 100644 index 0000000000..a2b26e9206 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcModule.java @@ -0,0 +1,54 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.List; +import java.util.function.Function; + +import io.grpc.*; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.jooby.*; + +public class GrpcModule implements Extension { + private final List services; + private final GrpcMethodRegistry methodRegistry = new GrpcMethodRegistry(); + private final String serverName = "jooby-internal-" + System.nanoTime(); + private Server grpcServer; + + public GrpcModule(BindableService... services) { + this.services = List.of(services); + } + + @Override + public void install(Jooby app) throws Exception { + // 1. Start an In-Process gRPC Server (Memory only) + var builder = InProcessServerBuilder.forName(serverName).directExecutor(); + for (BindableService service : services) { + builder.addService(service); + methodRegistry.registerService(service); + } + + this.grpcServer = builder.build().start(); + + // 2. Create the Channel to talk to it + var channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + + var handler = new UnifiedGrpcBridge(channel, methodRegistry); + app.getServices().put(ServiceKey.key(Function.class, "gRPC"), handler); + // 3. Register the bridge route + // gRPC paths are always /{package.Service}/{Method} + // app.post("/{service}/{method}", ReactiveSupport.concurrent(new GrpcHandler(channel, + // methodRegistry))); + + app.onStop( + () -> { + channel.shutdownNow(); + grpcServer.shutdownNow(); + }); + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java new file mode 100644 index 0000000000..074a147a81 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.util.HexFormat; +import java.util.concurrent.Flow.Subscriber; +import java.util.concurrent.Flow.Subscription; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.StreamObserver; + +/** + * Wraps a gRPC StreamObserver as an internal field and feeds it data from a standard Java + * Flow.Publisher. + */ +public class GrpcRequestBridge implements Subscriber { + + private final Logger log = LoggerFactory.getLogger(getClass()); + private final ClientCallStreamObserver internalObserver; + private final GrpcDeframer deframer; + private String path; + private Subscription subscription; + private AtomicBoolean completed = new AtomicBoolean(false); + + public GrpcRequestBridge(String path, StreamObserver internalObserver) { + this.path = path; + this.deframer = new GrpcDeframer(); + this.internalObserver = (ClientCallStreamObserver) internalObserver; + } + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + // Demand the first chunk. In a pro bridge, you might demand 1 + // and only demand more when the gRPC observer is ready. + subscription.request(1); + } + + public void onNext(byte[] item) { + try { + + deframer.process( + item, + msg -> { + log.info("deframe {}", HexFormat.of().formatHex(msg)); + internalObserver.onNext(msg); + }); + + log.info("asking for more request(1)"); + internalObserver.request(1); + } catch (Throwable t) { + subscription.cancel(); + internalObserver.onError(t); + } + } + + private boolean isReflectionPath(String path) { + return path.contains("ServerReflectionInfo"); + } + + @Override + public void onError(Throwable throwable) { + internalObserver.onError(throwable); + } + + @Override + public void onComplete() { + if (completed.compareAndSet(false, true)) { + internalObserver.onCompleted(); + } + } +} diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java new file mode 100644 index 0000000000..9a5b4178d0 --- /dev/null +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java @@ -0,0 +1,253 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.grpc; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HexFormat; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.grpc.*; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; +import io.jooby.Context; +import io.jooby.Sender; + +public class UnifiedGrpcBridge implements Function> { + // Minimal Marshaller to pass raw bytes through the bridge + private static class RawMarshaller implements MethodDescriptor.Marshaller { + @Override + public InputStream stream(byte[] value) { + return new ByteArrayInputStream(value); + } + + @Override + public byte[] parse(InputStream stream) { + try { + return stream.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + private final Logger log = LoggerFactory.getLogger(getClass()); + private final ManagedChannel channel; + private final GrpcMethodRegistry methodRegistry; + + public UnifiedGrpcBridge(ManagedChannel channel, GrpcMethodRegistry methodRegistry) { + this.channel = channel; + this.methodRegistry = methodRegistry; + } + + @Override + public Flow.Subscriber apply(Context context) { + return new GrpcRequestBridge(context.getRequestPath(), startCall(context)); + } + + /** + * Unified entry point to start an internal call. Handles Unary, Bidi, and Streaming via a single + * StreamObserver interface. + */ + public StreamObserver startCall(Context ctx) { + // Setup gRPC response headers + ctx.setResponseType("application/grpc"); + + var path = + ctx.getRequestPath(); // ctx.path("service").value() + "/" + ctx.path("method").value(); + var descriptor = methodRegistry.get(path.substring(1)); + if (descriptor == null) { + terminateWithStatus( + null, + Status.UNIMPLEMENTED.withDescription("Method not found in bridge registry: " + path)); + return null; + } + + var method = + MethodDescriptor.newBuilder() + .setType(descriptor.getType()) + .setFullMethodName(descriptor.getFullMethodName()) + .setRequestMarshaller(new RawMarshaller()) + .setResponseMarshaller(new RawMarshaller()) + .build(); + + // 2. Prepare Call Options (Propagation of timeouts/metadata could happen here) + CallOptions callOptions = CallOptions.DEFAULT; + ClientCall call = channel.newCall(method, callOptions); + + ClientResponseObserver responseObserver; + log.info("method type: {}", method.getType()); + if (method.getType() == MethodDescriptor.MethodType.UNARY) { + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + ctx.setResponseTrailer("grpc-status", "0"); + byte[] framed = addGrpcHeader(value); + ctx.send(framed); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(ctx, Status.fromThrowable(t)); + } + } + + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(ctx, Status.OK); + } + } + }; + } else { + var sender = ctx.responseSender(false); + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + sender.setTrailer("grpc-status", "0"); + byte[] framed = addGrpcHeader(value); + sender.write( + framed, + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { + log.info("onNext Sent {}", ctx); + if (cause != null) { + onError(cause); + } + } + }); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(ctx, Status.fromThrowable(t)); + } + } + + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(ctx, Status.OK); + } + } + }; + } + + // 4. Map gRPC Method Type to the correct ClientCalls utility + return switch (method.getType()) { + case UNARY -> wrapUnary(call, responseObserver); + case BIDI_STREAMING, CLIENT_STREAMING -> + ClientCalls.asyncBidiStreamingCall(call, responseObserver); + case SERVER_STREAMING -> wrapServerStreaming(call, responseObserver); + default -> { + terminateWithStatus(ctx, Status.INTERNAL.withDescription("Unsupported method type")); + yield null; + } + }; + } + + private boolean isReflectionPath(String path) { + return path.contains("ServerReflectionInfo"); + } + + private StreamObserver wrapUnary( + ClientCall call, StreamObserver responseObserver) { + // Unary expects a single message. We use the Bidi utility but logic ensures 1:1. + return ClientCalls.asyncBidiStreamingCall(call, responseObserver); + } + + private StreamObserver wrapServerStreaming( + ClientCall call, StreamObserver responseObserver) { + // Server streaming takes 1 request and returns an observer for the result stream + return new StreamObserver<>() { + private boolean sent = false; + + @Override + public void onNext(byte[] value) { + if (!sent) { + ClientCalls.asyncServerStreamingCall(call, value, responseObserver); + sent = true; + } + } + + @Override + public void onError(Throwable t) { + responseObserver.onError(t); + } + + @Override + public void onCompleted() { + /* Server side handles completion */ + } + }; + } + + /** + * Professional Status Termination. Sets gRPC trailers and closes the Jetty response correctly. + */ + private void terminateWithStatus(Context ctx, Status status) { + ctx.setResponseTrailer("grpc-status", String.valueOf(status.getCode().value())); + if (status.getDescription() != null) { + ctx.setResponseTrailer("grpc-message", status.getDescription()); + } + ctx.send(""); + } + + private byte[] addGrpcHeader(byte[] payload) { + int len = payload.length; + byte[] framed = new byte[5 + len]; + framed[0] = 0; // Uncompressed + framed[1] = (byte) (len >> 24); + framed[2] = (byte) (len >> 16); + framed[3] = (byte) (len >> 8); + framed[4] = (byte) len; + System.arraycopy(payload, 0, framed, 5, len); + return framed; + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java index f9d0d1d81b..ec68ae6c34 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyContext.java @@ -81,6 +81,7 @@ static DeleteFileTask of(FileDownload file) { private final long maxRequestSize; Request request; Response response; + HttpFields.Mutable trailers; private QueryString query; private Formdata formdata; @@ -439,6 +440,16 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HttpFields.build(); + response.setTrailersSupplier(() -> trailers); + } + trailers.put(name, value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { response.getHeaders().remove(name); @@ -480,11 +491,13 @@ public long getResponseLength() { return this; } - @NonNull @Override - public Sender responseSender() { - responseStarted = true; - ifSetChunked(); - return new JettySender(this, response); + @Override + public Sender responseSender(boolean startResponse) { + responseStarted = startResponse; + if (startResponse) { + ifSetChunked(); + } + return new JettySender(this); } @NonNull @Override diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java new file mode 100644 index 0000000000..ded27ef280 --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyGrpcHandler.java @@ -0,0 +1,39 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.util.concurrent.Flow; +import java.util.function.Function; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +import io.jooby.Context; + +/** A professional Jetty Handler that bridges HTTP/2 streams to a gRPC Subscriber. */ +public class JettyGrpcHandler extends Handler.Abstract { + + private final Function> subscriberFactory; + private final Context ctx; + + public JettyGrpcHandler( + io.jooby.Context ctx, Function> subscriberFactory) { + this.ctx = ctx; + this.subscriberFactory = subscriberFactory; + } + + @Override + public boolean handle(Request request, Response response, Callback callback) { + Flow.Subscriber subscriber = subscriberFactory.apply(ctx); + + JettyRequestPublisher publisher = new JettyRequestPublisher(request); + publisher.subscribe(subscriber); + + return true; + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java index c1aeba9972..ebe378783b 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyHandler.java @@ -5,12 +5,16 @@ */ package io.jooby.internal.jetty; +import java.util.function.Function; + +import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.util.Callback; import io.jooby.Router; +import io.jooby.ServiceKey; import io.jooby.internal.jetty.http2.JettyHeaders; public class JettyHandler extends Handler.Abstract { @@ -43,7 +47,13 @@ public boolean handle(Request request, Response response, Callback callback) { var context = new JettyContext( getInvocationType(), request, response, callback, router, bufferSize, maxRequestSize); - router.match(context).execute(context); + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !request.getHeaders().contains(HttpHeader.CONTENT_TYPE, "application/grpc")) { + router.match(context).execute(context); + } else { + var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); + new JettyGrpcHandler(context, subscriber).handle(request, response, callback); + } } catch (JettyStopPipeline ignored) { // handled already, } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java new file mode 100644 index 0000000000..6cffb42bde --- /dev/null +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java @@ -0,0 +1,137 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jetty; + +import java.util.HexFormat; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class JettyRequestPublisher implements Flow.Publisher { + private final Logger log = LoggerFactory.getLogger(getClass()); + private final Request request; + + public JettyRequestPublisher(Request request) { + this.request = request; + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + var subscription = new JettySubscription(request, subscriber); + subscriber.onSubscribe(subscription); + } +} + +/** + * Professional Jetty 12 Core Subscription. Uses the demand-callback pattern to satisfy gRPC stream + * requirements. + */ +class JettySubscription implements Flow.Subscription { + + private static final Logger log = LoggerFactory.getLogger(JettySubscription.class); + private final Request request; + private final Flow.Subscriber subscriber; + + private final AtomicLong demand = new AtomicLong(); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private final AtomicBoolean completed = new AtomicBoolean(false); + + public JettySubscription(Request request, Flow.Subscriber subscriber) { + this.request = request; + this.subscriber = subscriber; + } + + private final AtomicBoolean demandPending = new AtomicBoolean(false); + + private void process(String call) { + log.info("{}- start reading request", call); + try { + var demandMore = false; + while (true) { + // 2. Check for data. We MUST read if the deframer is "hungry," + // even if application demand is 0. + var chunk = request.read(); + + if (chunk == null) { + log.info("{}- demanding more", call); + request.demand( + () -> { + process(call + ".demand"); + }); + return; + } + + if (Content.Chunk.isFailure(chunk)) { + log.info("{}- bad chunk: {}", call, chunk); + boolean fatal = chunk.isLast(); + if (fatal) { + handleComplete(); + return; + } else { + handleError(chunk.getFailure()); + return; + } + } + var buffer = chunk.getByteBuffer(); + + if (buffer != null && buffer.hasRemaining()) { + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + + log.info("{}- byte read: {}", call, HexFormat.of().formatHex(bytes)); + // demand.decrementAndGet(); + subscriber.onNext(bytes); + } + chunk.release(); + + if (chunk.isLast()) { + log.info("{}- last reach", call); + // Even if we have 0 demand, we must finish the stream + handleComplete(); + return; + } + } + } catch (Throwable t) { + handleError(t); + } finally { + log.info("{}- finish reading request", call); + } + } + + private void handleComplete() { + if (completed.compareAndSet(false, true) && !cancelled.get()) { + log.info("handle complete"); + subscriber.onComplete(); + } + } + + private void handleError(Throwable t) { + if (completed.compareAndSet(false, true) && !cancelled.get()) { + log.info("handle error", t); + subscriber.onError(t); + } + } + + long c = 0; + + @Override + public void request(long n) { + if (n <= 0) return; + log.info("init request({})", n); + c += n; + process(Long.toString(c)); + } + + @Override + public void cancel() { + cancelled.set(true); + } +} diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java index c8f235041f..fa55675039 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java @@ -5,11 +5,11 @@ */ package io.jooby.internal.jetty; -import static io.jooby.internal.jetty.JettyCallbacks.fromOutput; - import java.nio.ByteBuffer; +import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -18,24 +18,63 @@ public class JettySender implements Sender { private final JettyContext ctx; private final Response response; + private HttpFields.Mutable trailers; + private ByteBuffer pending; + private org.eclipse.jetty.util.Callback pendingCallback; - public JettySender(JettyContext ctx, Response response) { + public JettySender(JettyContext ctx) { this.ctx = ctx; - this.response = response; + this.response = ctx.response; + this.trailers = ctx.trailers; } @Override - public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - response.write(false, ByteBuffer.wrap(data), toJettyCallback(ctx, callback)); + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HttpFields.build(); + } + trailers.put(name, value); return this; } + @Override + public Sender write(@NonNull byte[] data, @NonNull Callback callback) { + return write(ByteBuffer.wrap(data), callback); + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - fromOutput(response, toJettyCallback(ctx, callback), output).send(false); + return write(output.asByteBuffer(), callback); + } + + public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { + response.write(false, buffer, toJettyCallback(ctx, callback)); + // if (trailers == null) { + // response.write(false, buffer, toJettyCallback(ctx, callback)); + // } else { + // if (pending != null) { + // response.write(false, pending, pendingCallback); + // } + // pending = buffer; + // pendingCallback = toJettyCallback(ctx, callback); + // } return this; } + @Override + public void close() { + if (trailers != null) { + response.setTrailersSupplier(() -> trailers); + response.write(true, null, ctx); + } + // if (pending != null) { + // response.setTrailersSupplier(() -> trailers); + // response.write(true, pending, ctx); + // } else { + // response.write(true, null, ctx); + // } + } + private static org.eclipse.jetty.util.Callback toJettyCallback( JettyContext ctx, Callback callback) { return new org.eclipse.jetty.util.Callback() { @@ -51,9 +90,4 @@ public void failed(Throwable x) { } }; } - - @Override - public void close() { - response.write(false, null, ctx); - } } diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java index ab3b8d50cc..1a1d03d459 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/http2/JettyHttp2Configurer.java @@ -5,8 +5,6 @@ */ package io.jooby.internal.jetty.http2; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; @@ -25,13 +23,23 @@ public class JettyHttp2Configurer { public List configure(HttpConfiguration input) { if (input.getCustomizer(SecureRequestCustomizer.class) != null) { ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(H2, H2_17, HTTP_1_1); - alpn.setDefaultProtocol(HTTP_1_1); + alpn.setDefaultProtocol(H2); - HTTP2ServerConnectionFactory https2 = new HTTP2ServerConnectionFactory(input); + HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(input); + h2.setInitialStreamRecvWindow(1024 * 1024); + h2.setInitialSessionRecvWindow(10 * 1024 * 1024); - return Arrays.asList(alpn, https2); + // FIX: Set Max Concurrent Streams higher if you have many bidi clients + h2.setMaxConcurrentStreams(1000); + return List.of(alpn, h2); } else { - return Collections.singletonList(new HTTP2CServerConnectionFactory(input)); + var h2c = new HTTP2CServerConnectionFactory(input); + h2c.setInitialStreamRecvWindow(1024 * 1024); + h2c.setInitialSessionRecvWindow(10 * 1024 * 1024); + + // FIX: Set Max Concurrent Streams higher if you have many bidi clients + h2c.setMaxConcurrentStreams(1000); + return List.of(h2c); } } } diff --git a/modules/jooby-jetty/src/main/java/module-info.java b/modules/jooby-jetty/src/main/java/module-info.java index e10ff88cd1..8b1a6e495b 100644 --- a/modules/jooby-jetty/src/main/java/module-info.java +++ b/modules/jooby-jetty/src/main/java/module-info.java @@ -18,6 +18,7 @@ requires org.eclipse.jetty.http2.server; requires org.eclipse.jetty.websocket.server; requires java.desktop; + requires org.eclipse.jetty.http; provides Server with JettyServer; diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java new file mode 100644 index 0000000000..e3ec01b05c --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyByteBufBody.java @@ -0,0 +1,97 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.io.InputStream; +import java.lang.reflect.Type; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import io.jooby.Body; +import io.jooby.Context; +import io.jooby.MediaType; +import io.jooby.value.Value; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufUtil; + +public class NettyByteBufBody implements Body { + private final Context ctx; + private final ByteBuf data; + private final long length; + + public NettyByteBufBody(Context ctx, ByteBuf data) { + this.ctx = ctx; + this.data = data; + this.length = data.readableBytes(); + } + + @Override + public boolean isInMemory() { + return true; + } + + @Override + public long getSize() { + return length; + } + + @Override + public InputStream stream() { + return new ByteBufInputStream(data); + } + + @Override + public Value get(@NonNull String name) { + return Value.missing(ctx.getValueFactory(), name); + } + + @Override + public Value getOrDefault(@NonNull String name, @NonNull String defaultValue) { + return Value.value(ctx.getValueFactory(), name, defaultValue); + } + + @Override + public ReadableByteChannel channel() { + return Channels.newChannel(stream()); + } + + @Override + public byte[] bytes() { + return ByteBufUtil.getBytes(data); + } + + @NonNull @Override + public String value() { + return value(StandardCharsets.UTF_8); + } + + @Override + public String name() { + return "body"; + } + + @NonNull @Override + public T to(@NonNull Type type) { + return ctx.decode(type, ctx.getRequestType(MediaType.text)); + } + + @Nullable @Override + public T toNullable(@NonNull Type type) { + return ctx.decode(type, ctx.getRequestType(MediaType.text)); + } + + @Override + public Map> toMultimap() { + return Collections.emptyMap(); + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index 0f1c34d959..f0334184a7 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -118,6 +118,7 @@ public void operationComplete(ChannelFuture future) { private static final String STREAM_ID = "x-http2-stream-id"; private String streamId; + HeadersMultiMap trailers; HeadersMultiMap setHeaders = HEADERS.newHeaders(); private int bufferSize; InterfaceHttpPostRequestDecoder decoder; @@ -375,7 +376,9 @@ public Body body() { if (decoder != null && decoder.hasNext()) { return new NettyBody(this, (HttpData) decoder.next(), HttpUtil.getContentLength(req, -1L)); } - return Body.empty(this); + return (req instanceof DefaultFullHttpRequest full) + ? new NettyByteBufBody(this, full.content()) + : Body.empty(this); } @Override @@ -487,6 +490,15 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @NonNull @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HEADERS.newHeaders(); + } + trailers.set(name, value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { setHeaders.remove(name); @@ -567,9 +579,11 @@ public PrintWriter responseWriter(MediaType type) { new NettyWriter(newOutputStream(), ofNullable(type.getCharset()).orElse(UTF_8))); } - @NonNull @Override - public Sender responseSender() { - prepareChunked(); + @Override + public Sender responseSender(boolean startResponse) { + if (startResponse) { + prepareChunked(); + } ctx.write(new DefaultHttpResponse(HTTP_1_1, status, setHeaders)); return new NettySender(this); } @@ -623,7 +637,9 @@ Context send(@NonNull ByteBuf data, CharSequence contentLength) { try { responseStarted = true; setHeaders.set(CONTENT_LENGTH, contentLength); - var response = new DefaultFullHttpResponse(HTTP_1_1, status, data, setHeaders, NO_TRAILING); + var response = + new DefaultFullHttpResponse( + HTTP_1_1, status, data, setHeaders, trailers == null ? NO_TRAILING : trailers); connection.writeMessage(response, promise()); return this; } finally { diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java index 327d3416ca..fc357b81de 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyHandler.java @@ -84,9 +84,19 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) { // possibly body: long contentLength = contentLength(req); if (contentLength > 0 || isTransferEncodingChunked(req)) { - context.httpDataFactory = new DefaultHttpDataFactory(bufferSize); - context.httpDataFactory.setBaseDir(app.getTmpdir().toString()); - context.setDecoder(newDecoder(req, context.httpDataFactory, maxFormFields)); + if (req.getClass() == DefaultFullHttpRequest.class) { + // HTTP2 aggregates all into a full http request. + if (((DefaultFullHttpRequest) req).content().readableBytes() > maxRequestSize) { + router.match(context).execute(context, Route.REQUEST_ENTITY_TOO_LARGE); + return; + } + // full body is here move + router.match(context).execute(context); + } else { + context.httpDataFactory = new DefaultHttpDataFactory(bufferSize); + context.httpDataFactory.setBaseDir(app.getTmpdir().toString()); + context.setDecoder(newDecoder(req, context.httpDataFactory, maxFormFields)); + } } else { // no body, move on router.match(context).execute(context); diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java index 1fb339fae0..47daa6bff0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettySender.java @@ -6,6 +6,7 @@ package io.jooby.internal.netty; import static io.jooby.internal.netty.NettyByteBufRef.byteBuf; +import static io.jooby.internal.netty.NettyHeadersFactory.HEADERS; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Sender; @@ -14,16 +15,28 @@ import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.LastHttpContent; public class NettySender implements Sender { private final NettyContext ctx; private final ChannelHandlerContext context; + private HeadersMultiMap trailers; public NettySender(NettyContext ctx) { this.ctx = ctx; this.context = ctx.ctx; + this.trailers = ctx.trailers; + } + + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = HEADERS.newHeaders(); + } + trailers.set(name, value); + return this; } @Override @@ -44,7 +57,13 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { @Override public void close() { - context.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT, ctx.promise()); + LastHttpContent lastContent; + if (trailers != null) { + lastContent = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER, trailers); + } else { + lastContent = LastHttpContent.EMPTY_LAST_CONTENT; + } + context.writeAndFlush(lastContent, ctx.promise()); ctx.requestComplete(); } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java index 3e0de59faf..cfd84463d9 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java @@ -49,7 +49,7 @@ private Http2ConnectionHandler newHttp2Handler(int maxRequestSize, HttpScheme sc new InboundHttp2ToHttpAdapterBuilder(connection) .propagateSettings(false) .validateHttpHeaders(true) - .maxContentLength(maxRequestSize) + .maxContentLength(-1) .build(); return new HttpToHttp2ConnectionHandlerBuilder() diff --git a/modules/jooby-netty/src/main/java/module-info.java b/modules/jooby-netty/src/main/java/module-info.java index 5f9e7f16e4..0831e5136c 100644 --- a/modules/jooby-netty/src/main/java/module-info.java +++ b/modules/jooby-netty/src/main/java/module-info.java @@ -25,7 +25,6 @@ requires static io.netty.transport.classes.epoll; requires static io.netty.transport.classes.kqueue; requires static io.netty.transport.classes.io_uring; - requires java.desktop; provides Server with NettyServer; diff --git a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java index 66686d4189..e8ab24cf6a 100644 --- a/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java +++ b/modules/jooby-test/src/main/java/io/jooby/test/MockContext.java @@ -87,6 +87,8 @@ public class MockContext implements DefaultContext { private Map responseHeaders = new HashMap<>(); + private Map responseTrailers = new HashMap<>(); + private Map attributes = new HashMap<>(); private MockResponse response = new MockResponse(); @@ -497,6 +499,12 @@ public MockContext setResponseHeader(@NonNull String name, @NonNull String value return this; } + @Override + public MockContext setResponseTrailer(@NonNull String name, @NonNull String value) { + responseTrailers.put(name, value); + return this; + } + @Override public MockContext setResponseLength(long length) { response.setContentLength(length); @@ -557,8 +565,8 @@ public OutputStream responseStream() { } @Override - public Sender responseSender() { - responseStarted = true; + public Sender responseSender(boolean startResponse) { + responseStarted = startResponse; return new Sender() { @Override public Sender write(@NonNull byte[] data, @NonNull Callback callback) { @@ -567,6 +575,11 @@ public Sender write(@NonNull byte[] data, @NonNull Callback callback) { return this; } + @Override + public Sender setTrailer(@NonNull String name, @NonNull String value) { + return this; + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { response.setResult(output); diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java index 7eba730fce..02cfb85d4a 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java @@ -46,12 +46,14 @@ import io.undertow.server.RenegotiationRequiredException; import io.undertow.server.SSLSessionInfo; import io.undertow.server.handlers.form.FormData; +import io.undertow.server.protocol.http.HttpAttachments; import io.undertow.util.*; public class UndertowContext implements DefaultContext, IoCallback { private static final ByteBuffer EMPTY = ByteBuffer.wrap(new byte[0]); private Route route; HttpServerExchange exchange; + HeaderMap trailers; private Router router; private QueryString query; private Formdata formdata; @@ -331,6 +333,16 @@ public Context setResponseHeader(@NonNull String name, @NonNull String value) { return this; } + @Override + public Context setResponseTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = new HeaderMap(); + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, trailers); + } + trailers.put(HttpString.tryFromString(name), value); + return this; + } + @NonNull @Override public Context removeResponseHeader(@NonNull String name) { exchange.getResponseHeaders().remove(name); @@ -412,8 +424,8 @@ public OutputStream responseStream() { } @NonNull @Override - public io.jooby.Sender responseSender() { - return new UndertowSender(this, exchange); + public io.jooby.Sender responseSender(boolean startResponse) { + return new UndertowSender(this); } @NonNull @Override diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java new file mode 100644 index 0000000000..4700325fd0 --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowGrpcHandler.java @@ -0,0 +1,56 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.util.concurrent.Flow; +import java.util.function.Function; + +import io.jooby.Context; +import io.jooby.Router; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.Headers; + +public class UndertowGrpcHandler implements HttpHandler { + private final HttpHandler next; + private final Router router; + private final int bufferSize; + private final Function> subscriberFactory; + + public UndertowGrpcHandler( + HttpHandler next, + Router router, + int bufferSize, + Function> subscriberFactory) { + this.next = next; + this.router = router; + this.bufferSize = bufferSize; + this.subscriberFactory = subscriberFactory; + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + if (!exchange + .getRequestHeaders() + .get(Headers.CONTENT_TYPE) + .getFirst() + .contains("application/grpc")) { + next.handleRequest(exchange); + } else { + // Prevents Undertow from automatically closing/draining the request + // exchange.setPersistent(true); + + // 2. IMPORTANT: Dispatch to a worker thread so we don't block the IO thread + exchange.dispatch( + () -> { + // Ensure we don't trigger the default draining behavior + var context = new UndertowContext(exchange, router, bufferSize); + var subscriber = subscriberFactory.apply(context); + new UndertowRequestPublisher(exchange).subscribe(subscriber); + }); + } + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java index 9e33f66609..c05ded01d5 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.util.Optional; +import java.util.function.Function; import io.jooby.*; import io.undertow.io.Receiver; @@ -19,6 +20,7 @@ import io.undertow.util.HeaderMap; import io.undertow.util.Headers; import io.undertow.util.ParameterLimitException; +import io.undertow.util.Protocols; public class UndertowHandler implements HttpHandler { private final long maxRequestSize; @@ -54,9 +56,20 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } else { // possibly HTTP body HeaderMap headers = exchange.getRequestHeaders(); + if (exchange + .getRequestHeaders() + .get(Headers.CONTENT_TYPE) + .getFirst() + .contains("application/grpc")) { + // var route = router.match(context); + // context.setRoute(route.route()); + var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); + new UndertowGrpcHandler(this, router, bufferSize, subscriber).handleRequest(exchange); + return; + } long len = parseLen(headers.getFirst(Headers.CONTENT_LENGTH)); String chunked = headers.getFirst(Headers.TRANSFER_ENCODING); - if (len > 0 || chunked != null) { + if (len > 0 || chunked != null || exchange.getProtocol().equals(Protocols.HTTP_2_0)) { if (len > maxRequestSize) { Router.Match route = router.match(context); if (route.matches()) { @@ -88,7 +101,11 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if (len > 0 && len <= bufferSize) { receiver.receiveFullBytes(reader); } else { - receiver.receivePartialBytes(reader); + if (exchange.getProtocol().equals(Protocols.HTTP_2_0)) { + receiver.receiveFullBytes(reader); + } else { + receiver.receivePartialBytes(reader); + } } } else { try { diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java deleted file mode 100644 index 0a2222f6cc..0000000000 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowOutputCallback.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.undertow; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.Iterator; - -import io.jooby.output.Output; -import io.undertow.io.IoCallback; -import io.undertow.io.Sender; -import io.undertow.server.HttpServerExchange; - -public class UndertowOutputCallback implements IoCallback { - - private Iterator iterator; - private IoCallback callback; - - public UndertowOutputCallback(Output buffer, IoCallback callback) { - this.iterator = buffer.iterator(); - this.callback = callback; - } - - public void send(HttpServerExchange exchange) { - exchange.getResponseSender().send(iterator.next(), this); - } - - @Override - public void onComplete(HttpServerExchange exchange, Sender sender) { - if (iterator.hasNext()) { - sender.send(iterator.next(), this); - } else { - callback.onComplete(exchange, sender); - } - } - - @Override - public void onException(HttpServerExchange exchange, Sender sender, IOException exception) { - callback.onException(exchange, sender, exception); - } -} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java new file mode 100644 index 0000000000..fbd6aa4729 --- /dev/null +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java @@ -0,0 +1,88 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.undertow; + +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import io.undertow.server.HttpServerExchange; + +public class UndertowRequestPublisher implements Flow.Publisher { + private final HttpServerExchange exchange; + + public UndertowRequestPublisher(HttpServerExchange exchange) { + this.exchange = exchange; + } + + @Override + public void subscribe(Flow.Subscriber subscriber) { + // We use the Subscription to manage the state between Undertow and the Subscriber + UndertowReceiverSubscription sub = new UndertowReceiverSubscription(exchange, subscriber); + subscriber.onSubscribe(sub); + } +} + +class UndertowReceiverSubscription implements Flow.Subscription { + private final HttpServerExchange exchange; + private final Flow.Subscriber subscriber; + private final AtomicBoolean started = new AtomicBoolean(false); + private final AtomicLong demand = new AtomicLong(0); + private final AtomicBoolean readingStarted = new AtomicBoolean(false); + + public UndertowReceiverSubscription( + HttpServerExchange exchange, Flow.Subscriber subscriber) { + this.exchange = exchange; + this.subscriber = subscriber; + } + + @Override + public void request(long n) { + if (n <= 0) return; + + // Add to our demand counter + long prevDemand = demand.getAndAdd(n); + + // Case 1: First time starting the read + if (readingStarted.compareAndSet(false, true)) { + startReading(); + } + // Case 2: We were paused (demand was 0) and now have new demand + else if (prevDemand == 0) { + exchange.getRequestReceiver().resume(); + } + } + + private void startReading() { + exchange + .getRequestReceiver() + .receivePartialBytes( + (exch, message, last) -> { + if (message.length > 0) { + // Pass bytes to De-framer + subscriber.onNext(message); + } + // If we've exhausted the demand requested by the Bridge, pause Undertow + if (demand.decrementAndGet() == 0) { + exchange.getRequestReceiver().pause(); + } + // THE KEY FIX: + // 1. If 'last' is true, the stream is definitely over. + // 2. If 'isRequestComplete' is true, Undertow's internal state knows it's over. + if (last) { + subscriber.onComplete(); + } + }, + (exch, err) -> { + subscriber.onError(err); + }); + } + + @Override + public void cancel() { + exchange.getRequestReceiver().pause(); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java index a3844cfc6f..cc8fcac016 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java @@ -13,30 +13,64 @@ import io.jooby.output.Output; import io.undertow.io.IoCallback; import io.undertow.server.HttpServerExchange; +import io.undertow.server.protocol.http.HttpAttachments; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; public class UndertowSender implements Sender { private final UndertowContext ctx; private final HttpServerExchange exchange; + private HeaderMap trailers; - public UndertowSender(UndertowContext ctx, HttpServerExchange exchange) { + public UndertowSender(UndertowContext ctx) { this.ctx = ctx; - this.exchange = exchange; + this.exchange = ctx.exchange; + this.trailers = ctx.trailers; } @Override - public Sender write(@NonNull byte[] data, @NonNull Callback callback) { - exchange.getResponseSender().send(ByteBuffer.wrap(data), newIoCallback(ctx, callback)); + public Sender setTrailer(@NonNull String name, @NonNull String value) { + if (trailers == null) { + trailers = new HeaderMap(); + } + trailers.put(HttpString.tryFromString(name), value); return this; } + @Override + public Sender write(@NonNull byte[] data, @NonNull Callback callback) { + return write(ByteBuffer.wrap(data), callback); + } + @NonNull @Override public Sender write(@NonNull Output output, @NonNull Callback callback) { - new UndertowOutputCallback(output, newIoCallback(ctx, callback)).send(exchange); + return write(output.asByteBuffer(), callback); + } + + private Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { + exchange.getResponseSender().send(buffer, newIoCallback(ctx, callback)); return this; } @Override public void close() { + if (trailers != null) { + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, this.trailers); + exchange + .getResponseSender() + .send( + "", + new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} + + @Override + public void onException( + HttpServerExchange exchange, + io.undertow.io.Sender sender, + IOException exception) {} + }); + } ctx.destroy(null); } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java index f335a220ed..026c68ec74 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java @@ -154,7 +154,7 @@ public Server start(@NonNull Jooby... application) { builder.addHttpListener(options.getPort(), options.getHost()); } - // HTTP @ + // HTTP/2 builder.setServerOption(ENABLE_HTTP2, options.isHttp2() == Boolean.TRUE); var classLoader = this.applications.get(0).getClassLoader(); SSLContext sslContext = options.getSSLContext(classLoader); diff --git a/modules/pom.xml b/modules/pom.xml index 75f125079c..1e39ee0edc 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -62,6 +62,8 @@ jooby-thymeleaf jooby-camel + jooby-grpc + jooby-avaje-validator jooby-hibernate-validator diff --git a/pom.xml b/pom.xml index 8d87ce3931..d6e177fa61 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ 7.0.0 + 1.78.0 1.5.23 diff --git a/tests/pom.xml b/tests/pom.xml index 8cf0181a19..05c3dac7f0 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -111,6 +111,11 @@ jooby-guice ${jooby.version} + + io.jooby + jooby-grpc + ${jooby.version} + io.jooby jooby-pac4j @@ -169,6 +174,30 @@ kotlin-reflect + + io.grpc + grpc-services + ${grpc.version} + + + + io.grpc + grpc-servlet + ${grpc.version} + + + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + + io.grpc + grpc-okhttp + ${grpc.version} + + org.slf4j jcl-over-slf4j @@ -286,7 +315,33 @@ + + + kr.motd.maven + os-maven-plugin + 1.7.1 + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + + org.jetbrains.kotlin kotlin-maven-plugin diff --git a/tests/src/main/proto/chat.proto b/tests/src/main/proto/chat.proto new file mode 100644 index 0000000000..9f5f87d317 --- /dev/null +++ b/tests/src/main/proto/chat.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package test; + +option java_package = "com.example.grpc"; +option java_multiple_files = true; + +service ChatService { + // BiDi: Client sends a stream of messages, + // Server responds to each one individually. + rpc ChatStream (stream ChatMessage) returns (stream ChatMessage); +} + +message ChatMessage { + string user = 1; + string text = 2; +} diff --git a/tests/src/main/proto/hello.proto b/tests/src/main/proto/hello.proto new file mode 100644 index 0000000000..6efa27221e --- /dev/null +++ b/tests/src/main/proto/hello.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +option java_package = "com.example.grpc"; +option java_multiple_files = true; + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} diff --git a/tests/src/test/java/examples/grpc/ChatClient.java b/tests/src/test/java/examples/grpc/ChatClient.java new file mode 100644 index 0000000000..2078e0abf5 --- /dev/null +++ b/tests/src/test/java/examples/grpc/ChatClient.java @@ -0,0 +1,81 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.util.concurrent.CountDownLatch; + +import com.example.grpc.*; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; + +public class ChatClient { + public static void main(String[] args) throws InterruptedException { + // 1. Create a channel to your JOOBY server + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080) + .usePlaintext() // Assuming the bridge is HTTP/2 Cleartext + .build(); + + // 2. Create an ASYNC stub (BiDi requires the async stub) + ChatServiceGrpc.ChatServiceStub asyncStub = ChatServiceGrpc.newStub(channel); + + // This latch helps the main thread wait until the stream is fully finished + CountDownLatch latch = new CountDownLatch(3); + + // 3. Define the observer to handle responses coming BACK from the Bridge + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ChatMessage value) { + System.out.println( + "Received from Bridge: [" + value.getUser() + "] " + value.getText()); + latch.countDown(); + } + + @Override + public void onError(Throwable t) { + System.err.println("Bridge Error: " + t.getMessage()); + t.printStackTrace(); + latch.countDown(); + latch.countDown(); + latch.countDown(); + } + + @Override + public void onCompleted() { + System.out.println("Bridge closed the stream (Trailers received successfully)."); + latch.countDown(); + } + }; + + // 4. Start the call. Returns the observer we use to SEND messages TO the Bridge. + StreamObserver requestObserver = asyncStub.chatStream(responseObserver); + + try { + System.out.println("Connecting to Bridge and sending messages..."); + + // 5. Send a stream of messages over time + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 1").build()); + + Thread.sleep(1000); // Simulate network/processing delay + + requestObserver.onNext( + ChatMessage.newBuilder().setUser("JavaClient").setText("Ping 2").build()); + + // 6. Tell the Bridge we are done sending data + requestObserver.onCompleted(); + + } catch (Exception e) { + requestObserver.onError(e); + } + latch.await(); + + // Wait for the server to finish responding (timeout after 10 seconds) + channel.shutdown(); + } +} diff --git a/tests/src/test/java/examples/grpc/ChatServiceImpl.java b/tests/src/test/java/examples/grpc/ChatServiceImpl.java new file mode 100644 index 0000000000..280511763a --- /dev/null +++ b/tests/src/test/java/examples/grpc/ChatServiceImpl.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.example.grpc.ChatMessage; +import com.example.grpc.ChatServiceGrpc; +import io.grpc.stub.StreamObserver; + +public class ChatServiceImpl extends ChatServiceGrpc.ChatServiceImplBase { + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Override + public StreamObserver chatStream(StreamObserver responseObserver) { + return new StreamObserver() { + @Override + public void onNext(ChatMessage request) { + log.info("Got message: {}", request.getTextBytes()); + // Logic: Echo back the text with a prefix + ChatMessage response = + ChatMessage.newBuilder() + .setUser("Server") + .setText("Echo: " + request.getText()) + .build(); + + responseObserver.onNext(response); + } + + @Override + public void onError(Throwable t) { + System.err.println("Stream error: " + t.getMessage()); + } + + @Override + public void onCompleted() { + log.info("Chat closed"); + responseObserver.onCompleted(); + } + }; + } +} diff --git a/tests/src/test/java/examples/grpc/GreeterService.java b/tests/src/test/java/examples/grpc/GreeterService.java new file mode 100644 index 0000000000..fa6976499c --- /dev/null +++ b/tests/src/test/java/examples/grpc/GreeterService.java @@ -0,0 +1,20 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.stub.StreamObserver; + +public class GreeterService extends GreeterGrpc.GreeterImplBase { + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } +} diff --git a/tests/src/test/java/examples/grpc/GrpcClient.java b/tests/src/test/java/examples/grpc/GrpcClient.java new file mode 100644 index 0000000000..68d0b3c383 --- /dev/null +++ b/tests/src/test/java/examples/grpc/GrpcClient.java @@ -0,0 +1,26 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; + +public class GrpcClient { + public static void main(String[] args) { + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + + GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel); + + HelloReply response = stub.sayHello(HelloRequest.newBuilder().setName("Edgar").build()); + System.out.println(response.getMessage()); + + channel.shutdown(); + } +} diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java new file mode 100644 index 0000000000..82b678a880 --- /dev/null +++ b/tests/src/test/java/examples/grpc/GrpcServer.java @@ -0,0 +1,49 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.io.IOException; +import java.util.List; + +import io.grpc.protobuf.services.ProtoReflectionServiceV1; +import io.jooby.Jooby; +import io.jooby.ServerOptions; +import io.jooby.StartupSummary; +import io.jooby.grpc.GrpcModule; +import io.jooby.handler.AccessLogHandler; +import io.jooby.jetty.JettyServer; + +public class GrpcServer extends Jooby { + + { + setStartupSummary(List.of(StartupSummary.VERBOSE)); + use(new AccessLogHandler()); + install( + new GrpcModule( + new GreeterService(), new ChatServiceImpl(), ProtoReflectionServiceV1.newInstance())); + } + + public static void main(final String[] args) throws InterruptedException, IOException { + runApp( + args, + new JettyServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), + GrpcServer::new); + + // Build the server + // Server server = io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.forPort(9090) + // .addService(new GreeterService()) + // .addService(ProtoReflectionServiceV1.newInstance())// Your generated service + // implementation + // .build(); + // + // // Start the server + // server.start(); + // System.out.println("Server started on port 9090"); + // + // // Keep the main thread alive until the server is shut down + // server.awaitTermination(); + } +} diff --git a/tests/src/test/java/examples/grpc/ReflectionClient.java b/tests/src/test/java/examples/grpc/ReflectionClient.java new file mode 100644 index 0000000000..62219a7ebe --- /dev/null +++ b/tests/src/test/java/examples/grpc/ReflectionClient.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import java.util.concurrent.CountDownLatch; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.reflection.v1.ServerReflectionGrpc; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; + +public class ReflectionClient { + public static void main(String[] args) throws InterruptedException { + ManagedChannel channel = + ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + + var latch = new CountDownLatch(1); + ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); + + // 1. Prepare the response observer + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + // This is the part that returns the list of services + response + .getListServicesResponse() + .getServiceList() + .forEach( + s -> { + System.out.println("Service: " + s.getName()); + }); + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }; + + // 2. Open the bidirectional stream + StreamObserver requestObserver = + stub.serverReflectionInfo(responseObserver); + + // 3. Send the "List Services" request + requestObserver.onNext( + ServerReflectionRequest.newBuilder() + .setListServices("") // The trigger for 'list' + .setHost("localhost") + .build()); + + // 4. Signal half-close (Very important for reflection) + requestObserver.onCompleted(); + + latch.await(); + channel.shutdown(); + } +} diff --git a/tests/src/test/java/io/jooby/test/GrpcTest.java b/tests/src/test/java/io/jooby/test/GrpcTest.java new file mode 100644 index 0000000000..838c7c985e --- /dev/null +++ b/tests/src/test/java/io/jooby/test/GrpcTest.java @@ -0,0 +1,55 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.example.grpc.GreeterGrpc; +import com.example.grpc.HelloReply; +import com.example.grpc.HelloRequest; +import io.grpc.stub.StreamObserver; +import io.jooby.ServerOptions; +import io.jooby.grpc.GrpcModule; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import okhttp3.*; + +public class GrpcTest { + + public class GreeterService extends GreeterGrpc.GreeterImplBase { + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + } + + @ServerTest + public void http2(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define( + app -> { + app.install(new GrpcModule(new GreeterService())); + }) + .ready( + (http, https) -> { + https.get( + "/", + rsp -> { + assertEquals( + "{secure=true, protocol=HTTP/2.0, scheme=https}", rsp.body().string()); + }); + http.get( + "/", + rsp -> { + assertEquals( + "{secure=false, protocol=HTTP/1.1, scheme=http}", rsp.body().string()); + }); + }); + } +} diff --git a/tests/src/test/resources/logback.xml b/tests/src/test/resources/logback.xml index 442413b895..7427344940 100644 --- a/tests/src/test/resources/logback.xml +++ b/tests/src/test/resources/logback.xml @@ -2,7 +2,7 @@ - %-5p [%d{ISO8601}] [%thread] %msg %ex{0}%n + %-5p [%d{ISO8601}] [%thread] %logger{0} %msg %ex{0}%n From 6770ac510ed0c37ac9ed4231c256f1f1091459b1 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 15 Jan 2026 13:48:19 -0300 Subject: [PATCH 02/65] WIP: gRpc: works for jetty, almost work for undertow - grpcurl list doesn't work for undertow (sadly) --- dump.txt | 423 ------------------ .../java/io/jooby/grpc/GrpcRequestBridge.java | 1 + .../java/io/jooby/grpc/UnifiedGrpcBridge.java | 139 +++--- .../internal/jetty/JettyRequestPublisher.java | 1 - .../io/jooby/internal/jetty/JettySender.java | 41 +- .../internal/undertow/UndertowContext.java | 2 +- .../internal/undertow/UndertowHandler.java | 2 - .../undertow/UndertowRequestPublisher.java | 213 ++++++++- .../internal/undertow/UndertowSender.java | 38 +- .../test/java/examples/grpc/GrpcServer.java | 21 +- .../java/examples/grpc/JettyTrailerTest.java | 255 +++++++++++ .../java/examples/grpc/ReflectionClient.java | 80 ++-- 12 files changed, 623 insertions(+), 593 deletions(-) create mode 100644 tests/src/test/java/examples/grpc/JettyTrailerTest.java diff --git a/dump.txt b/dump.txt index 1fb5ca0768..e69de29bb2 100644 --- a/dump.txt +++ b/dump.txt @@ -1,423 +0,0 @@ -2026-01-02 19:02:09 -Full thread dump OpenJDK 64-Bit Server VM (24.0.2+12 mixed mode, sharing): - -Threads class SMR info: -_java_thread_list=0x0000600001982ce0, length=31, elements={ -0x000000014a00e200, 0x000000014a012000, 0x000000014a012800, 0x000000014a013000, -0x000000014a013800, 0x000000014a014000, 0x000000014a01c800, 0x000000014981d600, -0x000000014a122e00, 0x000000014a123600, 0x000000014a9e8800, 0x000000014aa54e00, -0x000000014aa55600, 0x000000014a9e9000, 0x00000001038d6000, 0x000000014c059c00, -0x000000014c057800, 0x000000014c058000, 0x000000012fb59200, 0x000000014a1f7000, -0x000000012fb5bc00, 0x000000013f810200, 0x000000013f819400, 0x000000012f1d5800, -0x000000012f1d6000, 0x000000013e998400, 0x000000014a1f7800, 0x000000014aa71e00, -0x000000012e03be00, 0x000000013e875000, 0x000000012e045800 -} - -"Reference Handler" #15 [29443] daemon prio=10 os_prio=31 cpu=0.26ms elapsed=24.14s tid=0x000000014a00e200 nid=29443 waiting on condition [0x000000016e5c2000] - java.lang.Thread.State: RUNNABLE - at java.lang.ref.Reference.waitForReferencePendingList(java.base@24.0.2/Native Method) - at java.lang.ref.Reference.processPendingReferences(java.base@24.0.2/Reference.java:246) - at java.lang.ref.Reference$ReferenceHandler.run(java.base@24.0.2/Reference.java:208) - - Locked ownable synchronizers: - - None - -"Finalizer" #16 [24835] daemon prio=8 os_prio=31 cpu=0.05ms elapsed=24.14s tid=0x000000014a012000 nid=24835 in Object.wait() [0x000000016e7ce000] - java.lang.Thread.State: WAITING (on object monitor) - at java.lang.Object.wait0(java.base@24.0.2/Native Method) - - waiting on <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.Object.wait(java.base@24.0.2/Object.java:389) - at java.lang.Object.wait(java.base@24.0.2/Object.java:351) - at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:138) - at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:229) - - locked <0x000000052b00cd30> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.ref.Finalizer$FinalizerThread.run(java.base@24.0.2/Finalizer.java:165) - - Locked ownable synchronizers: - - None - -"Signal Dispatcher" #17 [29187] daemon prio=9 os_prio=31 cpu=0.12ms elapsed=24.14s tid=0x000000014a012800 nid=29187 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"Service Thread" #18 [25603] daemon prio=9 os_prio=31 cpu=0.84ms elapsed=24.14s tid=0x000000014a013000 nid=25603 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"Monitor Deflation Thread" #19 [26115] daemon prio=9 os_prio=31 cpu=2.54ms elapsed=24.14s tid=0x000000014a013800 nid=26115 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"C2 CompilerThread0" #20 [28931] daemon prio=9 os_prio=31 cpu=208.92ms elapsed=24.14s tid=0x000000014a014000 nid=28931 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - No compile task - - Locked ownable synchronizers: - - None - -"C1 CompilerThread0" #28 [26627] daemon prio=9 os_prio=31 cpu=100.30ms elapsed=24.14s tid=0x000000014a01c800 nid=26627 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - No compile task - - Locked ownable synchronizers: - - None - -"Common-Cleaner" #32 [27395] daemon prio=8 os_prio=31 cpu=0.04ms elapsed=24.12s tid=0x000000014981d600 nid=27395 in Object.wait() [0x000000016f82e000] - java.lang.Thread.State: TIMED_WAITING (on object monitor) - at java.lang.Object.wait0(java.base@24.0.2/Native Method) - - waiting on <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) - at java.lang.Object.wait(java.base@24.0.2/Object.java:389) - at java.lang.ref.ReferenceQueue.remove0(java.base@24.0.2/ReferenceQueue.java:124) - at java.lang.ref.ReferenceQueue.remove(java.base@24.0.2/ReferenceQueue.java:215) - - locked <0x000000052b0199e8> (a java.lang.ref.ReferenceQueue$Lock) - at jdk.internal.ref.CleanerImpl.run(java.base@24.0.2/CleanerImpl.java:140) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - at jdk.internal.misc.InnocuousThread.run(java.base@24.0.2/InnocuousThread.java:148) - - Locked ownable synchronizers: - - None - -"Monitor Ctrl-Break" #33 [43011] daemon prio=5 os_prio=31 cpu=8.19ms elapsed=24.08s tid=0x000000014a122e00 nid=43011 runnable [0x000000016fc46000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.SocketDispatcher.read0(java.base@24.0.2/Native Method) - at sun.nio.ch.SocketDispatcher.read(java.base@24.0.2/SocketDispatcher.java:47) - at sun.nio.ch.NioSocketImpl.tryRead(java.base@24.0.2/NioSocketImpl.java:255) - at sun.nio.ch.NioSocketImpl.implRead(java.base@24.0.2/NioSocketImpl.java:306) - at sun.nio.ch.NioSocketImpl.read(java.base@24.0.2/NioSocketImpl.java:345) - at sun.nio.ch.NioSocketImpl$1.read(java.base@24.0.2/NioSocketImpl.java:790) - at java.net.Socket$SocketInputStream.implRead(java.base@24.0.2/Socket.java:983) - at java.net.Socket$SocketInputStream.read(java.base@24.0.2/Socket.java:970) - at sun.nio.cs.StreamDecoder.readBytes(java.base@24.0.2/StreamDecoder.java:279) - at sun.nio.cs.StreamDecoder.implRead(java.base@24.0.2/StreamDecoder.java:322) - at sun.nio.cs.StreamDecoder.read(java.base@24.0.2/StreamDecoder.java:186) - - locked <0x000000052b0266a0> (a java.io.InputStreamReader) - at java.io.InputStreamReader.read(java.base@24.0.2/InputStreamReader.java:175) - at java.io.BufferedReader.fill(java.base@24.0.2/BufferedReader.java:166) - at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:333) - - locked <0x000000052b0266a0> (a java.io.InputStreamReader) - at java.io.BufferedReader.readLine(java.base@24.0.2/BufferedReader.java:400) - at com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:31) - - Locked ownable synchronizers: - - <0x000000052b3d2888> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) - -"Notification Thread" #34 [42499] daemon prio=9 os_prio=31 cpu=0.01ms elapsed=24.08s tid=0x000000014a123600 nid=42499 runnable [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"worker I/O-1" #49 [38915] prio=5 os_prio=31 cpu=0.35ms elapsed=23.78s tid=0x000000014a9e8800 nid=38915 runnable [0x00000003220ba000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0333c8> (a sun.nio.ch.Util$2) - - locked <0x000000052b033370> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-2" #50 [38403] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa54e00 nid=38403 runnable [0x00000003222c6000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b040080> (a sun.nio.ch.Util$2) - - locked <0x000000052b040028> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-3" #51 [37891] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x000000014aa55600 nid=37891 runnable [0x00000003224d2000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b04cd38> (a sun.nio.ch.Util$2) - - locked <0x000000052b04cce0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-4" #52 [43523] prio=5 os_prio=31 cpu=0.24ms elapsed=23.78s tid=0x000000014a9e9000 nid=43523 runnable [0x00000003226de000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0599f0> (a sun.nio.ch.Util$2) - - locked <0x000000052b059998> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-5" #53 [44035] prio=5 os_prio=31 cpu=0.29ms elapsed=23.78s tid=0x00000001038d6000 nid=44035 runnable [0x00000003228ea000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b073360> (a sun.nio.ch.Util$2) - - locked <0x000000052b073308> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-6" #54 [44291] prio=5 os_prio=31 cpu=0.25ms elapsed=23.78s tid=0x000000014c059c00 nid=44291 runnable [0x0000000322af6000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b0666a8> (a sun.nio.ch.Util$2) - - locked <0x000000052b066650> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-7" #55 [64771] prio=5 os_prio=31 cpu=0.23ms elapsed=23.78s tid=0x000000014c057800 nid=64771 runnable [0x0000000322d02000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b000158> (a sun.nio.ch.Util$2) - - locked <0x000000052b000100> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-8" #56 [44803] prio=5 os_prio=31 cpu=28.09ms elapsed=23.78s tid=0x000000014c058000 nid=44803 runnable [0x0000000322f0e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b080018> (a sun.nio.ch.Util$2) - - locked <0x000000052b07ffc0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:142) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:563) - - Locked ownable synchronizers: - - None - -"worker I/O-9" #57 [64515] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000012fb59200 nid=64515 runnable [0x000000032311a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b08ccd0> (a sun.nio.ch.Util$2) - - locked <0x000000052b08cc78> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-10" #58 [45571] prio=5 os_prio=31 cpu=0.05ms elapsed=23.78s tid=0x000000014a1f7000 nid=45571 runnable [0x0000000323326000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b019b18> (a sun.nio.ch.Util$2) - - locked <0x000000052b019ac0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-11" #59 [64003] prio=5 os_prio=31 cpu=0.12ms elapsed=23.78s tid=0x000000012fb5bc00 nid=64003 runnable [0x0000000323532000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b00ce58> (a sun.nio.ch.Util$2) - - locked <0x000000052b00ce00> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-12" #60 [46083] prio=5 os_prio=31 cpu=0.06ms elapsed=23.78s tid=0x000000013f810200 nid=46083 runnable [0x000000032373e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b02cd58> (a sun.nio.ch.Util$2) - - locked <0x000000052b02cd00> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-13" #61 [46339] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000013f819400 nid=46339 runnable [0x000000032394a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b099988> (a sun.nio.ch.Util$2) - - locked <0x000000052b099930> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-14" #62 [63235] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d5800 nid=63235 runnable [0x0000000323b56000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b059b70> (a sun.nio.ch.Util$2) - - locked <0x000000052b059b18> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-15" #63 [46595] prio=5 os_prio=31 cpu=0.03ms elapsed=23.78s tid=0x000000012f1d6000 nid=46595 runnable [0x0000000323d62000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b033548> (a sun.nio.ch.Util$2) - - locked <0x000000052b0334f0> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker I/O-16" #64 [47107] prio=5 os_prio=31 cpu=0.04ms elapsed=23.78s tid=0x000000013e998400 nid=47107 runnable [0x0000000323f6e000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b02ced8> (a sun.nio.ch.Util$2) - - locked <0x000000052b02ce80> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"worker Accept" #65 [62467] prio=5 os_prio=31 cpu=4.29ms elapsed=23.78s tid=0x000000014a1f7800 nid=62467 runnable [0x000000032417a000] - java.lang.Thread.State: RUNNABLE - at sun.nio.ch.KQueue.poll(java.base@24.0.2/Native Method) - at sun.nio.ch.KQueueSelectorImpl.doSelect(java.base@24.0.2/KQueueSelectorImpl.java:121) - at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@24.0.2/SelectorImpl.java:130) - - locked <0x000000052b019d30> (a sun.nio.ch.Util$2) - - locked <0x000000052b019cd8> (a sun.nio.ch.KQueueSelectorImpl) - at sun.nio.ch.SelectorImpl.select(java.base@24.0.2/SelectorImpl.java:147) - at org.xnio.nio.WorkerThread.run(WorkerThread.java:544) - - Locked ownable synchronizers: - - None - -"DestroyJavaVM" #67 [5635] prio=5 os_prio=31 cpu=510.86ms elapsed=23.63s tid=0x000000014aa71e00 nid=5635 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"worker task-1" #68 [27911] prio=5 os_prio=31 cpu=70.58ms elapsed=16.81s tid=0x000000012e03be00 nid=27911 waiting on condition [0x000000016f622000] - java.lang.Thread.State: TIMED_WAITING (parking) - at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) - - parking to wait for <0x000000052b3de1c8> (a org.jboss.threads.EnhancedQueueExecutor) - at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) - at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1421) - at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - - Locked ownable synchronizers: - - None - -"grpc-timer-0" #69 [27143] daemon prio=5 os_prio=31 cpu=0.12ms elapsed=16.81s tid=0x000000013e875000 nid=27143 waiting on condition [0x000000016fa3a000] - java.lang.Thread.State: TIMED_WAITING (parking) - at jdk.internal.misc.Unsafe.park(java.base@24.0.2/Native Method) - - parking to wait for <0x000000052b059c88> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) - at java.util.concurrent.locks.LockSupport.parkNanos(java.base@24.0.2/LockSupport.java:271) - at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@24.0.2/AbstractQueuedSynchronizer.java:1802) - at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:1166) - at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@24.0.2/ScheduledThreadPoolExecutor.java:883) - at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@24.0.2/ThreadPoolExecutor.java:1021) - at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@24.0.2/ThreadPoolExecutor.java:1081) - at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@24.0.2/ThreadPoolExecutor.java:619) - at java.lang.Thread.runWith(java.base@24.0.2/Thread.java:1460) - at java.lang.Thread.run(java.base@24.0.2/Thread.java:1447) - - Locked ownable synchronizers: - - None - -"Attach Listener" #72 [41995] daemon prio=9 os_prio=31 cpu=0.34ms elapsed=0.11s tid=0x000000012e045800 nid=41995 waiting on condition [0x0000000000000000] - java.lang.Thread.State: RUNNABLE - - Locked ownable synchronizers: - - None - -"G1 Conc#2" os_prio=31 cpu=0.96ms elapsed=16.77s tid=0x000000014b1148c0 nid=34055 runnable - -"G1 Conc#1" os_prio=31 cpu=2.36ms elapsed=16.77s tid=0x0000000103616eb0 nid=33799 runnable - -"GC Thread#12" os_prio=31 cpu=2.93ms elapsed=23.83s tid=0x000000014971c4b0 nid=37379 runnable - -"GC Thread#11" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971bf30 nid=36867 runnable - -"GC Thread#10" os_prio=31 cpu=2.92ms elapsed=23.83s tid=0x000000014971b9b0 nid=36611 runnable - -"GC Thread#9" os_prio=31 cpu=2.97ms elapsed=23.83s tid=0x000000014971b430 nid=36355 runnable - -"GC Thread#8" os_prio=31 cpu=3.12ms elapsed=23.83s tid=0x000000014971aeb0 nid=36099 runnable - -"GC Thread#7" os_prio=31 cpu=3.09ms elapsed=23.83s tid=0x000000014971a930 nid=40451 runnable - -"GC Thread#6" os_prio=31 cpu=3.04ms elapsed=23.83s tid=0x000000014971a3b0 nid=35587 runnable - -"GC Thread#5" os_prio=31 cpu=2.95ms elapsed=23.83s tid=0x0000000149719e30 nid=35075 runnable - -"GC Thread#4" os_prio=31 cpu=2.86ms elapsed=23.83s tid=0x00000001497198b0 nid=34819 runnable - -"GC Thread#3" os_prio=31 cpu=3.13ms elapsed=23.83s tid=0x000000014b01c6a0 nid=41219 runnable - -"GC Thread#2" os_prio=31 cpu=3.11ms elapsed=23.83s tid=0x0000000149719330 nid=41731 runnable - -"GC Thread#1" os_prio=31 cpu=3.06ms elapsed=23.83s tid=0x000000012e812cd0 nid=34307 runnable - -"VM Thread" os_prio=31 cpu=5.25ms elapsed=24.15s tid=0x000000012df04560 nid=19715 runnable - -"VM Periodic Task Thread" os_prio=31 cpu=13.48ms elapsed=24.15s tid=0x00000001497086d0 nid=20743 waiting on condition - -"G1 Service" os_prio=31 cpu=1.69ms elapsed=24.15s tid=0x00000001497065d0 nid=21251 runnable - -"G1 Refine#0" os_prio=31 cpu=0.02ms elapsed=24.15s tid=0x000000014b860600 nid=16643 runnable - -"G1 Conc#0" os_prio=31 cpu=1.46ms elapsed=24.15s tid=0x0000000149705e30 nid=13827 runnable - -"G1 Main Marker" os_prio=31 cpu=0.12ms elapsed=24.15s tid=0x000000014b107a60 nid=13315 runnable - -"GC Thread#0" os_prio=31 cpu=3.00ms elapsed=24.15s tid=0x000000014b1072b0 nid=13059 runnable - -JNI global refs: 23, weak refs: 0 - diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java index 074a147a81..377b59e37b 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/GrpcRequestBridge.java @@ -55,6 +55,7 @@ public void onNext(byte[] item) { log.info("asking for more request(1)"); internalObserver.request(1); + // subscription.request(1); } catch (Throwable t) { subscription.cancel(); internalObserver.onError(t); diff --git a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java index 9a5b4178d0..9611bb4dee 100644 --- a/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java +++ b/modules/jooby-grpc/src/main/java/io/jooby/grpc/UnifiedGrpcBridge.java @@ -71,7 +71,7 @@ public StreamObserver startCall(Context ctx) { var descriptor = methodRegistry.get(path.substring(1)); if (descriptor == null) { terminateWithStatus( - null, + ctx, Status.UNIMPLEMENTED.withDescription("Method not found in bridge registry: " + path)); return null; } @@ -90,94 +90,55 @@ public StreamObserver startCall(Context ctx) { ClientResponseObserver responseObserver; log.info("method type: {}", method.getType()); - if (method.getType() == MethodDescriptor.MethodType.UNARY) { - // Atomic guard to prevent multiple terminal calls - var isFinished = new AtomicBoolean(false); - // 3. Unified Response Observer (Handles data coming BACK from the server) - responseObserver = - new ClientResponseObserver<>() { - @Override - public void beforeStart(ClientCallStreamObserver requestStream) { - requestStream.disableAutoInboundFlowControl(); - } - - @Override - public void onNext(byte[] value) { - if (isFinished.get()) return; - log.info("onNext Send {}", HexFormat.of().formatHex(value)); - - // Professional Framing: 5-byte header + payload - ctx.setResponseTrailer("grpc-status", "0"); - byte[] framed = addGrpcHeader(value); - ctx.send(framed); - } - - @Override - public void onError(Throwable t) { - if (isFinished.compareAndSet(false, true)) { - log.info(" error", t); - terminateWithStatus(ctx, Status.fromThrowable(t)); - } - } - - @Override - public void onCompleted() { - if (isFinished.compareAndSet(false, true)) { - log.info("onCompleted"); - terminateWithStatus(ctx, Status.OK); - } - } - }; - } else { - var sender = ctx.responseSender(false); - // Atomic guard to prevent multiple terminal calls - var isFinished = new AtomicBoolean(false); - // 3. Unified Response Observer (Handles data coming BACK from the server) - responseObserver = - new ClientResponseObserver<>() { - @Override - public void beforeStart(ClientCallStreamObserver requestStream) { - requestStream.disableAutoInboundFlowControl(); - } - - @Override - public void onNext(byte[] value) { - if (isFinished.get()) return; - log.info("onNext Send {}", HexFormat.of().formatHex(value)); - - // Professional Framing: 5-byte header + payload - sender.setTrailer("grpc-status", "0"); - byte[] framed = addGrpcHeader(value); - sender.write( - framed, - new Sender.Callback() { - @Override - public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { - log.info("onNext Sent {}", ctx); - if (cause != null) { - onError(cause); - } + var sender = ctx.responseSender(false); + // Atomic guard to prevent multiple terminal calls + var isFinished = new AtomicBoolean(false); + sender.setTrailer("grpc-status", "0"); + // 3. Unified Response Observer (Handles data coming BACK from the server) + responseObserver = + new ClientResponseObserver<>() { + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + requestStream.disableAutoInboundFlowControl(); + } + + @Override + public void onNext(byte[] value) { + if (isFinished.get()) return; + log.info("onNext Send {}", HexFormat.of().formatHex(value)); + + // Professional Framing: 5-byte header + payload + + byte[] framed = addGrpcHeader(value); + sender.write( + framed, + new Sender.Callback() { + @Override + public void onComplete(@NonNull Context ctx, @Nullable Throwable cause) { + log.info("onNext Sent {}", ctx); + if (cause != null) { + onError(cause); } - }); - } - - @Override - public void onError(Throwable t) { - if (isFinished.compareAndSet(false, true)) { - log.info(" error", t); - terminateWithStatus(ctx, Status.fromThrowable(t)); - } + } + }); + } + + @Override + public void onError(Throwable t) { + if (isFinished.compareAndSet(false, true)) { + log.info(" error", t); + terminateWithStatus(sender, Status.fromThrowable(t)); } + } - @Override - public void onCompleted() { - if (isFinished.compareAndSet(false, true)) { - log.info("onCompleted"); - terminateWithStatus(ctx, Status.OK); - } + @Override + public void onCompleted() { + if (isFinished.compareAndSet(false, true)) { + log.info("onCompleted"); + terminateWithStatus(sender, Status.OK); } - }; - } + } + }; // 4. Map gRPC Method Type to the correct ClientCalls utility return switch (method.getType()) { @@ -228,6 +189,14 @@ public void onCompleted() { }; } + private void terminateWithStatus(Sender ctx, Status status) { + ctx.setTrailer("grpc-status", String.valueOf(status.getCode().value())); + if (status.getDescription() != null) { + ctx.setTrailer("grpc-message", status.getDescription()); + } + ctx.close(); + } + /** * Professional Status Termination. Sets gRPC trailers and closes the Jetty response correctly. */ diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java index 6cffb42bde..97228e26bd 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettyRequestPublisher.java @@ -87,7 +87,6 @@ private void process(String call) { buffer.get(bytes); log.info("{}- byte read: {}", call, HexFormat.of().formatHex(bytes)); - // demand.decrementAndGet(); subscriber.onNext(bytes); } chunk.release(); diff --git a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java index fa55675039..a3c38b84fa 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/internal/jetty/JettySender.java @@ -48,7 +48,25 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { } public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - response.write(false, buffer, toJettyCallback(ctx, callback)); + if (trailers != null) { + var copy = HttpFields.build(trailers); + response.setTrailersSupplier(() -> copy); + this.trailers = null; + } + response.write( + false, + buffer, + new org.eclipse.jetty.util.Callback() { + @Override + public void succeeded() { + org.eclipse.jetty.util.Callback.super.succeeded(); + } + + @Override + public void failed(Throwable x) { + org.eclipse.jetty.util.Callback.super.failed(x); + } + }); // if (trailers == null) { // response.write(false, buffer, toJettyCallback(ctx, callback)); // } else { @@ -63,10 +81,23 @@ public Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { @Override public void close() { - if (trailers != null) { - response.setTrailersSupplier(() -> trailers); - response.write(true, null, ctx); - } + // if (trailers != null) { + // response.setTrailersSupplier(() -> trailers); + // response.write(true, null, new org.eclipse.jetty.util.Callback() { + // @Override + // public void succeeded() { + // System.out.println("Succeed"); + // } + // + // @Override + // public void failed(Throwable throwable) { + // System.out.println("Failed"); + // throwable.printStackTrace(); + // } + // }); + // } else { + response.write(true, null, ctx); + // } // if (pending != null) { // response.setTrailersSupplier(() -> trailers); // response.write(true, pending, ctx); diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java index 02cfb85d4a..46b2134c65 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowContext.java @@ -487,7 +487,7 @@ public Context send(@NonNull ByteBuffer[] data) { public Context send(@NonNull ByteBuffer data) { ifUnDispatch(data); exchange.setResponseContentLength(data.remaining()); - exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); + // exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, Long.toString(data.remaining())); exchange.getResponseSender().send(data, this); return this; } diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java index c05ded01d5..04460ad7c0 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowHandler.java @@ -61,8 +61,6 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { .get(Headers.CONTENT_TYPE) .getFirst() .contains("application/grpc")) { - // var route = router.match(context); - // context.setRoute(route.route()); var subscriber = router.require(ServiceKey.key(Function.class, "gRPC")); new UndertowGrpcHandler(this, router, bufferSize, subscriber).handleRequest(exchange); return; diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java index fbd6aa4729..847e88fcab 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowRequestPublisher.java @@ -5,13 +5,35 @@ */ package io.jooby.internal.undertow; +import static io.undertow.io.IoCallback.END_EXCHANGE; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HexFormat; import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xnio.channels.StreamSourceChannel; + +import io.undertow.UndertowLogger; +import io.undertow.UndertowMessages; +import io.undertow.connector.PooledByteBuffer; +import io.undertow.io.Receiver; +import io.undertow.server.Connectors; import io.undertow.server.HttpServerExchange; +import io.undertow.util.AttachmentKey; +import io.undertow.util.Headers; +import io.undertow.util.StatusCodes; public class UndertowRequestPublisher implements Flow.Publisher { + + public static final AttachmentKey REQUEST_CHANNEL = + AttachmentKey.create(StreamSourceChannel.class); + private final HttpServerExchange exchange; public UndertowRequestPublisher(HttpServerExchange exchange) { @@ -27,11 +49,13 @@ public void subscribe(Flow.Subscriber subscriber) { } class UndertowReceiverSubscription implements Flow.Subscription { + private static final Logger log = LoggerFactory.getLogger(UndertowReceiverSubscription.class); private final HttpServerExchange exchange; private final Flow.Subscriber subscriber; private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicLong demand = new AtomicLong(0); private final AtomicBoolean readingStarted = new AtomicBoolean(false); + private UndertowReceiver receiver; public UndertowReceiverSubscription( HttpServerExchange exchange, Flow.Subscriber subscriber) { @@ -42,37 +66,35 @@ public UndertowReceiverSubscription( @Override public void request(long n) { if (n <= 0) return; - - // Add to our demand counter - long prevDemand = demand.getAndAdd(n); - - // Case 1: First time starting the read - if (readingStarted.compareAndSet(false, true)) { - startReading(); - } - // Case 2: We were paused (demand was 0) and now have new demand - else if (prevDemand == 0) { - exchange.getRequestReceiver().resume(); - } + log.info("init request({})", n); + // if (receiver == null) { + // receiver = new UndertowReceiver(exchange, () -> {}); + process(); + // } else { + // receiver.resume(); + // } } - private void startReading() { + private void process() { + var call = new AtomicInteger(0); + + // var receiver = exchange.getRequestReceiver(); exchange .getRequestReceiver() .receivePartialBytes( (exch, message, last) -> { + call.incrementAndGet(); + log.info("{}- byte len: {}", call, message.length); if (message.length > 0) { // Pass bytes to De-framer + log.info("{}- byte read: {}", call, HexFormat.of().formatHex(message)); subscriber.onNext(message); } - // If we've exhausted the demand requested by the Bridge, pause Undertow - if (demand.decrementAndGet() == 0) { - exchange.getRequestReceiver().pause(); - } // THE KEY FIX: // 1. If 'last' is true, the stream is definitely over. // 2. If 'isRequestComplete' is true, Undertow's internal state knows it's over. if (last) { + log.info("{}- last reach", call); subscriber.onComplete(); } }, @@ -86,3 +108,160 @@ public void cancel() { exchange.getRequestReceiver().pause(); } } + +class UndertowReceiver { + private final Logger log = LoggerFactory.getLogger(UndertowReceiver.class); + private final HttpServerExchange exchange; + private final StreamSourceChannel channel; + private final Runnable runnable; + private int maxBufferSize = -1; + private boolean paused = false; + private boolean done = false; + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + private static final Receiver.ErrorCallback END_EXCHANGE = + new Receiver.ErrorCallback() { + @Override + public void error(HttpServerExchange exchange, IOException e) { + e.printStackTrace(); + exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR); + UndertowLogger.REQUEST_IO_LOGGER.ioException(e); + exchange.endExchange(); + } + }; + + public UndertowReceiver(HttpServerExchange exchange, Runnable runnable) { + this.exchange = exchange; + this.channel = exchange.getRequestChannel(); + exchange.putAttachment(UndertowRequestPublisher.REQUEST_CHANNEL, this.channel); + this.runnable = runnable; + } + + public void receivePartialBytes( + final Receiver.PartialBytesCallback callback, final Receiver.ErrorCallback errorCallback) { + if (done) { + throw UndertowMessages.MESSAGES.requestBodyAlreadyRead(); + } + final Receiver.ErrorCallback error = errorCallback == null ? END_EXCHANGE : errorCallback; + if (callback == null) { + throw UndertowMessages.MESSAGES.argumentCannotBeNull("callback"); + } + if (exchange.isRequestComplete()) { + log.info("request complete"); + callback.handle(exchange, EMPTY_BYTE_ARRAY, true); + return; + } + String contentLengthString = exchange.getRequestHeaders().getFirst(Headers.CONTENT_LENGTH); + if (contentLengthString == null) { + contentLengthString = exchange.getRequestHeaders().getFirst(Headers.X_CONTENT_LENGTH); + } + long contentLength; + if (contentLengthString != null) { + contentLength = Long.parseLong(contentLengthString); + if (contentLength > Integer.MAX_VALUE) { + error.error(exchange, new Receiver.RequestToLargeException()); + return; + } + } else { + contentLength = -1; + } + if (maxBufferSize > 0) { + if (contentLength > maxBufferSize) { + error.error(exchange, new Receiver.RequestToLargeException()); + return; + } + } + PooledByteBuffer pooled = exchange.getConnection().getByteBufferPool().allocate(); + final ByteBuffer buffer = pooled.getBuffer(); + + channel + .getReadSetter() + .set( + channel -> { + if (done || paused) { + log.info("request done: {} or paused: {}", done, paused); + return; + } + PooledByteBuffer pooled1 = exchange.getConnection().getByteBufferPool().allocate(); + final ByteBuffer buffer1 = pooled1.getBuffer(); + try { + int res2; + do { + if (paused) { + return; + } + try { + buffer1.clear(); + res2 = channel.read(buffer1); + if (res2 == -1) { + done = true; + log.info("INSIDE request read done: {} ", res2); + Connectors.executeRootHandler( + exchange -> callback.handle(exchange, EMPTY_BYTE_ARRAY, true), exchange); + return; + } else if (res2 == 0) { + log.info("INSIDE resume reads: {}", res2); + // channel.resumeReads(); + return; + } else { + buffer1.flip(); + final byte[] data = new byte[buffer1.remaining()]; + buffer1.get(data); + + Connectors.executeRootHandler( + exchange -> { + callback.handle(exchange, data, false); + channel.resumeReads(); + }, + exchange); + } + } catch (final IOException e) { + log.info("INSIDE error reading from {}", exchange, e); + Connectors.executeRootHandler(exchange -> error.error(exchange, e), exchange); + return; + } + } while (true); + } finally { + pooled1.close(); + } + }); + + try { + int res; + do { + try { + buffer.clear(); + res = channel.read(buffer); + if (res == -1) { + log.info("request read out-of listener: {} ", res); + done = true; + callback.handle(exchange, EMPTY_BYTE_ARRAY, true); + return; + } else if (res == 0) { + log.info("request resume reads out-of listener: {} ", res); + channel.resumeReads(); + return; + } else { + buffer.flip(); + byte[] data = new byte[buffer.remaining()]; + buffer.get(data); + log.info("request read done out-of listener: {} ", res); + callback.handle(exchange, data, false); + if (paused) { + return; + } + } + } catch (IOException e) { + error.error(exchange, e); + return; + } + } while (true); + } finally { + log.info("channel open: {} ", channel.isOpen()); + pooled.close(); + } + } + + public void resume() { + channel.wakeupReads(); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java index cc8fcac016..2de98bc591 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/internal/undertow/UndertowSender.java @@ -48,29 +48,31 @@ public Sender write(@NonNull Output output, @NonNull Callback callback) { } private Sender write(@NonNull ByteBuffer buffer, @NonNull Callback callback) { - exchange.getResponseSender().send(buffer, newIoCallback(ctx, callback)); + if (trailers != null) { + var copy = new HeaderMap(); + copy.putAll(trailers); + trailers = null; + exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, copy); + } + exchange + .getResponseSender() + .send( + buffer, + new IoCallback() { + @Override + public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} + + @Override + public void onException( + HttpServerExchange exchange, + io.undertow.io.Sender sender, + IOException exception) {} + }); return this; } @Override public void close() { - if (trailers != null) { - exchange.putAttachment(HttpAttachments.RESPONSE_TRAILERS, this.trailers); - exchange - .getResponseSender() - .send( - "", - new IoCallback() { - @Override - public void onComplete(HttpServerExchange exchange, io.undertow.io.Sender sender) {} - - @Override - public void onException( - HttpServerExchange exchange, - io.undertow.io.Sender sender, - IOException exception) {} - }); - } ctx.destroy(null); } diff --git a/tests/src/test/java/examples/grpc/GrpcServer.java b/tests/src/test/java/examples/grpc/GrpcServer.java index 82b678a880..827506bb48 100644 --- a/tests/src/test/java/examples/grpc/GrpcServer.java +++ b/tests/src/test/java/examples/grpc/GrpcServer.java @@ -14,7 +14,7 @@ import io.jooby.StartupSummary; import io.jooby.grpc.GrpcModule; import io.jooby.handler.AccessLogHandler; -import io.jooby.jetty.JettyServer; +import io.jooby.undertow.UndertowServer; public class GrpcServer extends Jooby { @@ -26,10 +26,27 @@ public class GrpcServer extends Jooby { new GreeterService(), new ChatServiceImpl(), ProtoReflectionServiceV1.newInstance())); } + // INFO [2026-01-15 10:19:29,307] [worker-55] UnifiedGrpcBridge method type: BIDI_STREAMING + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription init request(1) + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- start reading request + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- byte read: 00000000033a012a + // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge deframe 3a012a + // INFO [2026-01-15 10:19:29,308] [worker-55] UnifiedGrpcBridge onNext Send + // 12033a012a32460a120a10746573742e43686174536572766963650a250a23677270632e7265666c656374696f6e2e76312e5365727665725265666c656374696f6e0a090a0747726565746572 + // INFO [2026-01-15 10:19:29,308] [worker-55] GrpcRequestBridge asking for more request(1) + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- demanding more + // INFO [2026-01-15 10:19:29,308] [worker-55] JettySubscription 1- finish reading request + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- start reading request + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- last reach + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription handle complete + // INFO [2026-01-15 10:19:29,309] [worker-52] UnifiedGrpcBridge onCompleted + // INFO [2026-01-15 10:19:29,309] [worker-52] JettySubscription 1.demand- finish reading request + // INFO [2026-01-15 10:20:08,267] [Thread-0] GrpcServer Stopped GrpcServer + public static void main(final String[] args) throws InterruptedException, IOException { runApp( args, - new JettyServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), + new UndertowServer(new ServerOptions().setSecurePort(8443).setHttp2(true)), GrpcServer::new); // Build the server diff --git a/tests/src/test/java/examples/grpc/JettyTrailerTest.java b/tests/src/test/java/examples/grpc/JettyTrailerTest.java new file mode 100644 index 0000000000..7d149bc6ab --- /dev/null +++ b/tests/src/test/java/examples/grpc/JettyTrailerTest.java @@ -0,0 +1,255 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package examples.grpc; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.http.*; +import org.eclipse.jetty.http2.api.Session; +import org.eclipse.jetty.http2.api.Stream; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.frames.DataFrame; +import org.eclipse.jetty.http2.frames.HeadersFrame; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.*; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Promise; +import org.junit.jupiter.api.Test; + +public class JettyTrailerTest { + + @Test + public void testEmptyWriteWithTrailersCausesUnexpectedEOS() throws Exception { + // 1. Setup Server + Server server = new Server(); + HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(); + ServerConnector connector = new ServerConnector(server, h2); + connector.setPort(0); + server.addConnector(connector); + + server.setHandler( + new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + // Set the trailers that gRPC-Java expects + response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); + + // The core of the issue: sending a "last" write with no data + // This triggers Data.eof() in Jetty 12 instead of a HEADERS frame + response.write(true, null, callback); + return true; + } + }); + server.start(); + + // 2. Setup Client + HTTP2Client client = new HTTP2Client(); + client.start(); + + CompletableFuture resultFuture = new CompletableFuture<>(); + InetSocketAddress address = new InetSocketAddress("localhost", connector.getLocalPort()); + + client.connect( + address, + new Session.Listener() {}, + new Promise<>() { + @Override + public void succeeded(Session session) { + HttpURI uri = HttpURI.from("http://localhost:" + connector.getLocalPort() + "/"); + MetaData.Request metaData = + new MetaData.Request("GET", uri, HttpVersion.HTTP_2, HttpFields.build()); + HeadersFrame requestFrame = new HeadersFrame(metaData, null, true); + + session.newStream( + requestFrame, + Promise.noop(), + new Stream.Listener() { + @Override + public void onDataAvailable(Stream stream) { + // Access the Data wrapper you identified + Stream.Data data = stream.readData(); + if (data != null) { + DataFrame frame = data.frame(); + + // If we receive an empty DATA frame with END_STREAM, + // Jetty has closed the stream before sending trailers. + if (frame.isEndStream()) { + resultFuture.completeExceptionally( + new RuntimeException( + "BUG REPRODUCED: Received Data.EOF (DATA frame with END_STREAM)." + + " This violates gRPC protocol as trailers in HEADERS were" + + " expected.")); + return; + } + } + stream.demand(); + } + + @Override + public void onHeaders(Stream stream, HeadersFrame frame) { + MetaData metaData = frame.getMetaData(); + HttpFields fields = metaData.getHttpFields(); + // If trailers arrive correctly with END_STREAM, the test passes + if (frame.isEndStream() && fields.contains("grpc-status")) { + resultFuture.complete(null); + } + } + + @Override + public void onFailure( + Stream stream, + int error, + String reason, + Throwable failure, + Callback callback) { + resultFuture.completeExceptionally(failure); + callback.succeeded(); + } + }); + } + + @Override + public void failed(Throwable x) { + resultFuture.completeExceptionally(x); + } + }); + + // 3. Evaluation + try { + // We expect this to time out or fail if the bug exists + resultFuture.get(5, TimeUnit.SECONDS); + System.out.println("SUCCESS: Trailers arrived correctly on a HEADERS frame."); + } catch (Exception e) { + // In the bug scenario, e.getCause() will contain our RuntimeException + String message = e.getMessage() != null ? e.getMessage() : e.getCause().getMessage(); + fail("Test failed due to Jetty behavior: " + message); + } finally { + server.stop(); + client.stop(); + } + } + + @Test + public void testBidiExchangeWithTrailers() throws Exception { + // 1. Setup Server + Server server = new Server(); + ServerConnector connector = new ServerConnector(server, new HTTP2ServerConnectionFactory()); + server.addConnector(connector); + + server.setHandler( + new Handler.Abstract() { + @Override + public boolean handle(Request request, Response response, Callback callback) { + // Set trailers supplier + response.setTrailersSupplier(() -> HttpFields.build().put("grpc-status", "0")); + + // Obtain the content source to read client data + + request.demand( + () -> { + var chunk = request.read(); + if (chunk != null) { + // If we received data from client + if (BufferUtil.hasContent(chunk.getByteBuffer())) { + // Send an echo response back + var echo = ByteBuffer.wrap("Server Echo".getBytes(StandardCharsets.UTF_8)); + response.write(false, echo, Callback.NOOP); + } + + // Check if this was the last chunk from client + if (chunk.isLast()) { + // SIGNAL END OF SERVER STREAM + // This triggers the Data.EOF bug in 12.1.5 + response.write(true, null, callback); + } + chunk.release(); + } + }); + return true; + } + }); + server.start(); + + // 2. Setup Client + HTTP2Client client = new HTTP2Client(); + client.start(); + CompletableFuture resultFuture = new CompletableFuture<>(); + int port = connector.getLocalPort(); + + client.connect( + new InetSocketAddress("localhost", port), + new Session.Listener() {}, + new Promise<>() { + @Override + public void succeeded(Session session) { + HttpURI uri = HttpURI.from("http://localhost:" + port + "/"); + MetaData.Request metaData = + new MetaData.Request("POST", uri, HttpVersion.HTTP_2, HttpFields.build()); + + // Client starts stream + HeadersFrame headers = new HeadersFrame(metaData, null, false); + + session.newStream( + headers, + Promise.noop(), + new Stream.Listener() { + @Override + public void onDataAvailable(Stream stream) { + Stream.Data data = stream.readData(); + if (data != null) { + if (data.frame().isEndStream()) { + // If Jetty sends a DATA frame with END_STREAM, the bug is reproduced + resultFuture.completeExceptionally( + new RuntimeException( + "Received DATA frame with END_STREAM flag. Expected Trailers in" + + " HEADERS.")); + return; + } + } + stream.demand(); + } + + @Override + public void onHeaders(Stream stream, HeadersFrame frame) { + HttpFields fields = frame.getMetaData().getHttpFields(); + if (frame.isEndStream() && fields.contains("grpc-status")) { + resultFuture.complete(null); + } + } + }); + + // Client sends one message and closes client-side stream + session + .getStreams() + .forEach( + s -> { + ByteBuffer clientMsg = + ByteBuffer.wrap("Client Hello".getBytes(StandardCharsets.UTF_8)); + s.data(new DataFrame(s.getId(), clientMsg, true), Callback.NOOP); + }); + } + }); + + // 3. Evaluation + try { + resultFuture.get(5, TimeUnit.SECONDS); + System.out.println("SUCCESS: Bidi exchange completed correctly."); + } catch (Exception e) { + String msg = (e.getCause() != null) ? e.getCause().getMessage() : e.getMessage(); + fail("Reproduced: " + msg); + } finally { + server.stop(); + client.stop(); + } + } +} diff --git a/tests/src/test/java/examples/grpc/ReflectionClient.java b/tests/src/test/java/examples/grpc/ReflectionClient.java index 62219a7ebe..bdcc61795a 100644 --- a/tests/src/test/java/examples/grpc/ReflectionClient.java +++ b/tests/src/test/java/examples/grpc/ReflectionClient.java @@ -18,51 +18,53 @@ public class ReflectionClient { public static void main(String[] args) throws InterruptedException { ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext().build(); + try { + var latch = new CountDownLatch(1); + ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); - var latch = new CountDownLatch(1); - ServerReflectionGrpc.ServerReflectionStub stub = ServerReflectionGrpc.newStub(channel); + // 1. Prepare the response observer + StreamObserver responseObserver = + new StreamObserver<>() { + @Override + public void onNext(ServerReflectionResponse response) { + // This is the part that returns the list of services + response + .getListServicesResponse() + .getServiceList() + .forEach( + s -> { + System.out.println("Service: " + s.getName()); + }); + } - // 1. Prepare the response observer - StreamObserver responseObserver = - new StreamObserver<>() { - @Override - public void onNext(ServerReflectionResponse response) { - // This is the part that returns the list of services - response - .getListServicesResponse() - .getServiceList() - .forEach( - s -> { - System.out.println("Service: " + s.getName()); - }); - } + @Override + public void onError(Throwable t) { + t.printStackTrace(); + } - @Override - public void onError(Throwable t) { - t.printStackTrace(); - } + @Override + public void onCompleted() { + latch.countDown(); + } + }; - @Override - public void onCompleted() { - latch.countDown(); - } - }; + // 2. Open the bidirectional stream + StreamObserver requestObserver = + stub.serverReflectionInfo(responseObserver); - // 2. Open the bidirectional stream - StreamObserver requestObserver = - stub.serverReflectionInfo(responseObserver); + // 3. Send the "List Services" request + requestObserver.onNext( + ServerReflectionRequest.newBuilder() + .setListServices("") // The trigger for 'list' + .setHost("localhost") + .build()); - // 3. Send the "List Services" request - requestObserver.onNext( - ServerReflectionRequest.newBuilder() - .setListServices("") // The trigger for 'list' - .setHost("localhost") - .build()); + // 4. Signal half-close (Very important for reflection) + requestObserver.onCompleted(); - // 4. Signal half-close (Very important for reflection) - requestObserver.onCompleted(); - - latch.await(); - channel.shutdown(); + latch.await(); + } finally { + channel.shutdown(); + } } } From f636d26e5b2c9ba9e179cc6085f304a40401c442 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 25 Jan 2026 11:09:02 -0300 Subject: [PATCH 03/65] netty: redo pipeline for HTTP2 --- .../jooby/internal/netty/Http2Extension.java | 66 ------- .../jooby/internal/netty/Http2Settings.java | 24 --- .../io/jooby/internal/netty/NettyContext.java | 38 ++-- .../jooby/internal/netty/NettyPipeline.java | 177 ++++++++++++------ .../internal/netty/NettyRequestDecoder.java | 52 ----- .../internal/netty/NettyResponseEncoder.java | 21 --- .../internal/netty/NettyServerCodec.java | 136 ++++++++++++++ .../netty/http2/Http2OrHttp11Handler.java | 36 ---- .../http2/Http2PrefaceOrHttpHandler.java | 44 ----- .../netty/http2/NettyHttp2Configurer.java | 62 ------ .../test/java/io/jooby/test/FeaturedTest.java | 2 +- .../test/java/io/jooby/test/Http2Test.java | 61 +++++- 12 files changed, 333 insertions(+), 386 deletions(-) delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java create mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java delete mode 100644 modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java deleted file mode 100644 index 1ff0bb2d4d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Extension.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.http.HttpServerUpgradeHandler; - -public class Http2Extension { - - private Http2Settings settings; - - private Consumer http11; - - private BiConsumer> - http11Upgrade; - - private BiConsumer> http2; - - private BiConsumer> http2c; - - public Http2Extension( - Http2Settings settings, - Consumer http11, - BiConsumer> http11Upgrade, - BiConsumer> http2, - BiConsumer> http2c) { - this.settings = settings; - this.http11 = http11; - this.http11Upgrade = http11Upgrade; - this.http2 = http2; - this.http2c = http2c; - } - - public boolean isSecure() { - return settings.isSecure(); - } - - public void http11(ChannelPipeline pipeline) { - this.http11.accept(pipeline); - } - - public void http2( - ChannelPipeline pipeline, Function factory) { - this.http2.accept(pipeline, () -> factory.apply(settings)); - } - - public void http2c( - ChannelPipeline pipeline, Function factory) { - this.http2c.accept(pipeline, () -> factory.apply(settings)); - } - - public void http11Upgrade( - ChannelPipeline pipeline, - Function factory) { - this.http11Upgrade.accept(pipeline, () -> factory.apply(settings)); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java deleted file mode 100644 index 4a99f63d5d..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/Http2Settings.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -public class Http2Settings { - private final int maxRequestSize; - private final boolean secure; - - public Http2Settings(long maxRequestSize, boolean secure) { - this.maxRequestSize = (int) maxRequestSize; - this.secure = secure; - } - - public boolean isSecure() { - return secure; - } - - public int getMaxRequestSize() { - return maxRequestSize; - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java index 0f1c34d959..91d2496e92 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyContext.java @@ -46,12 +46,7 @@ import io.jooby.value.Value; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.ChannelPromise; -import io.netty.channel.DefaultFileRegion; +import io.netty.channel.*; import io.netty.handler.codec.http.*; import io.netty.handler.codec.http.cookie.ServerCookieDecoder; import io.netty.handler.codec.http.multipart.*; @@ -320,7 +315,7 @@ public String getProtocol() { @NonNull @Override public List getClientCertificates() { - SslHandler sslHandler = (SslHandler) ctx.channel().pipeline().get("ssl"); + var sslHandler = ssl(); if (sslHandler != null) { try { return List.of(sslHandler.engine().getSession().getPeerCertificates()); @@ -334,11 +329,22 @@ public List getClientCertificates() { @NonNull @Override public String getScheme() { if (scheme == null) { - scheme = ctx.pipeline().get("ssl") == null ? "http" : "https"; + scheme = ssl() == null ? "http" : "https"; } return scheme; } + private SslHandler ssl() { + return (SslHandler) + Stream.of(ctx.channel(), ctx.channel().parent()) + .filter(Objects::nonNull) + .map(Channel::pipeline) + .map(it -> it.get("ssl")) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + @NonNull @Override public Context setScheme(@NonNull String scheme) { this.scheme = scheme; @@ -416,7 +422,7 @@ public Context upgrade(WebSocket.Initializer handler) { ? conf.getBytes("websocket.maxSize").intValue() : WebSocket.MAX_BUFFER_SIZE; String webSocketURL = getProtocol() + "://" + req.headers().get(HttpHeaderNames.HOST) + path; - WebSocketDecoderConfig config = + var config = WebSocketDecoderConfig.newBuilder() .allowExtensions(true) .allowMaskMismatch(false) @@ -425,7 +431,7 @@ public Context upgrade(WebSocket.Initializer handler) { .build(); webSocket = new NettyWebSocket(this); handler.init(Context.readOnly(this), webSocket); - FullHttpRequest webSocketRequest = + var webSocketRequest = new DefaultFullHttpRequest( HTTP_1_1, req.method(), @@ -433,6 +439,8 @@ public Context upgrade(WebSocket.Initializer handler) { Unpooled.EMPTY_BUFFER, req.headers(), EmptyHttpHeaders.INSTANCE); + var codec = ctx.pipeline().get(NettyServerCodec.class); + codec.webSocketHandshake(ctx); WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory(webSocketURL, null, config); WebSocketServerHandshaker handshaker = factory.newHandshaker(webSocketRequest); @@ -856,15 +864,9 @@ private long responseLength() { private void prepareChunked() { responseStarted = true; // remove flusher, doesn't play well with streaming/chunked responses - ChannelPipeline pipeline = ctx.pipeline(); + var pipeline = ctx.pipeline(); if (pipeline.get("chunker") == null) { - String base = - Stream.of("compressor", "encoder", "codec", "http2") - .filter(name -> pipeline.get(name) != null) - .findFirst() - .orElseThrow( - () -> new IllegalStateException("No available handler for chunk writer")); - pipeline.addAfter(base, "chunker", new ChunkedWriteHandler()); + pipeline.addBefore("handler", "chunker", new ChunkedWriteHandler()); } if (!setHeaders.contains(CONTENT_LENGTH)) { setHeaders.set(TRANSFER_ENCODING, CHUNKED); diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java index 1fd488d2be..61437319a0 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyPipeline.java @@ -5,20 +5,23 @@ */ package io.jooby.internal.netty; +import java.util.List; import java.util.concurrent.ScheduledExecutorService; -import java.util.function.Supplier; import io.jooby.Context; -import io.jooby.internal.netty.http2.NettyHttp2Configurer; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOutboundHandler; -import io.netty.channel.ChannelPipeline; +import io.netty.buffer.ByteBuf; +import io.netty.channel.*; import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.ByteToMessageDecoder; import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http2.*; +import io.netty.handler.ssl.ApplicationProtocolNames; +import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; import io.netty.handler.ssl.SslContext; public class NettyPipeline extends ChannelInitializer { private static final String H2_HANDSHAKE = "h2-handshake"; + private final SslContext sslContext; private final HttpDecoderConfig decoderConfig; private final Context.Selector contextSelector; @@ -53,45 +56,61 @@ public NettyPipeline( this.compressionLevel = compressionLevel; } - private NettyHandler createHandler(ScheduledExecutorService executor) { - return new NettyHandler( - new NettyDateService(executor), - contextSelector, - maxRequestSize, - maxFormFields, - bufferSize, - defaultHeaders, - http2); - } - @Override public void initChannel(SocketChannel ch) { - var p = ch.pipeline(); + ChannelPipeline p = ch.pipeline(); + if (sslContext != null) { p.addLast("ssl", sslContext.newHandler(ch.alloc())); } - // https://github.com/jooby-project/jooby/issues/3433: - // using new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, true) - // cause the bug, for now I'm going to remove flush consolidating handler... doesn't seem to - // help much - // p.addLast(new FlushConsolidationHandler(DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, false)); + if (http2) { - var settings = new Http2Settings(maxRequestSize, sslContext != null); - var extension = - new Http2Extension( - settings, this::http11, this::http11Upgrade, this::http2, this::http2c); - var configurer = new NettyHttp2Configurer(); - var handshake = configurer.configure(extension); - - p.addLast(H2_HANDSHAKE, handshake); - additionalHandlers(p); - p.addLast("handler", createHandler(ch.eventLoop())); + p.addLast(H2_HANDSHAKE, setupHttp2Handshake(sslContext != null)); } else { - http11(p); + setupHttp11(p); + } + } + + private void setupHttp11(ChannelPipeline p) { + p.addLast("codec", createServerCodec()); + addCommonHandlers(p); + p.addLast("handler", createHandler(p.channel().eventLoop())); + } + + private void setupHttp2(ChannelPipeline pipeline) { + var frameCodec = + Http2FrameCodecBuilder.forServer() + .initialSettings(Http2Settings.defaultSettings().maxFrameSize((int) maxRequestSize)) + .build(); + + pipeline.addLast("http2-codec", frameCodec); + pipeline.addLast( + "http2-multiplex", new Http2MultiplexHandler(new Http2StreamInitializer(this))); + } + + private void setupHttp11Upgrade(ChannelPipeline pipeline) { + var serverCodec = createServerCodec(); + pipeline.addLast("codec", serverCodec); + + pipeline.addLast( + "h2upgrade", + new HttpServerUpgradeHandler( + serverCodec, + protocol -> "h2c".equals(protocol.toString()) ? createH2CUpgradeCodec() : null, + (int) maxRequestSize)); + + addCommonHandlers(pipeline); + pipeline.addLast("handler", createHandler(pipeline.channel().eventLoop())); + } + + private ChannelInboundHandler setupHttp2Handshake(boolean secure) { + if (secure) { + return new AlpnHandler(this); } + return new Http2PrefaceOrHttpHandler(this); } - private void additionalHandlers(ChannelPipeline p) { + private void addCommonHandlers(ChannelPipeline p) { if (expectContinue) { p.addLast("expect-continue", new HttpServerExpectContinueHandler()); } @@ -101,32 +120,80 @@ private void additionalHandlers(ChannelPipeline p) { } } - private void http2(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private Http2ServerUpgradeCodec createH2CUpgradeCodec() { + return new Http2ServerUpgradeCodec( + Http2FrameCodecBuilder.forServer().build(), + new Http2MultiplexHandler(new Http2StreamInitializer(this))); } - private void http2c(ChannelPipeline pipeline, Supplier factory) { - pipeline.addAfter(H2_HANDSHAKE, "http2", factory.get()); + private NettyHandler createHandler(ScheduledExecutorService executor) { + return new NettyHandler( + new NettyDateService(executor), + contextSelector, + maxRequestSize, + maxFormFields, + bufferSize, + defaultHeaders, + http2); } - private void http11Upgrade( - ChannelPipeline pipeline, Supplier factory) { - // direct http1 to h2c - HttpServerCodec serverCodec = new HttpServerCodec(decoderConfig); - pipeline.addAfter(H2_HANDSHAKE, "codec", serverCodec); - pipeline.addAfter( - "codec", - "h2upgrade", - new HttpServerUpgradeHandler( - serverCodec, - protocol -> protocol.toString().equals("h2c") ? factory.get() : null, - (int) maxRequestSize)); + private NettyServerCodec createServerCodec() { + return new NettyServerCodec(decoderConfig); } - private void http11(ChannelPipeline p) { - p.addLast("decoder", new NettyRequestDecoder(decoderConfig)); - p.addLast("encoder", new NettyResponseEncoder()); - additionalHandlers(p); - p.addLast("handler", createHandler(p.channel().eventLoop())); + /** Handles the transition from ALPN to H1 or H2 */ + private static class AlpnHandler extends ApplicationProtocolNegotiationHandler { + private final NettyPipeline pipeline; + + AlpnHandler(NettyPipeline pipeline) { + super(ApplicationProtocolNames.HTTP_1_1); + this.pipeline = pipeline; + } + + @Override + protected void configurePipeline(ChannelHandlerContext ctx, String protocol) { + if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11(ctx.pipeline()); + } + } + } + + /** Detects HTTP/2 connection preface or upgrades to H1/H2C */ + private static class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { + private static final int PRI = 0x50524920; // "PRI " + private final NettyPipeline pipeline; + + Http2PrefaceOrHttpHandler(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + if (in.readableBytes() < 4) return; + + if (in.getInt(in.readerIndex()) == PRI) { + pipeline.setupHttp2(ctx.pipeline()); + } else { + pipeline.setupHttp11Upgrade(ctx.pipeline()); + } + ctx.pipeline().remove(this); + } + } + + /** Initializes the child channels created for each HTTP/2 stream */ + private static class Http2StreamInitializer extends ChannelInitializer { + private final NettyPipeline pipeline; + + Http2StreamInitializer(NettyPipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected void initChannel(Channel ch) { + ch.pipeline().addLast("http2", new Http2StreamFrameToHttpObjectCodec(true)); + ch.pipeline().addLast("handler", pipeline.createHandler(ch.eventLoop())); + } } } diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java deleted file mode 100644 index 71788e2c3a..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyRequestDecoder.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.handler.codec.http.*; - -public class NettyRequestDecoder extends HttpRequestDecoder { - - private static final String GET = HttpMethod.GET.name(); - private static final String POST = HttpMethod.POST.name(); - private static final String PUT = HttpMethod.PUT.name(); - private static final String DELETE = HttpMethod.DELETE.name(); - - public NettyRequestDecoder(HttpDecoderConfig config) { - super(config); - } - - @Override - protected HttpMessage createMessage(String[] initialLine) throws Exception { - return new DefaultHttpRequest( - HttpVersion.valueOf(initialLine[2]), - valueOf(initialLine[0]), - initialLine[1], - headersFactory); - } - - @Override - protected boolean isContentAlwaysEmpty(HttpMessage msg) { - return false; - } - - private static HttpMethod valueOf(String name) { - // fast-path - if (name == GET) { - return HttpMethod.GET; - } - if (name == POST) { - return HttpMethod.POST; - } - if (name == DELETE) { - return HttpMethod.DELETE; - } - if (name == PUT) { - return HttpMethod.PUT; - } - // "slow"-path: ensure method is on upper case - return HttpMethod.valueOf(name.toUpperCase()); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java deleted file mode 100644 index c8c26cb2b1..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyResponseEncoder.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty; - -import io.netty.buffer.ByteBuf; -import io.netty.handler.codec.http.HttpHeaders; -import io.netty.handler.codec.http.HttpResponseEncoder; - -public class NettyResponseEncoder extends HttpResponseEncoder { - @Override - protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { - if (headers.getClass() == HeadersMultiMap.class) { - ((HeadersMultiMap) headers).encode(buf); - } else { - super.encodeHeaders(headers, buf); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java new file mode 100644 index 0000000000..25544286dd --- /dev/null +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyServerCodec.java @@ -0,0 +1,136 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.netty; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Queue; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.CombinedChannelDuplexHandler; +import io.netty.handler.codec.http.*; + +/** + * Copy of {@link HttpServerCodec} with a custom request method parser and optimized header response + * writer. + */ +public class NettyServerCodec + extends CombinedChannelDuplexHandler + implements HttpServerUpgradeHandler.SourceCodec { + + /** A queue that is used for correlating a request and a response. */ + private final Queue queue = new ArrayDeque(); + + private final HttpDecoderConfig decoderConfig; + + /** Creates a new instance with the specified decoder configuration. */ + public NettyServerCodec(HttpDecoderConfig decoderConfig) { + this.decoderConfig = decoderConfig; + init(new HttpServerRequestDecoder(decoderConfig), new HttpServerResponseEncoder()); + } + + /** + * Web socket looks for these two component while doing the upgrade. + * + * @param ctx Channel context. + */ + /*package*/ void webSocketHandshake(ChannelHandlerContext ctx) { + var p = ctx.pipeline(); + var codec = p.context(getClass()).name(); + p.addBefore(codec, "encoder", new HttpServerResponseEncoder()); + p.addBefore(codec, "decoder", new HttpServerRequestDecoder(decoderConfig)); + p.remove(this); + } + + /** + * Upgrades to another protocol from HTTP. Removes the {@link HttpRequestDecoder} and {@link + * HttpResponseEncoder} from the pipeline. + */ + @Override + public void upgradeFrom(ChannelHandlerContext ctx) { + ctx.pipeline().remove(this); + } + + private final class HttpServerRequestDecoder extends HttpRequestDecoder { + HttpServerRequestDecoder(HttpDecoderConfig config) { + super(config); + } + + @Override + protected HttpMessage createMessage(String[] initialLine) { + return new DefaultHttpRequest( + // Do strict version checking + HttpVersion.valueOf(initialLine[2]), + httpMethod(initialLine[0]), + initialLine[1], + headersFactory); + } + + public static HttpMethod httpMethod(String name) { + return switch (name) { + case "OPTIONS" -> HttpMethod.OPTIONS; + case "GET" -> HttpMethod.GET; + case "HEAD" -> HttpMethod.HEAD; + case "POST" -> HttpMethod.POST; + case "PUT" -> HttpMethod.PUT; + case "PATCH" -> HttpMethod.PATCH; + case "DELETE" -> HttpMethod.DELETE; + case "TRACE" -> HttpMethod.TRACE; + case "CONNECT" -> HttpMethod.CONNECT; + default -> new HttpMethod(name.toUpperCase()); + }; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List out) + throws Exception { + int oldSize = out.size(); + super.decode(ctx, buffer, out); + int size = out.size(); + for (int i = oldSize; i < size; i++) { + Object obj = out.get(i); + if (obj instanceof HttpRequest) { + queue.add(((HttpRequest) obj).method()); + } + } + } + } + + private final class HttpServerResponseEncoder extends HttpResponseEncoder { + + private HttpMethod method; + + @Override + protected void sanitizeHeadersBeforeEncode(HttpResponse msg, boolean isAlwaysEmpty) { + if (!isAlwaysEmpty + && HttpMethod.CONNECT.equals(method) + && msg.status().codeClass() == HttpStatusClass.SUCCESS) { + // Stripping Transfer-Encoding: + // See https://tools.ietf.org/html/rfc7230#section-3.3.1 + msg.headers().remove(HttpHeaderNames.TRANSFER_ENCODING); + return; + } + + super.sanitizeHeadersBeforeEncode(msg, isAlwaysEmpty); + } + + @Override + protected void encodeHeaders(HttpHeaders headers, ByteBuf buf) { + if (headers.getClass() == HeadersMultiMap.class) { + ((HeadersMultiMap) headers).encode(buf); + } else { + super.encodeHeaders(headers, buf); + } + } + + @Override + protected boolean isContentAlwaysEmpty(@SuppressWarnings("unused") HttpResponse msg) { + method = queue.poll(); + return HttpMethod.HEAD.equals(method) || super.isContentAlwaysEmpty(msg); + } + } +} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java deleted file mode 100644 index 0af06a6f70..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2OrHttp11Handler.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.function.Consumer; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.ssl.ApplicationProtocolNames; -import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; - -class Http2OrHttp11Handler extends ApplicationProtocolNegotiationHandler { - - private final Consumer http2; - private final Consumer http1; - - public Http2OrHttp11Handler(Consumer http1, Consumer http2) { - super(ApplicationProtocolNames.HTTP_1_1); - this.http2 = http2; - this.http1 = http1; - } - - @Override - public void configurePipeline(final ChannelHandlerContext ctx, final String protocol) { - if (ApplicationProtocolNames.HTTP_1_1.equals(protocol)) { - http1.accept(ctx.pipeline()); - } else if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { - http2.accept(ctx.pipeline()); - } else { - throw new IllegalStateException("Unknown protocol: " + protocol); - } - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java deleted file mode 100644 index 51900925d3..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/Http2PrefaceOrHttpHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import java.util.List; -import java.util.function.Consumer; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelPipeline; -import io.netty.handler.codec.ByteToMessageDecoder; - -class Http2PrefaceOrHttpHandler extends ByteToMessageDecoder { - - private static final int PRI = 0x50524920; - - private Consumer http1; - - private Consumer http2; - - public Http2PrefaceOrHttpHandler( - Consumer http1, Consumer http2) { - this.http1 = http1; - this.http2 = http2; - } - - @Override - protected void decode(final ChannelHandlerContext ctx, final ByteBuf in, final List out) { - if (in.readableBytes() < 4) { - return; - } - - if (in.getInt(in.readerIndex()) == PRI) { - http2.accept(ctx.pipeline()); - } else { - http1.accept(ctx.pipeline()); - } - - ctx.pipeline().remove(this); - } -} diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java deleted file mode 100644 index 3e0de59faf..0000000000 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/http2/NettyHttp2Configurer.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.netty.http2; - -import static io.netty.handler.codec.http.HttpScheme.HTTP; - -import io.jooby.internal.netty.Http2Extension; -import io.netty.channel.ChannelInboundHandler; -import io.netty.handler.codec.http.HttpScheme; -import io.netty.handler.codec.http2.DefaultHttp2Connection; -import io.netty.handler.codec.http2.Http2ConnectionHandler; -import io.netty.handler.codec.http2.Http2FrameLogger; -import io.netty.handler.codec.http2.Http2ServerUpgradeCodec; -import io.netty.handler.codec.http2.HttpToHttp2ConnectionHandlerBuilder; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapter; -import io.netty.handler.codec.http2.InboundHttp2ToHttpAdapterBuilder; -import io.netty.handler.logging.LogLevel; - -public class NettyHttp2Configurer { - - public ChannelInboundHandler configure(Http2Extension extension) { - if (extension.isSecure()) { - return new Http2OrHttp11Handler( - extension::http11, - pipeline -> - extension.http2( - pipeline, - settings -> newHttp2Handler(settings.getMaxRequestSize(), HttpScheme.HTTPS))); - } else { - return new Http2PrefaceOrHttpHandler( - pipeline -> - extension.http11Upgrade( - pipeline, - settings -> - new Http2ServerUpgradeCodec( - newHttp2Handler(settings.getMaxRequestSize(), HTTP))), - pipeline -> - extension.http2c( - pipeline, settings -> newHttp2Handler(settings.getMaxRequestSize(), HTTP))); - } - } - - private Http2ConnectionHandler newHttp2Handler(int maxRequestSize, HttpScheme scheme) { - DefaultHttp2Connection connection = new DefaultHttp2Connection(true); - InboundHttp2ToHttpAdapter listener = - new InboundHttp2ToHttpAdapterBuilder(connection) - .propagateSettings(false) - .validateHttpHeaders(true) - .maxContentLength(maxRequestSize) - .build(); - - return new HttpToHttp2ConnectionHandlerBuilder() - .frameListener(listener) - .frameLogger(new Http2FrameLogger(LogLevel.DEBUG)) - .connection(connection) - .httpScheme(scheme) - .build(); - } -} diff --git a/tests/src/test/java/io/jooby/test/FeaturedTest.java b/tests/src/test/java/io/jooby/test/FeaturedTest.java index de84f0a75f..d780ac3d85 100644 --- a/tests/src/test/java/io/jooby/test/FeaturedTest.java +++ b/tests/src/test/java/io/jooby/test/FeaturedTest.java @@ -272,7 +272,7 @@ public void rawPath(ServerTestRunner runner) { runner .define( app -> { - app.get("/{code}", ctx -> ctx.getRequestPath()); + app.get("/{code}", Context::getRequestPath); }) .ready( client -> { diff --git a/tests/src/test/java/io/jooby/test/Http2Test.java b/tests/src/test/java/io/jooby/test/Http2Test.java index 09594ab77b..f0405aeda1 100644 --- a/tests/src/test/java/io/jooby/test/Http2Test.java +++ b/tests/src/test/java/io/jooby/test/Http2Test.java @@ -5,9 +5,13 @@ */ package io.jooby.test; +import static io.jooby.test.TestUtil._19kb; +import static okhttp3.RequestBody.create; import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Phaser; @@ -26,19 +30,62 @@ import org.junit.jupiter.api.BeforeAll; import com.google.common.collect.ImmutableMap; -import io.jooby.ServerOptions; -import io.jooby.SneakyThrows; -import io.jooby.StatusCode; +import io.jooby.*; +import io.jooby.jackson.JacksonModule; import io.jooby.junit.ServerTest; import io.jooby.junit.ServerTestRunner; +import okhttp3.*; import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; public class Http2Test { + @ServerTest + public void h2body(ServerTestRunner runner) { + runner + .options(new ServerOptions().setHttp2(true).setSecurePort(8443)) + .define( + app -> { + app.install(new JacksonModule()); + app.post( + "/h2/multipart", + ctx -> { + try (var f = ctx.file("f")) { + return ctx.getScheme() + + ":" + + ctx.getProtocol() + + ":" + + new String(f.bytes(), StandardCharsets.UTF_8); + } + }); + + app.post( + "/h2/body", + ctx -> { + return ctx.getScheme() + ":" + ctx.getProtocol() + ":" + ctx.body(Map.class); + }); + }) + .ready( + (http, https) -> { + https.post( + "/h2/multipart", + new MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "f", "19kb.txt", create(_19kb, MediaType.parse("text/plain"))) + .build(), + rsp -> { + assertEquals("https:HTTP/2.0:" + _19kb, rsp.body().string()); + }); + + https.post( + "/h2/body", + create("{\"foo\": \"bar\"}", MediaType.parse("application/json")), + rsp -> { + assertEquals("https:HTTP/2.0:" + "{foo=bar}", rsp.body().string()); + }); + }); + } + @ServerTest public void http2(ServerTestRunner runner) { runner From 45e18793f5c20a78e8b5376eb96ea76580d9e347 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:02:46 +0000 Subject: [PATCH 04/65] build(deps): bump swagger-ui-dist in /modules/jooby-swagger-ui Bumps [swagger-ui-dist](https://github.com/swagger-api/swagger-ui) from 5.31.2 to 5.32.0. - [Release notes](https://github.com/swagger-api/swagger-ui/releases) - [Commits](https://github.com/swagger-api/swagger-ui/compare/v5.31.2...v5.32.0) --- updated-dependencies: - dependency-name: swagger-ui-dist dependency-version: 5.32.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- modules/jooby-swagger-ui/package-lock.json | 8 ++++---- modules/jooby-swagger-ui/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/jooby-swagger-ui/package-lock.json b/modules/jooby-swagger-ui/package-lock.json index 00948a416e..611f5d1450 100644 --- a/modules/jooby-swagger-ui/package-lock.json +++ b/modules/jooby-swagger-ui/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.0", "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.31.2" + "swagger-ui-dist": "^5.32.0" } }, "node_modules/@scarf/scarf": { @@ -20,9 +20,9 @@ "license": "Apache-2.0" }, "node_modules/swagger-ui-dist": { - "version": "5.31.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.2.tgz", - "integrity": "sha512-uIoesCjDcxnAKj/C/HG5pjHZMQs2K/qmqpUlwLxxaVryGKlgm8Ri+VOza5xywAqf//pgg/hW16RYa6dDuTCOSg==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" diff --git a/modules/jooby-swagger-ui/package.json b/modules/jooby-swagger-ui/package.json index ce24a7a792..fa7244423f 100644 --- a/modules/jooby-swagger-ui/package.json +++ b/modules/jooby-swagger-ui/package.json @@ -4,7 +4,7 @@ "private": true, "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.31.2" + "swagger-ui-dist": "^5.32.0" }, "scarfSettings": { "enabled": false From a50c024ee6ee7a3efb1e65f1d9e786cfa8ce4c32 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 13:15:10 -0300 Subject: [PATCH 05/65] tRPC implementation - generate `d.ts` file from MVC routes - introduce `@Trpc`, `@Trpc.Query` and `@Trpc.Mutation` - implement `GET` on APT - ref #3863 --- .../main/java/io/jooby/annotation/Trpc.java | 62 ++ .../main/java/io/jooby/trpc/TrpcError.java | 12 + .../main/java/io/jooby/trpc/TrpcResponse.java | 13 + .../main/java/io/jooby/trpc/TrpcResult.java | 8 + modules/jooby-apt/pom.xml | 8 + .../java/io/jooby/apt/JoobyProcessor.java | 2 +- .../java/io/jooby/internal/apt/CodeBlock.java | 4 + .../io/jooby/internal/apt/HttpMethod.java | 17 +- .../java/io/jooby/internal/apt/HttpPath.java | 28 +- .../io/jooby/internal/apt/MvcContext.java | 20 + .../java/io/jooby/internal/apt/MvcRoute.java | 261 ++++++-- .../src/test/java/tests/i3863/C3863.java | 22 + .../src/test/java/tests/i3863/C3863_.java | 53 ++ .../src/test/java/tests/i3863/Issue3863.java | 21 + .../src/test/java/tests/i3863/U3863.java | 8 + .../java/io/jooby/jackson3/PrimitiveTest.java | 21 + .../io/jooby/openapi/OpenAPIGenerator.java | 2 +- modules/jooby-trcp/pom.xml | 137 ++++ .../java/io/jooby/trpc/TrpcGenerator.java | 594 ++++++++++++++++++ .../test/java/io/jooby/trpc/i3863/C3863.java | 33 + .../jooby/trpc/i3863/TrpcGeneratorTest.java | 68 ++ .../test/java/io/jooby/trpc/i3863/U3863.java | 8 + modules/pom.xml | 3 + 23 files changed, 1352 insertions(+), 53 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/annotation/Trpc.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcError.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcResponse.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcResult.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/C3863.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/C3863_.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/U3863.java create mode 100644 modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java create mode 100644 modules/jooby-trcp/pom.xml create mode 100644 modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java diff --git a/jooby/src/main/java/io/jooby/annotation/Trpc.java b/jooby/src/main/java/io/jooby/annotation/Trpc.java new file mode 100644 index 0000000000..d85b3cab25 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/Trpc.java @@ -0,0 +1,62 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a controller class or a specific route method for tRPC TypeScript generation. */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Trpc { + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface Mutation { + /** + * Custom name for the tRPC procedure. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a + * class, this overrides the generated namespace/router name. + * + * @return The custom procedure name. Empty by default, which means the generator will use the + * Java method or class name. + */ + String value() default ""; + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface Query { + /** + * Custom name for the tRPC procedure. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a + * class, this overrides the generated namespace/router name. + * + * @return The custom procedure name. Empty by default, which means the generator will use the + * Java method or class name. + */ + String value() default ""; + } + + /** + * Custom name for the tRPC procedure or namespace. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a class, + * this overrides the generated namespace/router name. + * + * @return The custom procedure or namespace name. Empty by default, which means the generator + * will use the Java method or class name. + */ + String value() default ""; +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcError.java b/jooby/src/main/java/io/jooby/trpc/TrpcError.java new file mode 100644 index 0000000000..a32dbbad92 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcError.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.util.Map; + +public record TrpcError(ErrorDetail error) { + public record ErrorDetail(String message, int code, Map data) {} +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java new file mode 100644 index 0000000000..2b16ea6af9 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +public record TrpcResponse(TrpcResult result) { + + public static TrpcResponse success(T data) { + return new TrpcResponse<>(new TrpcResult<>(data)); + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResult.java b/jooby/src/main/java/io/jooby/trpc/TrpcResult.java new file mode 100644 index 0000000000..70b3337ac3 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResult.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +public record TrpcResult(T data) {} diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 454298979c..d3bf806dcd 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -25,6 +25,13 @@ test + + io.jooby + jooby-jackson3 + ${jooby.version} + test + + jakarta.validation jakarta.validation-api @@ -76,6 +83,7 @@ test + io.jooby jooby-test diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 5beba9abd5..e67006a707 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -297,7 +297,7 @@ private void buildRouteRegistry(Map registry, TypeElemen } }); if (!currentType.equals(superType)) { - // edge-case #1: when controller has no method and extends another class which has. + // edge-case #1: when a controller has no method and extends another class which has. // edge-case #2: some odd usage a controller could be empty. // See https://github.com/jooby-project/jooby/issues/3656 if (registry.containsKey(superType)) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java index 86e2c43d85..3fa2a04857 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java @@ -35,6 +35,10 @@ public static CharSequence semicolon(boolean kt) { return kt ? "" : ";"; } + public static CharSequence var(boolean kt) { + return kt ? "val " : "var "; + } + public static String indent(int count) { return " ".repeat(count); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 4481b567a3..9b4fb93bbd 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -27,7 +27,13 @@ public enum HttpMethod implements AnnotationSupport { OPTIONS, PATCH, POST, - PUT; + PUT, + // Special + tRPC( + List.of( + "io.jooby.annotation.Trpc", + "io.jooby.annotation.Trpc.Mutation", + "io.jooby.annotation.Trpc.Query")); private final List annotations; HttpMethod(String... packages) { @@ -36,6 +42,10 @@ public enum HttpMethod implements AnnotationSupport { this.annotations = packageList.stream().map(it -> it + "." + name()).toList(); } + HttpMethod(List annotations) { + this.annotations = annotations; + } + /** * Look at path attribute over HTTP method annotation (like io.jooby.annotation.GET) or fallback * to Path annotation. @@ -76,6 +86,11 @@ public List produces(Element element) { return mediaType(element, HttpMediaType.Produces, "produces"::equals); } + public boolean matches(Element element) { + return annotations.stream() + .anyMatch(it -> AnnotationSupport.findAnnotationByName(element, it) != null); + } + private List mediaType( Element element, HttpMediaType mediaType, Predicate filter) { var path = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java index 60e285d9e7..696250aef2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java @@ -32,10 +32,24 @@ public List getAnnotations() { * @return Path or empty list. */ public List path(Collection hierarchy) { + return path(hierarchy, getAnnotations()); + } + + /** + * Find path on type hierarchy. It goes back at hierarchy until it finds a Path annotation. + * + * @param hierarchy Type hierarchy. + * @return Path or empty list. + */ + public List trpcPath(Collection hierarchy) { + return path(hierarchy, List.of("io.jooby.annotation.Trpc")); + } + + private List path(Collection hierarchy, List annotations) { var prefix = Collections.emptyList(); var it = hierarchy.iterator(); while (prefix.isEmpty() && it.hasNext()) { - prefix = path(it.next()); + prefix = path(it.next(), annotations); } return prefix; } @@ -47,7 +61,17 @@ public List path(Collection hierarchy) { * @return Path or empty list. */ public List path(Element element) { - return getAnnotations().stream() + return path(element, getAnnotations()); + } + + /** + * Find Path from method or class. + * + * @param element Method or Class. + * @return Path or empty list. + */ + private List path(Element element, List annotations) { + return annotations.stream() .map(it -> AnnotationSupport.findAnnotationByName(element, it)) .filter(Objects::nonNull) .findFirst() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java index 6a4a441a9c..99ab9443ab 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java @@ -87,6 +87,26 @@ public ProcessingEnvironment getProcessingEnvironment() { return processingEnvironment; } + /** + * Find path from trpc route method and router type. This method scan and expand path base on the + * annotation present at method or class level. + * + * @param owner Router type. + * @param exec Method. + * @param procedure Child path. + * @return List of possible paths. + */ + public List trpcPath(TypeElement owner, ExecutableElement exec, String procedure) { + var prefix = HttpPath.PATH.trpcPath(superTypes(owner)); + if (prefix.isEmpty()) { + return procedure.isEmpty() ? Collections.singletonList("/") : List.of(procedure); + } + return prefix.stream() + .map(root -> root.equals("/") ? procedure : root + procedure) + .distinct() + .toList(); + } + /** * Find path from route method and router type. This method scan and expand path base on the * annotation present at method or class level. diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index b50423de28..e75cf175ff 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -7,7 +7,7 @@ import static io.jooby.internal.apt.AnnotationSupport.*; import static io.jooby.internal.apt.CodeBlock.*; -import static java.lang.System.lineSeparator; +import static java.lang.System.*; import static java.util.Optional.ofNullable; import java.util.*; @@ -31,6 +31,7 @@ public class MvcRoute { private final boolean suspendFun; private boolean uncheckedCast; private final boolean hasBeanValidation; + private final List trpcMethods = new ArrayList<>(); public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { this.context = context; @@ -142,19 +143,36 @@ public List generateMapping(boolean kt) { var lastHttpMethod = lastRoute && entries.get(entries.size() - 1).equals(e); var annotation = e.getKey(); var httpMethod = HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString()); + var dslMethod = annotation.getSimpleName().toString().toLowerCase(); var paths = context.path(router.getTargetType(), method, annotation); + var targetMethod = methodName; + if (httpMethod == HttpMethod.tRPC) { + httpMethod = trpcMethod(method); + if (httpMethod == null) { + throw new IllegalArgumentException( + "tRPC method not found: " + + method.getSimpleName() + + "() in " + + router.getTargetType()); + } + dslMethod = httpMethod.name().toLowerCase(); + paths = List.of(trpcPath(method)); + targetMethod = + "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1); + trpcMethods.add(new TrpcMethod(httpMethod, targetMethod)); + } for (var path : paths) { var lastLine = lastHttpMethod && paths.get(paths.size() - 1).equals(path); block.add(javadocLink); block.add( statement( isSuspendFun() ? "" : "app.", - annotation.getSimpleName().toString().toLowerCase(), + dslMethod, "(", string(leadingSlash(path)), ", ", context.pipeline( - getReturnTypeHandler(), methodReference(kt, thisRef, methodName)))); + getReturnTypeHandler(), methodReference(kt, thisRef, targetMethod)))); if (context.nonBlocking(getReturnTypeHandler()) || isSuspendFun()) { block.add(statement(indent(2), ".setNonBlocking(true)")); } @@ -245,7 +263,6 @@ public List generateHandlerCall(boolean kt) { paramList.add(generatedParameter); } - var throwsException = !method.getThrownTypes().isEmpty(); var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); @@ -256,48 +273,8 @@ public List generateHandlerCall(boolean kt) { returnTypeString = Types.PROJECTED + "<" + returnType + ">"; } - boolean nullable = false; - if (kt) { - nullable = - method.getAnnotationMirrors().stream() - .map(AnnotationMirror::getAnnotationType) - .map(Objects::toString) - .anyMatch(NULLABLE); - if (throwsException) { - buffer.add(statement("@Throws(Exception::class)")); - } - if (isSuspendFun()) { - buffer.add( - statement( - "suspend ", - "fun ", - returnTypeGenerics, - getGeneratedName(), - "(handler: io.jooby.kt.HandlerContext): ", - returnTypeString, - " {")); - buffer.add(statement(indent(2), "val ctx = handler.ctx")); - } else { - buffer.add( - statement( - "fun ", - returnTypeGenerics, - getGeneratedName(), - "(ctx: io.jooby.Context): ", - returnTypeString, - " {")); - } - } else { - buffer.add( - statement( - "public ", - returnTypeGenerics, - returnTypeString, - " ", - getGeneratedName(), - "(io.jooby.Context ctx) ", - throwsException ? "throws Exception {" : "{")); - } + var nullable = + methodCallHeader(kt, getGeneratedName(), buffer, returnTypeGenerics, returnTypeString); if (returnType.isVoid()) { String statusCode; if (annotationMap.size() == 1) { @@ -381,11 +358,152 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement("}", System.lineSeparator())); if (uncheckedCast) { if (kt) { - buffer.add(0, statement("@Suppress(\"UNCHECKED_CAST\")")); + buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + } else { + buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } + } + for (var trpcMethod : trpcMethods) { + buffer.addAll(generateTrpcMethod(kt, trpcMethod)); + } + return buffer; + } + + private boolean methodCallHeader( + boolean kt, + String methodName, + ArrayList buffer, + String returnTypeGenerics, + String returnTypeString) { + var throwsException = !method.getThrownTypes().isEmpty(); + var nullable = false; + if (kt) { + nullable = + method.getAnnotationMirrors().stream() + .map(AnnotationMirror::getAnnotationType) + .map(Objects::toString) + .anyMatch(NULLABLE); + if (throwsException) { + buffer.add(statement("@Throws(Exception::class)")); + } + if (isSuspendFun()) { + buffer.add( + statement( + "suspend ", + "fun ", + returnTypeGenerics, + methodName, + "(handler: io.jooby.kt.HandlerContext): ", + returnTypeString, + " {")); + buffer.add(statement(indent(2), "val ctx = handler.ctx")); } else { - buffer.add(0, statement("@SuppressWarnings(\"unchecked\")")); + buffer.add( + statement( + "fun ", + returnTypeGenerics, + methodName, + "(ctx: io.jooby.Context): ", + returnTypeString, + " {")); + } + } else { + buffer.add( + statement( + "public ", + returnTypeGenerics, + returnTypeString, + " ", + methodName, + "(io.jooby.Context ctx) ", + throwsException ? "throws Exception {" : "{")); + } + return nullable; + } + + private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { + var buffer = new ArrayList(); + + var returnTypeString = + "io.jooby.trpc.TrpcResponse<" + type(kt, getReturnType().toString()) + ">"; + + var nullable = methodCallHeader(kt, trpcMethod.name, buffer, "", returnTypeString); + if (trpcMethod.method == HttpMethod.GET) { + buffer.add( + statement( + indent(2), + var(kt), + "input = ctx.query(", + string("input"), + ").value()", + semicolon(kt))); + buffer.add( + statement( + indent(2), + var(kt), + "mapper = ctx.require(tools.jackson.databind.ObjectMapper", + clazz(kt), + ")", + semicolon(kt))); + List arguments = + switch (method.getParameters().size()) { + case 0 -> List.of(); + case 1 -> { + buffer.add( + statement( + indent(2), + var(kt), + method.getParameters().getFirst().getSimpleName(), + " = mapper.readValue(input, ", + method.getParameters().getFirst().asType().toString(), + clazz(kt), + ")", + semicolon(kt))); + yield List.of(method.getParameters().getFirst().getSimpleName().toString()); + } + default -> { + buffer.add( + statement(indent(2), var(kt), "array = mapper.readTree(input)", semicolon(kt))); + var args = new ArrayList(); + for (int i = 0; i < method.getParameters().size(); i++) { + buffer.add( + statement( + indent(2), + var(kt), + method.getParameters().getFirst().getSimpleName(), + Integer.toString(i), + " = mapper.readValue(array.get(", + Integer.toString(i), + "), ", + method.getParameters().getFirst().asType().toString(), + clazz(kt), + ")", + semicolon(kt))); + args.add(method.getParameters().getFirst().getSimpleName().toString()); + } + yield args; + } + }; + controllerVar(kt, buffer); + var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + arguments.stream().collect(Collectors.joining(", ", "(", ")"))); + if (!cast.isEmpty()) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } + buffer.add(statement(indent(2), var(kt), "result = ", call, semicolon(kt))); + buffer.add( + statement( + indent(2), "return ", "io.jooby.trpc.TrpcResponse.success(result)", semicolon(kt))); } + + buffer.add(statement("}", System.lineSeparator())); return buffer; } @@ -544,4 +662,51 @@ public void setUncheckedCast(boolean value) { public boolean hasBeanValidation() { return hasBeanValidation; } + + private HttpMethod trpcMethod(Element element) { + var trpc = AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc"); + if (trpc != null) { + if (HttpMethod.GET.matches(element)) { + return HttpMethod.GET; + } + if (HttpMethod.POST.matches(element)) { + return HttpMethod.POST; + } + return null; + } + if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Query") != null) { + return HttpMethod.GET; + } + if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation") + != null) { + return HttpMethod.POST; + } + return null; + } + + public String trpcPath(Element element) { + var namespace = + Optional.ofNullable( + AnnotationSupport.findAnnotationByName( + element.getEnclosingElement(), "io.jooby.annotation.Trpc")) + .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) + .map(it -> it + ".") + .orElse(""); + + var procedure = + Stream.of( + "io.jooby.annotation.Trpc.Query", + "io.jooby.annotation.Trpc.Mutation", + "io.jooby.annotation.Trpc") + .map(it -> AnnotationSupport.findAnnotationByName(element, it)) + .filter(Objects::nonNull) + .findFirst() + .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) + .orElse(element.getSimpleName().toString()); + return Stream.of("trpc", namespace + procedure) + .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) + .collect(Collectors.joining("/", "/", "")); + } + + record TrpcMethod(HttpMethod method, String name) {} } diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java new file mode 100644 index 0000000000..9ac6d42c75 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +import io.jooby.annotation.*; + +@Trpc("users") +public class C3863 { + + @Trpc.Query + public U3863 findUser(@PathParam long id) { + return null; + } + + // @Trpc.Mutation + // public U3863 updateUser(@PathParam String id, U3863 payload) { + // return null; + // } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java new file mode 100644 index 0000000000..f929114403 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java @@ -0,0 +1,53 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +@io.jooby.annotation.Generated(C3863.class) +public class C3863_ implements io.jooby.Extension { + protected java.util.function.Function factory; + + public C3863_() { + this(io.jooby.SneakyThrows.singleton(C3863::new)); + } + + public C3863_(C3863 instance) { + setup(ctx -> instance); + } + + public C3863_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (C3863) provider.get()); + } + + public C3863_(io.jooby.SneakyThrows.Function, C3863> provider) { + setup(ctx -> provider.apply(C3863.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + public void install(io.jooby.Jooby app) throws Exception { + /** See {@link C3863#findUser(long) */ + app.get("/trpc/users.findUser", this::trpcFindUser); + + /** See {@link C3863#findUser(long) */ + app.get("/users/{id}", this::findUser); + } + + public U3863 findUser(io.jooby.Context ctx) { + var c = this.factory.apply(ctx); + return c.findUser(ctx.path("id").longValue()); + } + + public io.jooby.trpc.TrpcResponse trpcFindUser(io.jooby.Context ctx) { + var input = ctx.query("input").value(); + var mapper = ctx.require(tools.jackson.databind.ObjectMapper.class); + var arg0 = mapper.readValue(input, long.class); + var c = this.factory.apply(ctx); + var result = c.findUser(arg0); + return io.jooby.trpc.TrpcResponse.success(result); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java new file mode 100644 index 0000000000..cc1e3036e0 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3863 { + @Test + public void shouldGenerateTrpcHandler() throws Exception { + new ProcessorRunner(new C3863()) + .withSourceCode( + source -> { + System.out.println(source); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/U3863.java b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java new file mode 100644 index 0000000000..d8a96d53d7 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +public record U3863(long id, String name) {} diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java new file mode 100644 index 0000000000..83ada6f24b --- /dev/null +++ b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; + +public class PrimitiveTest { + + @Test + public void shouldParsePrimitive() { + var mapper = new ObjectMapper(); + assertEquals(1, mapper.readValue("1", long.class)); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 8f9046e07f..5b65339e9c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -142,7 +142,7 @@ public List write( } } - private Logger log = LoggerFactory.getLogger(getClass()); + private final Logger log = LoggerFactory.getLogger(getClass()); private Set debug; diff --git a/modules/jooby-trcp/pom.xml b/modules/jooby-trcp/pom.xml new file mode 100644 index 0000000000..15f208983a --- /dev/null +++ b/modules/jooby-trcp/pom.xml @@ -0,0 +1,137 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.0.16-SNAPSHOT + + jooby-trcp + jooby-trcp + + + + io.jooby + jooby + ${jooby.version} + + + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + io.github.classgraph + classgraph + 4.8.184 + + + + cz.habarta.typescript-generator + typescript-generator-core + 3.2.1263 + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + io.jooby + jooby-kotlin + ${jooby.version} + test + + + + org.jetbrains.kotlin + kotlin-stdlib + test + + + + org.jetbrains.kotlin + kotlin-reflect + test + + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + none + + + test-compile + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + none + + + + default-testCompile + none + + + java-test-compile + + testCompile + + test-compile + + + + + + diff --git a/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java new file mode 100644 index 0000000000..8120a22f66 --- /dev/null +++ b/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java @@ -0,0 +1,594 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import cz.habarta.typescript.generator.DateMapping; +import cz.habarta.typescript.generator.EnumMapping; +import cz.habarta.typescript.generator.Input; +import cz.habarta.typescript.generator.JsonLibrary; +import cz.habarta.typescript.generator.Output; +import cz.habarta.typescript.generator.Settings; +import cz.habarta.typescript.generator.TypeScriptFileType; +import cz.habarta.typescript.generator.TypeScriptGenerator; +import cz.habarta.typescript.generator.TypeScriptOutputKind; +import io.github.classgraph.ClassGraph; + +/** + * A generator that orchestrates {@code typescript-generator} to produce a tRPC-compatible + * TypeScript API definition from compiled Jooby controllers. + * + *

This tool bypasses the standard REST scanner of {@code typescript-generator}. Instead, it: + * + *

    + *
  1. Scans the compiled class directory (or explicitly added classes) for controllers marked + * with {@code @Trpc}. + *
  2. Extracts only the input and return types (DTOs) of the matching methods. + *
  3. Feeds those data models to the generator to produce clean TypeScript interfaces. + *
  4. Uses a fast, recursive type resolver to accurately map Java methods to tRPC {@code { input, + * output }} shapes. + *
  5. Appends a strict {@code AppRouter} definition to the generated file. + *
+ */ +public class TrpcGenerator { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private Path buildClassesDir; + private ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + private Path outputDir; + private String outputFile = "trpc.d.ts"; + private boolean expandLookup = false; + + private final Set> manualControllers = new LinkedHashSet<>(); + + private JsonLibrary jsonLibrary = JsonLibrary.jackson2; + private Map customTypeMappings = new LinkedHashMap<>(); + private Map customTypeNaming = new LinkedHashMap<>(); + private List importDeclarations = new ArrayList<>(); + private DateMapping mapDate = DateMapping.asString; + private EnumMapping mapEnum = EnumMapping.asInlineUnion; + + /** + * Executes the full TypeScript and tRPC generation pipeline. + * + * @throws IOException If an I/O error occurs reading classes or writing the output file. + * @throws IllegalStateException If {@code outputDir} is not configured or if no controllers are + * found. + */ + public void generate() throws IOException { + if (outputDir == null) { + throw new IllegalStateException("outputDir is required to generate the TypeScript file."); + } + + var finalOutput = outputDir.resolve(outputFile); + if (!Files.exists(outputDir)) { + Files.createDirectories(outputDir); + } + + var controllers = discoverControllers(); + controllers.addAll(manualControllers); + + if (controllers.isEmpty()) { + throw new IllegalStateException( + "No controllers were found to generate. " + + "Ensure 'buildClassesDir' points to the compiled classes directory, " + + "or use 'addController(Class)' to manually register controllers for unit testing."); + } + + // 1. Extract ONLY the Data Models (Inputs/Outputs) + var typesToGenerate = new LinkedHashSet(); + for (var controller : controllers) { + for (var method : controller.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + typesToGenerate.add(method.getGenericReturnType()); + for (var param : method.getGenericParameterTypes()) { + typesToGenerate.add(param); + } + } + } + } + + var settings = new Settings(); + settings.outputFileType = TypeScriptFileType.declarationFile; + settings.outputKind = TypeScriptOutputKind.module; + settings.classLoader = classLoader; + settings.jsonLibrary = this.jsonLibrary; + settings.mapDate = this.mapDate; + settings.mapEnum = this.mapEnum; + if (customTypeMappings != null) settings.customTypeMappings.putAll(customTypeMappings); + if (customTypeNaming != null) settings.customTypeNaming.putAll(customTypeNaming); + if (importDeclarations != null) settings.importDeclarations.addAll(importDeclarations); + + // 2. Generate standard interfaces (DTOs only) + if (!typesToGenerate.isEmpty()) { + TypeScriptGenerator.setLogger(asSlf4j(log)); + var generator = new TypeScriptGenerator(settings); + var input = Input.from(typesToGenerate.toArray(new Type[0])); + generator.generateTypeScript(input, Output.to(finalOutput.toFile())); + } + + // Safety net: If typescript-generator skipped generation (e.g., only primitive types), create + // the base file. + if (!Files.exists(finalOutput)) { + Files.writeString(finalOutput, "/* tslint:disable */\n/* eslint-disable */\n\n"); + } + + // 3. Append the exact tRPC AppRouter + appendTrpcRouter(finalOutput, controllers); + } + + private static cz.habarta.typescript.generator.Logger asSlf4j(Logger log) { + return new cz.habarta.typescript.generator.Logger() { + @Override + protected void write(Level level, String message) { + switch (level) { + case Info -> log.info(message); + case Warning -> log.warn(message.replace("Warning: ", "")); + case Error -> log.error(message.replace("Error: ", "")); + case Debug -> log.debug(message.replace("Debug: ", "")); + case Verbose -> log.trace(message); + } + } + }; + } + + /** + * Constructs and appends the tRPC {@code AppRouter} mapping to the bottom of the generated file. + * + * @param finalOutput The path to the generated output file. + * @param controllers The set of validated controller classes. + * @throws IOException If file writing fails. + */ + private void appendTrpcRouter(Path finalOutput, Set> controllers) throws IOException { + var ts = new StringBuilder(); + + ts.append("\n// --- tRPC Router Mapping ---\n\n"); + ts.append("export type AppRouter = {\n"); + + for (var controller : controllers) { + var namespace = extractNamespace(controller); + String indent = " "; // Default indent for root methods + + if (namespace != null) { + ts.append(" ").append(namespace).append(": {\n"); + indent = " "; // Increase indent for nested methods + } + + for (var method : controller.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + var params = method.getGenericParameterTypes(); + String tsInput = "void"; + if (params.length == 1) { + tsInput = resolveTsType(params[0]); + } else if (params.length > 1) { + var tuple = new ArrayList(); + for (var p : params) tuple.add(resolveTsType(p)); + tsInput = "[" + String.join(", ", tuple) + "]"; + } + + String tsOutput = resolveTsType(method.getGenericReturnType()); + + ts.append(indent) + .append(method.getName()) + .append(": { input: ") + .append(tsInput) + .append("; output: ") + .append(tsOutput) + .append(" };\n"); + } + } + + if (namespace != null) { + ts.append(" };\n"); + } + } + + ts.append("};\n"); + Files.writeString(finalOutput, ts.toString(), StandardOpenOption.APPEND); + } + + /** + * Fast, recursive type resolver to map Java types directly to TypeScript signatures. Understands + * Jooby async types, standard collections, and primitive mappings. + * + * @param type The Java type to evaluate. + * @return A valid TypeScript string representation of the type. + */ + private String resolveTsType(Type type) { + if (type == void.class || type == Void.class) return "void"; + + if (type instanceof ParameterizedType pt) { + var raw = pt.getRawType(); + var rawName = raw.getTypeName(); + + // Unwrap async types (CompletableFuture, Mono, Single, Future) + if (rawName.endsWith("CompletableFuture") + || rawName.endsWith("Single") + || rawName.endsWith("Mono") + || rawName.endsWith("Future")) { + return resolveTsType(pt.getActualTypeArguments()[0]); + } + + if (raw instanceof Class clazz) { + if (java.util.Collection.class.isAssignableFrom(clazz)) { + return resolveTsType(pt.getActualTypeArguments()[0]) + "[]"; + } + if (java.util.Map.class.isAssignableFrom(clazz)) { + return "{ [index: string]: " + resolveTsType(pt.getActualTypeArguments()[1]) + " }"; + } + if (java.util.Optional.class.isAssignableFrom(clazz)) { + return resolveTsType(pt.getActualTypeArguments()[0]) + " | null"; + } + + // Handle generic DTOs + var args = pt.getActualTypeArguments(); + var tsArgs = new ArrayList(); + for (var arg : args) tsArgs.add(resolveTsType(arg)); + return getClassName(clazz) + "<" + String.join(", ", tsArgs) + ">"; + } + } + + if (type instanceof Class clazz) { + if (clazz.isArray()) { + if (clazz.getComponentType() == byte.class) + return "string"; // Common byte[] to base64 string + return resolveTsType(clazz.getComponentType()) + "[]"; + } + + if (clazz == String.class + || clazz == char.class + || clazz == Character.class + || clazz.getName().equals("java.util.UUID")) return "string"; + if (clazz == boolean.class || clazz == Boolean.class) return "boolean"; + if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) return "number"; + + if (java.util.Date.class.isAssignableFrom(clazz) + || clazz.getName().startsWith("java.time.")) { + return mapDate == DateMapping.asString + ? "string" + : (mapDate == DateMapping.asNumber ? "number" : "Date"); + } + + return getClassName(clazz); + } + + return "any"; + } + + /** + * Evaluates the custom mappings to determine the appropriate TypeScript interface name. + * + * @param clazz The Java class being resolved. + * @return The target TypeScript interface name. + */ + private String getClassName(Class clazz) { + var fqn = clazz.getName(); + if (customTypeMappings != null && customTypeMappings.containsKey(fqn)) { + return customTypeMappings.get(fqn); + } + if (customTypeNaming != null && customTypeNaming.containsKey(fqn)) { + return customTypeNaming.get(fqn); + } + return clazz.getSimpleName(); + } + + /** + * Scans the build output directory to load and validate controller classes. + * + * @return A set of valid controller classes found on disk. + * @throws IOException If the directory scan fails. + */ + private Set> discoverControllers() { + var controllers = new LinkedHashSet>(); + + // We scope the scan strictly to the build directory for maximum speed + var classGraph = + new ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .enableMethodInfo() + .ignoreClassVisibility(); + + if (buildClassesDir != null && Files.exists(buildClassesDir)) { + classGraph.overrideClasspath(buildClassesDir.toUri().toString()); + } else if (classLoader != null) { + classGraph.overrideClassLoaders(classLoader); + } else { + return controllers; + } + + try (var scanResult = classGraph.scan()) { + for (var classInfo : scanResult.getAllClasses()) { + try { + var clazz = classInfo.loadClass(false); // loads without initializing! + + boolean includeClass = isTrpcAnnotated(clazz); + if (!includeClass && expandLookup) includeClass = hasWebAnnotation(clazz); + + if (includeClass) { + controllers.add(clazz); + } else { + // Check methods if the class itself isn't annotated + for (var method : clazz.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + controllers.add(clazz); + break; + } + } + } + } catch (Throwable ignored) { + // Safely ignore classes that throw LinkageError or NoClassDefFoundError + } + } + } + + return controllers; + } + + /** + * ClassLoader-agnostic check to see if an element has the Trpc annotation. + * + * @param element The class or method to inspect. + * @return True if annotated with {@code io.jooby.annotation.Trpc}. + */ + private boolean isTrpcAnnotated(AnnotatedElement element) { + for (Annotation a : element.getAnnotations()) { + if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { + return true; + } + } + return false; + } + + /** + * ClassLoader-agnostic check for standard web routing annotations. + * + * @param element The class or method to inspect. + * @return True if a JAX-RS or Jooby web annotation is present. + */ + private boolean hasWebAnnotation(AnnotatedElement element) { + for (Annotation a : element.getAnnotations()) { + var name = a.annotationType().getName(); + if (name.startsWith("io.jooby.annotation.") + || name.startsWith("jakarta.ws.rs.") + || name.startsWith("javax.ws.rs.")) { + return true; + } + } + return false; + } + + /** + * Extracts the target namespace for the tRPC router based on the controller. If the class is not + * annotated with @Trpc, it returns null (indicating root-level). + * + * @param controller The controller class. + * @return The determined namespace string, or null for root-level. + */ + private String extractNamespace(Class controller) { + boolean hasClassLevelTrpc = false; + + for (Annotation a : controller.getAnnotations()) { + if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { + hasClassLevelTrpc = true; + try { + var method = a.annotationType().getMethod("value"); + var value = (String) method.invoke(a); + // Explicit namespace provided: @Trpc("myNamespace") + if (value != null && !value.isBlank()) return value; + } catch (Exception ignored) { + } + } + } + + // No class-level annotation means these methods sit at the root of the router + if (!hasClassLevelTrpc) { + return null; + } + + // Class is annotated, but no explicit value provided. Derive from class name. + var name = controller.getSimpleName(); + name = Character.toLowerCase(name.charAt(0)) + name.substring(1); + return name.replace("Controller", "").replace("Resource", ""); + } + + // --- Configuration API (Getters, Setters, and Builders) --- + + /** + * Explicitly adds a controller class to the generation pipeline. Highly recommended for unit + * testing to avoid classpath scanning issues. + * + * @param controller The controller class to analyze. + */ + public void addController(Class controller) { + this.manualControllers.add(controller); + } + + /** + * @return The directory where compiled class files are located. + */ + public Path getBuildClassesDir() { + return buildClassesDir; + } + + /** + * @param buildClassesDir The directory where compiled class files are located. + */ + public void setBuildClassesDir(Path buildClassesDir) { + this.buildClassesDir = buildClassesDir; + } + + /** + * @return The class loader used to load compiled controllers. + */ + public ClassLoader getClassLoader() { + return classLoader; + } + + /** + * @param classLoader The class loader used to load compiled controllers. Defaults to context + * class loader. + */ + public void setClassLoader(ClassLoader classLoader) { + if (classLoader != null) this.classLoader = classLoader; + } + + /** + * @return The destination directory for the generated TypeScript file. + */ + public Path getOutputDir() { + return outputDir; + } + + /** + * @param outputDir The destination directory for the generated TypeScript file. + */ + public void setOutputDir(Path outputDir) { + this.outputDir = outputDir; + } + + /** + * @return The name of the generated TypeScript file. + */ + public String getOutputFile() { + return outputFile; + } + + /** + * @param outputFile The name of the generated TypeScript file. Defaults to {@code trpc.d.ts}. + */ + public void setOutputFile(String outputFile) { + if (outputFile != null && !outputFile.isBlank()) this.outputFile = outputFile; + } + + /** + * @return True if standard Jooby and JAX-RS annotations are included in the generation. + */ + public boolean isExpandLookup() { + return expandLookup; + } + + /** + * @param expandLookup Set to true to generate endpoints for standard web annotations even + * without @Trpc. + */ + public void setExpandLookup(boolean expandLookup) { + this.expandLookup = expandLookup; + } + + /** + * @return The target JSON library for data model generation. + */ + public JsonLibrary getJsonLibrary() { + return jsonLibrary; + } + + /** + * @param jsonLibrary The target JSON library used to parse field annotations. Defaults to Jackson + * 2. + */ + public void setJsonLibrary(JsonLibrary jsonLibrary) { + if (jsonLibrary != null) this.jsonLibrary = jsonLibrary; + } + + /** + * @return Custom mapping overrides translating Java types to raw TypeScript strings. + */ + public Map getCustomTypeMappings() { + return customTypeMappings; + } + + /** + * @param customTypeMappings Custom mapping overrides translating Java types to raw TypeScript + * strings. + */ + public void setCustomTypeMappings(Map customTypeMappings) { + this.customTypeMappings = customTypeMappings; + } + + /** + * @return Custom overrides for generating specific TypeScript interface names. + */ + public Map getCustomTypeNaming() { + return customTypeNaming; + } + + /** + * @param customTypeNaming Custom overrides for generating specific TypeScript interface names. + */ + public void setCustomTypeNaming(Map customTypeNaming) { + this.customTypeNaming = customTypeNaming; + } + + /** + * @return Raw import statements appended to the top of the generated file. + */ + public List getImportDeclarations() { + return importDeclarations; + } + + /** + * @param importDeclarations Raw import statements appended to the top of the generated file. + */ + public void setImportDeclarations(List importDeclarations) { + this.importDeclarations = importDeclarations; + } + + /** + * @return The mapping strategy applied to Java date types. + */ + public DateMapping getMapDate() { + return mapDate; + } + + /** + * @param mapDate The mapping strategy applied to Java date types. + */ + public void setMapDate(DateMapping mapDate) { + this.mapDate = mapDate; + } + + /** + * @return The mapping strategy applied to Java enum types. + */ + public EnumMapping getMapEnum() { + return mapEnum; + } + + /** + * @param mapEnum The mapping strategy applied to Java enum types. + */ + public void setMapEnum(EnumMapping mapEnum) { + this.mapEnum = mapEnum; + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java new file mode 100644 index 0000000000..a9e64941dc --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java @@ -0,0 +1,33 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc.i3863; + +import io.jooby.annotation.GET; +import io.jooby.annotation.POST; +import io.jooby.annotation.Path; +import io.jooby.annotation.Trpc; + +@Path("/users") +@Trpc("users") // Custom namespace +public class C3863 { + + @GET("/{id}") + @Trpc + public U3863 getUser(String id) { + return new U3863(id, "user"); + } + + @POST + @Trpc + public U3863 createUser(U3863 user) { + return user; + } + + @GET("/internal") + public String internalEndpoint() { + return "This should not be exposed to tRPC"; + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java new file mode 100644 index 0000000000..479c30f8dd --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc.i3863; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +import io.jooby.trpc.TrpcGenerator; + +class TrpcGeneratorTest { + + @Test + void shouldGenerateTrpcRouterAndModels() throws Exception { + var generator = new TrpcGenerator(); + var outputDir = Paths.get("target"); + + // Dynamically locate the test-classes directory where the sample is compiled + var testClassesDir = + Paths.get(C3863.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + + generator.setBuildClassesDir(testClassesDir); + generator.setClassLoader(Thread.currentThread().getContextClassLoader()); + generator.setOutputDir(outputDir); + generator.setOutputFile("api.d.ts"); + + generator.generate(); + + var outputFile = outputDir.resolve("api.d.ts"); + assertTrue(Files.exists(outputFile), "TypeScript file should be generated"); + + var actualContent = Files.readString(outputFile); + + var expectedContent = + """ + /* tslint:disable */ + /* eslint-disable */ + + export interface U3863 { + id: string; + name: string; + } + + // --- tRPC Router Mapping --- + + export type AppRouter = { + users: { + getUser: { input: string; output: U3863 }; + createUser: { input: U3863; output: U3863 }; + }; + }; + """; + + // Strip out the dynamic timestamp comment line + var cleanActual = + actualContent.replaceAll("// Generated using typescript-generator.*\\r?\\n", ""); + + // Assert with normalized newlines to avoid \r\n vs \n test flakes + assertThat(cleanActual).isEqualToNormalizingNewlines(expectedContent); + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java new file mode 100644 index 0000000000..55d1467b37 --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc.i3863; + +public record U3863(String id, String name) {} diff --git a/modules/pom.xml b/modules/pom.xml index 5277cd3326..fa20dd3db9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -32,6 +32,9 @@ jooby-swagger-ui jooby-redoc + + jooby-trcp + jooby-hikari jooby-jdbi From 02ea957a0eaee902a6259cb36eed22a545365332 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 14:40:45 -0300 Subject: [PATCH 06/65] doc: fix some broken styles on light theme --- docs/js/styles/theme.css | 618 +++++++++------------------------------ 1 file changed, 138 insertions(+), 480 deletions(-) diff --git a/docs/js/styles/theme.css b/docs/js/styles/theme.css index 0b781eb15e..44cf32dcb7 100644 --- a/docs/js/styles/theme.css +++ b/docs/js/styles/theme.css @@ -13,13 +13,15 @@ --bg-callout: #f3f4f6; --border-color: #e5e7eb; - --text-main: #374151; + /* Typography Colors (Adapts to Light Theme) */ + --text-main: #334155; /* Dark Slate */ --text-muted: #6b7280; + --text-bold: #0f172a; /* Near Black */ --heading-color: #111827; - --link-color: var(--jooby-blue); + --link-color: #2563eb; /* Bright Blue */ /* Code Blocks */ - --code-bg: #282c34; /* Update this to match Atom One Dark perfectly */ + --code-bg: #282c34; --code-text: #ffffff; --code-inline-bg: #f1f5f9; --code-inline-text: #be185d; @@ -29,67 +31,30 @@ --content-max-width: 900px; --border-radius: 6px; - /* Modern Developer Font Stack */ + /* Typography Stacks */ --font-code: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - /* 1. The Modern Developer System Stack - This bypasses old default fonts and uses the highly-optimized UI font of whatever OS the user is on (San Francisco on Mac, Segoe UI on Windows, Roboto on Android) */ --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } -body { - font-family: var(--font-main); - font-size: 16px; /* Ensure a solid base size */ - line-height: 1.65; /* Gives the lines a bit more breathing room */ - - /* 2. Dark Mode Legibility Magic - These three properties stop the text from looking thin/jagged on dark backgrounds */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - - /* 3. A slightly crisper text color. - Pure white (#FFF) is too harsh, but a bright slate grey pops beautifully */ - color: #e2e8f0; -} - -/* Ensure standard paragraphs use the spacing properly */ -p { - margin-top: 0; - margin-bottom: 1.25rem; -} - -/* Optional polish for your bold text to make it stand out better against the new crisp text */ -strong, b { - font-weight: 600; - color: #f8fafc; /* Slightly brighter than normal text */ -} - -pre.highlightjs, .highlightjs code { - background: var(--code-bg) !important; -} - -/* Optional: Make the line under the tabs a bit softer to match the new color */ -.switch { - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - html[data-theme="dark"] { --bg-main: #0f172a; --bg-surface: #1e293b; --bg-callout: #1e293b; --border-color: #334155; - --text-main: #cbd5e1; + /* Typography Colors (Adapts to Dark Theme) */ + --text-main: #e2e8f0; /* Light Slate */ --text-muted: #94a3b8; + --text-bold: #f8fafc; /* Near White */ --heading-color: #f8fafc; - --link-color: #38bdf8; + --link-color: #60a5fa; /* Light Blue */ --code-inline-bg: #1e293b; --code-inline-text: #f472b6; } /* ========================================================================== - 2. Base & Typography (TIGHTENED SPACING) + 2. Base & Typography ========================================================================== */ html { scroll-padding-top: 80px; } *, ::before, ::after { box-sizing: border-box; } @@ -97,11 +62,14 @@ html { scroll-padding-top: 80px; } body { background: var(--bg-main); color: var(--text-main); - font-family: 'Open Sans', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - line-height: 1.6; + font-family: var(--font-main); + font-size: 16px; + line-height: 1.65; margin: 0; padding: 0; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } /* Tighter Headings */ @@ -116,33 +84,40 @@ h1, h2, h3, h4, h5, h6 { h1 { font-size: 2.5rem; margin-top: 1em; letter-spacing: -0.02em; } h2 { font-size: 1.875rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; margin-top: 2em; } h3 { font-size: 1.5rem; } -h4 { font-size: 1.25rem; } /* 20px */ -h5 { font-size: 1.125rem; } /* 18px - Just slightly larger than body text */ +h4 { font-size: 1.25rem; } +h5 { font-size: 1.125rem; } -/* Optional: Make h6 distinct since it's the same size as body text */ h6 { - font-size: 1rem; /* 16px - Same as body */ + font-size: 1rem; color: var(--text-muted); letter-spacing: 0.05em; } -/* ASCIIDOCTOR FIX: Pull subheadings closer when they immediately follow a parent section */ +/* ASCIIDOCTOR FIX: Pull subheadings closer */ .sectionbody > .sect2:first-child > h3, .sect2 > .sect3:first-child > h4, .sect3 > .sect4:first-child > h5 { margin-top: 0.75em; } -p, table, blockquote { margin-top: 0; margin-bottom: 1rem; } +p, table, blockquote { margin-top: 0; margin-bottom: 1.25rem; } + +strong, b { font-weight: 600; color: var(--text-bold); } -a { color: var(--link-color); text-decoration: none; transition: color 0.15s ease; } +a { + color: var(--link-color); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} a:hover { text-decoration: underline; color: var(--jooby-blue-hover); } + hr { border: none; border-bottom: 1px solid var(--border-color); margin: 2rem 0; } -/* ASCIIDOCTOR FIX: Fix bloated lists caused by

tags inside

  • */ +/* ASCIIDOCTOR FIX: Fix bloated lists */ ul, ol, dl { margin-top: 0; margin-bottom: 1rem; } li { margin-bottom: 0.35rem; } -.ulist li p, .olist li p, .dlist li p { margin-bottom: 0; } /* Kills the double-spacing */ +.ulist li p, .olist li p, .dlist li p { margin-bottom: 0; } /* ========================================================================== 3. Main Content Layout @@ -234,7 +209,6 @@ html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748 ========================================================================== */ .admonitionblock { margin-bottom: 1.5rem; } -/* Override Asciidoctor's rigid table layout with modern Flexbox */ .admonitionblock > table, .admonitionblock > table > tbody, .admonitionblock > table > tbody > tr { @@ -250,7 +224,6 @@ html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748 padding: 1.25rem; } -/* Style the Left Column (Label & Icon) */ .admonitionblock td.icon { border: none; padding: 0; @@ -260,7 +233,6 @@ html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748 align-items: flex-start; } -/* The Text "Note", "Tip", etc. */ .admonitionblock td.icon .title { font-weight: 700; text-transform: uppercase; @@ -272,7 +244,6 @@ html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748 gap: 0.5rem; } -/* Style the Main Content */ .admonitionblock td.content { border: none; padding: 0; @@ -283,51 +254,26 @@ html[data-theme="dark"] #toc::-webkit-scrollbar-thumb:hover { background: #64748 .admonitionblock td.content p:last-child { margin-bottom: 0; } -/* --- Color Variants & Zero-Dependency CSS Icons --- */ - -/* NOTE (Blue) */ -.admonitionblock.note { - --admonition-color: var(--jooby-blue); - --admonition-bg: rgba(33, 150, 243, 0.05); /* Subtle 5% tint */ -} -.admonitionblock.note td.icon .title::before { - content: url('data:image/svg+xml;utf8,'); - display: block; width: 16px; height: 16px; -} +/* Variants */ +.admonitionblock.note { --admonition-color: var(--jooby-blue); --admonition-bg: rgba(33, 150, 243, 0.05); } +.admonitionblock.note td.icon .title::before { content: url('data:image/svg+xml;utf8,'); display: block; width: 16px; height: 16px; } -/* TIP (Green) */ -.admonitionblock.tip { - --admonition-color: #10b981; - --admonition-bg: rgba(16, 185, 129, 0.05); -} -.admonitionblock.tip td.icon .title::before { - content: url('data:image/svg+xml;utf8,'); - display: block; width: 16px; height: 16px; -} +.admonitionblock.tip { --admonition-color: #10b981; --admonition-bg: rgba(16, 185, 129, 0.05); } +.admonitionblock.tip td.icon .title::before { content: url('data:image/svg+xml;utf8,'); display: block; width: 16px; height: 16px; } -/* WARNING (Orange/Yellow) */ -.admonitionblock.warning { - --admonition-color: #f59e0b; - --admonition-bg: rgba(245, 158, 11, 0.05); -} -.admonitionblock.warning td.icon .title::before { - content: url('data:image/svg+xml;utf8,'); - display: block; width: 16px; height: 16px; -} +.admonitionblock.warning { --admonition-color: #f59e0b; --admonition-bg: rgba(245, 158, 11, 0.05); } +.admonitionblock.warning td.icon .title::before { content: url('data:image/svg+xml;utf8,'); display: block; width: 16px; height: 16px; } -/* IMPORTANT (Red) */ -.admonitionblock.important { - --admonition-color: #ef4444; - --admonition-bg: rgba(239, 68, 68, 0.05); -} -.admonitionblock.important td.icon .title::before { - content: url('data:image/svg+xml;utf8,'); - display: block; width: 16px; height: 16px; -} +.admonitionblock.important { --admonition-color: #ef4444; --admonition-bg: rgba(239, 68, 68, 0.05); } +.admonitionblock.important td.icon .title::before { content: url('data:image/svg+xml;utf8,'); display: block; width: 16px; height: 16px; } /* ========================================================================== 6. Code Blocks & Inline Code ========================================================================== */ +pre.highlightjs, .highlightjs code { + background: var(--code-bg) !important; +} + pre { position: relative; background: var(--code-bg); @@ -335,19 +281,15 @@ pre { padding: 1rem 1.25rem; border-radius: var(--border-radius); overflow-x: auto; - - /* Use the new font */ font-family: var(--font-code); font-size: 0.85rem; font-weight: 400; - line-height: 1.6; /* Slightly increased for better code readability */ - letter-spacing: -0.01em; /* Crisp rendering */ - + line-height: 1.6; + letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; } -/* Also update your inline code blocks */ :not(pre) > code { font-family: var(--font-code); } @@ -359,25 +301,20 @@ pre code, pre .hljs { font-size: inherit; } -/* ========================================================================== - 6. Code Blocks & Inline Code - ========================================================================== */ - -/* 1. Style the Title as a Mac/IDE File Tab */ +/* Style the Title as a Mac/IDE File Tab */ .listingblock .title { - background-color: #21252b; /* Slightly darker than the code background */ - color: #abb2bf; /* Muted terminal gray */ + background-color: #21252b; + color: #abb2bf; font-family: var(--font-code); font-size: 0.78rem; padding: 0.5rem 1.25rem; - margin-bottom: 0; /* Connects it to the block below */ + margin-bottom: 0; border-top-left-radius: var(--border-radius); border-top-right-radius: var(--border-radius); border-bottom: 1px solid rgba(0, 0, 0, 0.4); display: block; } -/* 2. Remove the top rounded corners of the code block so it attaches perfectly */ .listingblock .title + .content pre { border-top-left-radius: 0; border-top-right-radius: 0; @@ -395,7 +332,7 @@ pre code, pre .hljs { background-color: var(--code-bg); border-radius: var(--border-radius) var(--border-radius) 0 0; overflow: hidden; - border-bottom: 1px solid #444; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); margin-bottom: 0; } @@ -483,15 +420,9 @@ html[data-theme="dark"] .theme-toggle .sun-icon { display: none; } /* ========================================================================== 9. Header Anchor Links (Deep Linking) ========================================================================== */ -h2, h3, h4, h5, h6 { - position: relative; -} +h2, h3, h4, h5, h6 { position: relative; } -h2 > a.anchor, -h3 > a.anchor, -h4 > a.anchor, -h5 > a.anchor, -h6 > a.anchor { +h2 > a.anchor, h3 > a.anchor, h4 > a.anchor, h5 > a.anchor, h6 > a.anchor { position: absolute; left: -1.5rem; top: 0; @@ -502,29 +433,16 @@ h6 > a.anchor { font-weight: 400; } -h2 > a.anchor::before, -h3 > a.anchor::before, -h4 > a.anchor::before, -h5 > a.anchor::before, -h6 > a.anchor::before { - content: "#"; /* Modern hash symbol instead of the old section mark */ +h2 > a.anchor::before, h3 > a.anchor::before, h4 > a.anchor::before, h5 > a.anchor::before, h6 > a.anchor::before { + content: "#"; } -/* Show the anchor on hover */ -h2:hover > a.anchor, -h3:hover > a.anchor, -h4:hover > a.anchor, -h5:hover > a.anchor, -h6:hover > a.anchor { - opacity: 1; -} +h2:hover > a.anchor, h3:hover > a.anchor, h4:hover > a.anchor, h5:hover > a.anchor, h6:hover > a.anchor { opacity: 1; } +h2 > a.anchor:hover, h3 > a.anchor:hover, h4 > a.anchor:hover, h5 > a.anchor:hover, h6 > a.anchor:hover { color: var(--jooby-blue); } -h2 > a.anchor:hover, -h3 > a.anchor:hover, -h4 > a.anchor:hover, -h5 > a.anchor:hover, -h6 > a.anchor:hover { - color: var(--jooby-blue); +h2 > a.anchor.copied::before, h3 > a.anchor.copied::before, h4 > a.anchor.copied::before, +h5 > a.anchor.copied::before, h6 > a.anchor.copied::before { + content: "✓"; color: #10b981; } /* ========================================================================== @@ -536,7 +454,6 @@ h6 > a.anchor:hover { letter-spacing: -0.03em; margin-top: 0; margin-bottom: 1rem; - /* Optional: A subtle gradient using Jooby's blue to accent */ background: linear-gradient(135deg, var(--jooby-blue) 0%, #8b5cf6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; @@ -551,50 +468,8 @@ h6 > a.anchor:hover { } /* ========================================================================== - 12. Print Styles (For "Save to PDF") + 11. Miscellaneous Badges ========================================================================== */ -@media print { - /* Force light theme for printing */ - :root { - --bg-main: #ffffff !important; - --text-main: #000000 !important; - --heading-color: #000000 !important; - } - - body { - background: white !important; - color: black !important; - font-size: 11pt; /* Better for paper */ - } - - /* Hide UI elements */ - #toc, .theme-toggle, .clipboard, .version-selector, .switch { - display: none !important; - } - - /* Reset layout constraints */ - body.toc2, #content, #header, #footer { - padding: 0 !important; - margin: 0 !important; - max-width: 100% !important; - } - - /* Prevent awkward page breaks */ - h2, h3, h4, h5 { page-break-after: avoid; } - pre, blockquote, table, img { page-break-inside: avoid; } - - /* Show link URLs explicitly on paper */ - #content a::after { - content: " (" attr(href) ")"; - font-size: 0.85em; - color: #666; - } - /* Don't print URLs for internal anchor links */ - #content a[href^="#"]::after { - content: ""; - } -} - .badge { display: inline-block; padding: 0.15em 0.5em; @@ -612,76 +487,11 @@ h6 > a.anchor:hover { .badge.new { background: #10b981; color: #fff; } /* ========================================================================== - 13. Callouts (Code Pointers) + 12. Callouts (Code Pointers & Lists) ========================================================================== */ +pre.highlightjs code { opacity: 0; transition: opacity 0.05s ease-out; } +pre.highlightjs.badges-loaded code { opacity: 1; } -/* 1. The badge INSIDE the code block (created via JavaScript) */ -.conum-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - background-color: var(--code-inline-text); /* The Pink/Red */ - color: #ffffff; - font-size: 0.85rem; - font-weight: 700; - font-style: normal; -} - -/* 2. The list BELOW the code block */ -.colist { - margin-top: 1rem; - margin-bottom: 1.5rem; -} - -.colist table { border: none !important; background: transparent !important; margin: 0 !important;} -.colist tr { background: transparent !important; } -.colist td { border: none !important; padding: 0.4rem 0.5rem !important; vertical-align: top !important; color: var(--text-main); } -.colist td:first-child { padding-left: 0 !important; width: 2.5rem !important; } - -/* The dark circle in the list below the code block */ -.colist .conum { - display: inline-flex !important; - align-items: center; - justify-content: center; - width: 1.5rem !important; - height: 1.5rem !important; - border-radius: 50% !important; - background-color: var(--text-main) !important; - color: var(--bg-main) !important; - font-size: 0.85rem !important; - font-weight: 700 !important; - font-style: normal !important; - position: relative; -} - -/* Hide fallback text Asciidoctor injects */ -.colist i.conum[data-value] { color: transparent !important; } -.colist i.conum[data-value]::after { - content: attr(data-value); - color: var(--bg-main) !important; - position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); -} -.colist .conum + b { display: none !important; } - -/* ========================================================================== - 13. Callouts (Code Pointers & Lists) - ========================================================================== */ - -/* 1. Prevent the Flash: Keep background solid, micro-fade the text only */ -pre.highlightjs code { - opacity: 0; - transition: opacity 0.05s ease-out; -} -pre.highlightjs.badges-loaded code { - opacity: 1; -} - -/* 2. The Pink Badge INSIDE the code block */ .conum-badge { display: inline-flex; align-items: center; @@ -697,7 +507,6 @@ pre.highlightjs.badges-loaded code { flex-shrink: 0; } -/* 3. The List BELOW the code block */ .colist.arabic ol { list-style: none !important; padding-left: 0; @@ -709,12 +518,10 @@ pre.highlightjs.badges-loaded code { margin-bottom: 1.25rem; counter-increment: colist-counter; position: relative; - /* This creates a "gutter" for the badge */ padding-left: 2.5rem; min-height: 1.5rem; } -/* Draws the Dark Badge */ .colist.arabic li::before { content: counter(colist-counter); position: absolute; @@ -732,40 +539,13 @@ pre.highlightjs.badges-loaded code { font-weight: 700; } -/* Ensures all nested content (paragraphs, ulist, etc.) aligns to the right of the badge */ -.colist.arabic li > *:first-child { - display: block; - margin-top: 0; -} - -.colist.arabic li p { - margin-bottom: 0.5rem; - line-height: 1.5rem; -} - -/* Nested Bullet Lists */ -.colist.arabic li .ulist { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.colist.arabic li .ulist ul { - list-style: disc; - padding-left: 1.25rem; /* Standard bullet indentation */ - margin: 0; -} - -.colist.arabic li .ulist li { - padding-left: 0; /* Reset the callout padding for nested bullets */ - margin-bottom: 0.25rem; - counter-increment: none; -} +.colist.arabic li > *:first-child { display: block; margin-top: 0; } +.colist.arabic li p { margin-bottom: 0.5rem; line-height: 1.5rem; } -.colist.arabic li .ulist li::before { - content: none; /* No badges on nested bullets */ -} - -/* --- Utilities --- */ +.colist.arabic li .ulist { margin-top: 0.5rem; margin-bottom: 0.5rem; } +.colist.arabic li .ulist ul { list-style: disc; padding-left: 1.25rem; margin: 0; } +.colist.arabic li .ulist li { padding-left: 0; margin-bottom: 0.25rem; counter-increment: none; } +.colist.arabic li .ulist li::before { content: none; } .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; @@ -773,18 +553,11 @@ pre.highlightjs.badges-loaded code { clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } -h2 > a.anchor.copied::before, h3 > a.anchor.copied::before, h4 > a.anchor.copied::before, -h5 > a.anchor.copied::before, h6 > a.anchor.copied::before { - content: "✓"; color: #10b981; -} - /* ========================================================================== - 14. Tables + 13. Tables ========================================================================== */ - table.tableblock { width: 100%; - /* Switch back to fixed to strictly honor [cols="1,1,4"] */ table-layout: fixed !important; border-collapse: separate; border-spacing: 0; @@ -794,9 +567,7 @@ table.tableblock { overflow: hidden; } -/* 1. Reset all columns to allow wrapping by default */ -table.tableblock td, -table.tableblock th { +table.tableblock td, table.tableblock th { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border-color); font-size: 0.9rem; @@ -807,120 +578,53 @@ table.tableblock th { overflow-wrap: anywhere; } -/* 2. Header Polish */ table.tableblock thead th { background-color: var(--bg-surface); font-weight: 600; border-bottom: 2px solid var(--border-color); color: var(--heading-color); - white-space: nowrap; /* Keep headers clean on one line */ + white-space: nowrap; } -/* 3. Smart First Column Handling - If you have NOT defined specific columns, we force it to fit. - If you HAVE defined [cols], we let it wrap to fit your proportions. */ table.tableblock:not(:has(colgroup col[style*="width"])) td:first-child { white-space: nowrap; - width: 1%; /* Shrink-wrap effect */ -} - -/* 4. Column Spacing & Visuals */ -table.tableblock td:first-child { - font-weight: 500; - color: var(--heading-color); -} - -/* Ensure code blocks inside tables don't have extra margins */ -table.tableblock td p { - margin: 0; -} -table.tableblock td p + p { - margin-top: 0.5rem; + width: 1%; } +table.tableblock td:first-child { font-weight: 500; color: var(--heading-color); } +table.tableblock td p { margin: 0; } +table.tableblock td p + p { margin-top: 0.5rem; } table.tableblock tbody tr:hover { background-color: var(--bg-callout); } table.tableblock tbody tr:last-child td { border-bottom: none; } /* ========================================================================== - 15. Keyboard Shortcuts (kbd) + 14. Keyboard Shortcuts (kbd) ========================================================================== */ kbd { display: inline-block; padding: 0.1rem 0.4rem; - font-family: var(--font-code); /* Uses JetBrains Mono for that tech feel */ + font-family: var(--font-code); font-size: 0.8rem; font-weight: 500; color: var(--text-main); background-color: var(--bg-surface); - /* The "3D" Key Effect */ border: 1px solid var(--border-color); border-radius: 4px; - box-shadow: - 0 1px 0 rgba(0, 0, 0, 0.2), - inset 0 0 0 2px var(--bg-main); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 0 0 2px var(--bg-main); margin: 0 0.15rem; vertical-align: middle; line-height: 1.2; } /* ========================================================================== - 16. Responsive / Mobile Adjustments + 15. Responsive / Mobile Adjustments ========================================================================== */ -@media screen and (max-width: 768px) { - /* 1. Create a top bar so the button isn't just floating over text */ - #header { - padding-top: 4rem !important; /* Push content down to clear the bar */ - } +/* Hidden elements on Desktop */ +.mobile-nav-bar { display: none; } - .mobile-nav-bar { - display: flex; - align-items: center; - justify-content: space-between; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 3.5rem; - background-color: var(--bg-main); - border-bottom: 1px solid var(--border-color); - padding: 0 1.5rem; - z-index: 10001; /* Higher than the sidebar */ - } - - /* 2. Reposition the button inside the new bar */ - #menu-toggle { - position: static !important; /* Remove the fixed positioning from before */ - margin-left: auto; - } -} - -@media screen and (max-width: 768px) { - /* 1. Make tables scrollable horizontally without breaking the page */ - .tableblock { - display: block; - width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ - } - - /* 2. Prevent the "First Column" from taking up too much room on mobile */ - table.tableblock td:first-child { - white-space: normal !important; /* Allow wrapping on small screens */ - min-width: 120px; - } - - /* 3. Slightly reduce padding to save precious horizontal space */ - table.tableblock th, - table.tableblock td { - padding: 0.5rem 0.75rem !important; - font-size: 0.85rem; - } -} - -/* Hamburger Button Styling */ .hamburger { - display: none; /* Hidden by default on Desktop */ + display: none; flex-direction: column; justify-content: space-around; width: 2rem; @@ -941,130 +645,84 @@ kbd { } /* Animate Hamburger to X */ -.hamburger.open span:nth-child(1) { - transform: rotate(45deg) translate(5px, 5px); -} -.hamburger.open span:nth-child(2) { - opacity: 0; -} -.hamburger.open span:nth-child(3) { - transform: rotate(-45deg) translate(7px, -6px); -} +.hamburger.open span:nth-child(1) { transform: rotate(45deg) translate(5px, 5px); } +.hamburger.open span:nth-child(2) { opacity: 0; } +.hamburger.open span:nth-child(3) { transform: rotate(-45deg) translate(7px, -6px); } @media screen and (max-width: 768px) { - /* 1. Force the navigation to be a fixed mobile drawer */ + /* Mobile Header Top Bar */ + #header { padding-top: 4rem !important; } + + .mobile-nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 3.5rem; + background-color: var(--bg-main); + border-bottom: 1px solid var(--border-color); + padding: 0 1.5rem; + z-index: 10001; + } + + .hamburger { display: flex; } + #menu-toggle { position: static !important; margin-left: auto; } + + /* Slide-out Sidebar Menu */ #toc, .nav-container, #sidebar { position: fixed !important; top: 0 !important; - left: -100% !important; /* Start off-screen */ - width: 80% !important; /* Take up most of the screen */ + left: -100% !important; + width: 80% !important; max-width: 300px !important; height: 100vh !important; - background-color: var(--bg-main) !important; /* Ensure it's not transparent */ - z-index: 9999 !important; /* Sit on top of everything */ + background-color: var(--bg-main) !important; + z-index: 9999 !important; transition: left 0.3s ease-in-out !important; - display: block !important; /* Ensure it's not hidden with display:none */ + display: block !important; box-shadow: 5px 0 15px rgba(0,0,0,0.5); - padding: 2rem 1rem !important; + padding: 4rem 1rem 2rem 1rem !important; /* Spaced to clear nav bar */ overflow-y: auto !important; } - /* 2. Slide in when the 'active' class is applied */ - #toc.active, .nav-container.active, #sidebar.active { - left: 0 !important; - } - - /* 3. Ensure the hamburger button stays visible and on top */ - #menu-toggle { - display: flex !important; - position: fixed !important; - top: 1rem; - right: 1rem; - z-index: 10000 !important; - background: var(--bg-surface); - padding: 5px; - border-radius: var(--border-radius); - } -} - -@media screen and (max-width: 768px) { - .hamburger { - display: flex; /* Show on Mobile */ - position: fixed; - top: 1rem; - right: 1rem; - } + #toc.active, .nav-container.active, #sidebar.active { left: 0 !important; } - /* Target your specific sidebar class (usually .nav-container or #toc) */ - #sidebar, .nav-container { - position: fixed; - top: 0; - left: -100%; /* Hide off-screen */ - width: 280px; - height: 100vh; - background: var(--bg-main); - transition: left 0.3s ease-in-out; - z-index: 1000; - box-shadow: 2px 0 10px rgba(0,0,0,0.3); - } - - /* When the 'active' class is added via JS, slide it in */ - #sidebar.active, .nav-container.active { - left: 0; + /* Mobile Tables */ + .tableblock { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } + table.tableblock td:first-child { white-space: normal !important; min-width: 120px; } + table.tableblock th, table.tableblock td { padding: 0.5rem 0.75rem !important; font-size: 0.85rem; } } /* ========================================================================== - 17. Print Styles (Ink-Friendly PDF) + 16. Print Styles (Ink-Friendly PDF) ========================================================================== */ - @media print { - /* 1. Force a clean white background and black text for legibility */ - body { - background: white !important; - color: black !important; - font-size: 11pt; - } - - /* 2. Remove the sidebar, header, and footer to focus on the content */ - #header, #footer, #sidebar, .nav-container, .edit-link { - display: none !important; - } - - #content { - width: 100% !important; - margin: 0 !important; - padding: 0 !important; - } + body { background: white !important; color: black !important; font-size: 11pt; } + #header, #footer, #sidebar, .nav-container, .edit-link, .mobile-nav-bar, .theme-toggle, .clipboard, .switch { display: none !important; } + #content { width: 100% !important; margin: 0 !important; padding: 0 !important; } + body.toc2, #header, #footer { padding: 0 !important; max-width: 100% !important; } - /* 3. Convert Code Blocks to a light theme for the printer */ pre { background: #f5f5f5 !important; color: #333 !important; border: 1px solid #ddd !important; - page-break-inside: avoid; /* Prevents code from splitting across pages */ - } - - pre code { - color: #333 !important; - } - - /* 4. Ensure Tables expand and borders are visible */ - table.tableblock { - border: 1px solid #999 !important; - page-break-inside: auto; - } - - tr { page-break-inside: avoid; - page-break-after: auto; } + pre code { color: #333 !important; } - /* 5. Show the actual URL next to links (useful for paper) */ - a[href^="http"]:after { - content: " (" attr(href) ")"; - font-size: 90%; - color: #666; - } -} + table.tableblock { border: 1px solid #999 !important; page-break-inside: auto; } + tr { page-break-inside: avoid; page-break-after: auto; } + h2, h3, h4, h5 { page-break-after: avoid; } + pre, blockquote, img { page-break-inside: avoid; } + a[href^="http"]:after { content: " (" attr(href) ")"; font-size: 90%; color: #666; } + #content a[href^="#"]::after { content: ""; } +} From 6aa39803655b5252c1f0a551cda01d5342362c47 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 14:42:49 -0300 Subject: [PATCH 07/65] prepare for next development cycle --- jooby/pom.xml | 2 +- modules/jooby-apt/pom.xml | 2 +- modules/jooby-avaje-inject/pom.xml | 2 +- modules/jooby-avaje-jsonb/pom.xml | 2 +- modules/jooby-avaje-validator/pom.xml | 2 +- modules/jooby-awssdk-v1/pom.xml | 2 +- modules/jooby-awssdk-v2/pom.xml | 2 +- modules/jooby-bom/pom.xml | 4 ++-- modules/jooby-caffeine/pom.xml | 2 +- modules/jooby-camel/pom.xml | 2 +- modules/jooby-cli/pom.xml | 2 +- modules/jooby-commons-email/pom.xml | 2 +- modules/jooby-conscrypt/pom.xml | 2 +- modules/jooby-db-scheduler/pom.xml | 2 +- modules/jooby-distribution/pom.xml | 2 +- modules/jooby-ebean/pom.xml | 2 +- modules/jooby-flyway/pom.xml | 2 +- modules/jooby-freemarker/pom.xml | 2 +- modules/jooby-gradle-setup/pom.xml | 2 +- modules/jooby-graphiql/pom.xml | 2 +- modules/jooby-graphql/pom.xml | 2 +- modules/jooby-gson/pom.xml | 2 +- modules/jooby-guice/pom.xml | 2 +- modules/jooby-handlebars/pom.xml | 2 +- modules/jooby-hibernate-validator/pom.xml | 2 +- modules/jooby-hibernate/pom.xml | 2 +- modules/jooby-hikari/pom.xml | 2 +- modules/jooby-jackson/pom.xml | 2 +- modules/jooby-jackson3/pom.xml | 2 +- modules/jooby-jasypt/pom.xml | 2 +- modules/jooby-jdbi/pom.xml | 2 +- modules/jooby-jetty/pom.xml | 2 +- modules/jooby-jstachio/pom.xml | 2 +- modules/jooby-jte/pom.xml | 2 +- modules/jooby-jwt/pom.xml | 2 +- modules/jooby-kafka/pom.xml | 2 +- modules/jooby-kotlin/pom.xml | 2 +- modules/jooby-log4j/pom.xml | 2 +- modules/jooby-logback/pom.xml | 2 +- modules/jooby-maven-plugin/pom.xml | 2 +- modules/jooby-metrics/pom.xml | 2 +- modules/jooby-mutiny/pom.xml | 2 +- modules/jooby-netty/pom.xml | 2 +- modules/jooby-openapi/pom.xml | 2 +- modules/jooby-pac4j/pom.xml | 2 +- modules/jooby-pebble/pom.xml | 2 +- modules/jooby-quartz/pom.xml | 2 +- modules/jooby-reactor/pom.xml | 2 +- modules/jooby-redis/pom.xml | 2 +- modules/jooby-redoc/pom.xml | 2 +- modules/jooby-rocker/pom.xml | 2 +- modules/jooby-run/pom.xml | 2 +- modules/jooby-rxjava3/pom.xml | 2 +- modules/jooby-stork/pom.xml | 2 +- modules/jooby-swagger-ui/pom.xml | 2 +- modules/jooby-test/pom.xml | 2 +- modules/jooby-thymeleaf/pom.xml | 2 +- modules/jooby-undertow/pom.xml | 2 +- modules/jooby-vertx-mysql-client/pom.xml | 2 +- modules/jooby-vertx-pg-client/pom.xml | 2 +- modules/jooby-vertx-sql-client/pom.xml | 2 +- modules/jooby-vertx/pom.xml | 2 +- modules/jooby-whoops/pom.xml | 2 +- modules/jooby-yasson/pom.xml | 2 +- modules/pom.xml | 2 +- pom.xml | 4 ++-- tests/pom.xml | 2 +- 67 files changed, 69 insertions(+), 69 deletions(-) diff --git a/jooby/pom.xml b/jooby/pom.xml index d6b781528e..981f7880a1 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.16 + 4.0.17-SNAPSHOT jooby jooby diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 2ee8a6add5..8481f2a0b5 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 4180caa86e..ec9c370e78 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index 409ca6cf0c..89d1b32f37 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index c30788ec2b..e87d67feb6 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index 82d2ff98ea..0c16c95a7d 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 8b4fb9ff15..5dd1cc0cb9 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-awssdk-v2 jooby-awssdk-v2 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 3cba8f7fc9..5d332b25cc 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT io.jooby jooby-bom jooby-bom pom - 4.0.16 + 4.0.17-SNAPSHOT Jooby (Bill of Materials) https://jooby.io diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index a6c318be9b..19a5775e28 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index b05c86f2c2..334aa3f15c 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index 4e931f8a9b..a771f3a1d1 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index b648eba77c..46e715dfb3 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index 5deeb9a6d8..4c58ad7140 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index c19f0a776c..59a7ea3688 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-db-scheduler jooby-db-scheduler diff --git a/modules/jooby-distribution/pom.xml b/modules/jooby-distribution/pom.xml index 4b16a1d559..5f4fe26b22 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index 4e9a33afd4..f24e82764f 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index ff6f5b2f6a..3db984eb70 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index 3fe53adefa..8201f461ca 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index f3484c813a..1ede79d726 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index e2034de259..44c54bbb8c 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 120fedd68b..9e43f2f66b 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-graphql jooby-graphql diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index 8ed17eb338..83370ad13a 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index a8a6afe8f3..f29b33c1a4 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index 5c0d7a89f8..c33ae3432b 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index a1d658b579..e71e2cfee7 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 91c07d0072..de3e44ba6d 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 515a16bf56..be3d242c7d 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-hikari jooby-hikari diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index 80bc59429e..e1eeea18d6 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jackson jooby-jackson diff --git a/modules/jooby-jackson3/pom.xml b/modules/jooby-jackson3/pom.xml index aa4e989af9..5d89a2d3a2 100644 --- a/modules/jooby-jackson3/pom.xml +++ b/modules/jooby-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jackson3 jooby-jackson3 diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index da4fd2dacc..35452ce231 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jasypt jooby-jasypt diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 39dd6522f7..70fcdd1997 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index f0026fa07f..74c6761d40 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jetty jooby-jetty diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index a964fb17ff..4b16f666ed 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index 48aa7ccfa2..487bf91243 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index f678afdc39..24bd896969 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index eab93e6182..1c62d73183 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index 24a195024a..8ed3661e85 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-kotlin jooby-kotlin diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index 4d8d916f5a..00d7b69463 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index b82e84ed39..b9a398bb84 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index 41844833ae..af499970ce 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index 49def3c1b3..5e34b68132 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index 8395662b4c..56360aee4d 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index f1e56b022c..98002431b0 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-netty jooby-netty diff --git a/modules/jooby-openapi/pom.xml b/modules/jooby-openapi/pom.xml index 89fb3ca557..4250af55ff 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-openapi jooby-openapi diff --git a/modules/jooby-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 41b609c9b8..15374a865c 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index b6614d5b40..f9ac02db08 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index 07ab7c2b5d..cc6cbbad21 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 0f07fbbd89..a8de103f7b 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 4cab040da8..a48d30fe08 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index 5e41fc411b..808e6ab099 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index f3b894163c..758d872862 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index 1de34adc7f..10072428d1 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index e053dbcf5b..7a265c7041 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index 04e7fb33e8..9a95383eee 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-stork diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index 93935c2ffe..fcecf1ce95 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 0552096161..a29876366e 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index 755986cba1..6ecbfca019 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index 99493b903f..792b72c602 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-undertow jooby-undertow diff --git a/modules/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 2fd0564a9f..7ca7ee37c0 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 685056b626..93fd12fad8 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index d7f0ac67d6..a248ed01b8 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index e091423053..e22d957475 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index 42614abab1..968dd61650 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index 6f9a5f4681..6d418d0a78 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.0.16 + 4.0.17-SNAPSHOT jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 9510e9ab68..2e05b5bcce 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.0.16 + 4.0.17-SNAPSHOT modules diff --git a/pom.xml b/pom.xml index 6aa5a44b26..9bc2167ade 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.0.16 + 4.0.17-SNAPSHOT pom jooby-project @@ -211,7 +211,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2026-03-02T16:38:34Z + 2026-03-02T17:42:39Z UTF-8 etc${file.separator}source${file.separator}formatter.sh diff --git a/tests/pom.xml b/tests/pom.xml index 9a69f4e060..a8c930a1b8 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.0.16 + 4.0.17-SNAPSHOT tests tests From 569bc04ce8fbeb6519f989e76fe5dbf4ef169626 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 14:53:44 -0300 Subject: [PATCH 08/65] - fix typo on module - ref #3863 --- modules/{jooby-trcp => jooby-trpc}/pom.xml | 6 +++--- .../src/main/java/io/jooby/trpc/TrpcGenerator.java | 0 .../src/test/java/io/jooby/trpc/i3863/C3863.java | 0 .../test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java | 0 .../src/test/java/io/jooby/trpc/i3863/U3863.java | 0 modules/pom.xml | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename modules/{jooby-trcp => jooby-trpc}/pom.xml (97%) rename modules/{jooby-trcp => jooby-trpc}/src/main/java/io/jooby/trpc/TrpcGenerator.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/C3863.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/U3863.java (100%) diff --git a/modules/jooby-trcp/pom.xml b/modules/jooby-trpc/pom.xml similarity index 97% rename from modules/jooby-trcp/pom.xml rename to modules/jooby-trpc/pom.xml index 15f208983a..9e84263f92 100644 --- a/modules/jooby-trcp/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -6,10 +6,10 @@ io.jooby modules - 4.0.16-SNAPSHOT + 4.0.17-SNAPSHOT - jooby-trcp - jooby-trcp + jooby-trpc + jooby-trpc diff --git a/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java similarity index 100% rename from modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java rename to modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java diff --git a/modules/pom.xml b/modules/pom.xml index 841c08d41b..be324dbec9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -33,7 +33,7 @@ jooby-redoc - jooby-trcp + jooby-trpc jooby-hikari From a2b6924999378016cadcab5e5adf126751fd6b0d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 19:00:22 -0300 Subject: [PATCH 09/65] - add basic ErrorHandler logic - ref #3863 --- jooby/src/main/java/io/jooby/StatusCode.java | 9 ++++ .../main/java/io/jooby/trpc/TrpcError.java | 6 ++- .../java/io/jooby/trpc/TrpcErrorCode.java | 51 +++++++++++++++++++ .../java/io/jooby/trpc/TrpcErrorHandler.java | 35 +++++++++++++ .../main/java/io/jooby/trpc/TrpcModule.java | 17 +++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcModule.java diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java index 0292bcf902..92195514bd 100644 --- a/jooby/src/main/java/io/jooby/StatusCode.java +++ b/jooby/src/main/java/io/jooby/StatusCode.java @@ -738,6 +738,14 @@ public final class StatusCode { public static final StatusCode REQUEST_HEADER_FIELDS_TOO_LARGE = new StatusCode(REQUEST_HEADER_FIELDS_TOO_LARGE_CODE, "Request Header Fields Too Large"); + /** {@code 499 The client aborted the request before completion}. */ + public static final int CLIENT_CLOSED_REQUEST_CODE = 499; + + /** {@code 499 The client aborted the request before completion}. */ + public static final StatusCode CLIENT_CLOSED_REQUEST = + new StatusCode( + CLIENT_CLOSED_REQUEST_CODE, "The client aborted the request before completion"); + // --- 5xx Server Error --- /** @@ -1025,6 +1033,7 @@ public static StatusCode valueOf(final int statusCode) { case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED; case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS; case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE; + case CLIENT_CLOSED_REQUEST_CODE -> CLIENT_CLOSED_REQUEST; case SERVER_ERROR_CODE -> SERVER_ERROR; case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED; case BAD_GATEWAY_CODE -> BAD_GATEWAY; diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcError.java b/jooby/src/main/java/io/jooby/trpc/TrpcError.java index a32dbbad92..f7753ad46c 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcError.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcError.java @@ -7,6 +7,8 @@ import java.util.Map; -public record TrpcError(ErrorDetail error) { - public record ErrorDetail(String message, int code, Map data) {} +public class TrpcError extends RuntimeException { + public TrpcError(String message, int code, Map data) { + super(message); + } } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java new file mode 100644 index 0000000000..88ea7c1e77 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java @@ -0,0 +1,51 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import io.jooby.StatusCode; + +public enum TrpcErrorCode { + PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), + BAD_REQUEST(-32600, StatusCode.BAD_REQUEST), + INTERNAL_SERVER_ERROR(-32603, StatusCode.SERVER_ERROR), + UNAUTHORIZED(-32001, StatusCode.UNAUTHORIZED), + FORBIDDEN(-32003, StatusCode.FORBIDDEN), + NOT_FOUND(-32004, StatusCode.NOT_FOUND), + METHOD_NOT_SUPPORTED(-32005, StatusCode.METHOD_NOT_ALLOWED), + TIMEOUT(-32008, StatusCode.REQUEST_TIMEOUT), + CONFLICT(-32009, StatusCode.CONFLICT), + PRECONDITION_FAILED(-32012, StatusCode.PRECONDITION_FAILED), + PAYLOAD_TOO_LARGE(-32013, StatusCode.REQUEST_ENTITY_TOO_LARGE), + UNPROCESSABLE_CONTENT(-32022, StatusCode.UNPROCESSABLE_ENTITY), + TOO_MANY_REQUESTS(-32029, StatusCode.TOO_MANY_REQUESTS), + CLIENT_CLOSED_REQUEST(-32099, StatusCode.CLIENT_CLOSED_REQUEST); + + private final int rpcCode; + private final StatusCode statusCode; + + TrpcErrorCode(int rpcCode, StatusCode statusCode) { + this.rpcCode = rpcCode; + this.statusCode = statusCode; + } + + public int getRpcCode() { + return rpcCode; + } + + public StatusCode getStatusCode() { + return statusCode; + } + + /** Helper to map a standard Jooby HTTP status code to the closest tRPC equivalent. */ + public static TrpcErrorCode of(StatusCode status) { + for (var code : values()) { + if (code.statusCode.value() == status.value()) { + return code; + } + } + return INTERNAL_SERVER_ERROR; // Fallback + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java new file mode 100644 index 0000000000..2ade60e2ce --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.StatusCode; + +public class TrpcErrorHandler implements ErrorHandler { + @Override + public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) { + if (ctx.getRequestPath().startsWith("/trpc/")) { + + var trpcCode = TrpcErrorCode.of(code); + + Map errorData = + Map.of( + "code", trpcCode.name(), + "httpStatus", code.value(), + "path", ctx.getRequestPath().replace("/trpc/", "")); + + var errorDetail = + new TrpcError.ErrorDetail(cause.getMessage(), trpcCode.getRpcCode(), errorData); + var trpcResponse = new TrpcError(errorDetail); + + ctx.setResponseCode(code).render(trpcResponse); + } + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java new file mode 100644 index 0000000000..639dc4e10b --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; + +public class TrpcModule implements Extension { + @Override + public void install(@NonNull Jooby application) throws Exception { + application.error(new TrpcErrorHandler()); + } +} From d921e6ea332e548840f0fb03c8ae5fadb5d67ef7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 19:36:56 -0300 Subject: [PATCH 10/65] doc: add Algolia DocSearch fix #3861 --- docs/asciidoc/core.adoc | 2 +- docs/asciidoc/docinfo-footer.html | 28 ++++++++++++ docs/asciidoc/docinfo.html | 2 + docs/js/styles/theme.css | 75 +++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/asciidoc/core.adoc b/docs/asciidoc/core.adoc index fd813fd0cf..b3c31b5a76 100644 --- a/docs/asciidoc/core.adoc +++ b/docs/asciidoc/core.adoc @@ -1,6 +1,6 @@ == Core [.lead] -The foundational building blocks of your Jooby application. This section covers how to bootstrap the server, manage application environments and configuration, and leverage the modular architecture that makes Jooby both lightweight and highly extensible. +The heart of the Jooby development experience. This section defines the Request-Response Pipeline, covering everything from expressive routing and path patterns to managing the Context and crafting fluid responses. It is the essential guide to building the logic that powers your web applications. include::routing.adoc[] diff --git a/docs/asciidoc/docinfo-footer.html b/docs/asciidoc/docinfo-footer.html index 7ef6b48b49..081c2d2dd2 100644 --- a/docs/asciidoc/docinfo-footer.html +++ b/docs/asciidoc/docinfo-footer.html @@ -155,3 +155,31 @@ } }); + + + + diff --git a/docs/asciidoc/docinfo.html b/docs/asciidoc/docinfo.html index 27704b6ebf..a68b19183a 100644 --- a/docs/asciidoc/docinfo.html +++ b/docs/asciidoc/docinfo.html @@ -1,5 +1,7 @@ + +