diff --git a/docs/asciidoc/websocket.adoc b/docs/asciidoc/websocket.adoc index 2ad9483d31..c0c9908ef9 100644 --- a/docs/asciidoc/websocket.adoc +++ b/docs/asciidoc/websocket.adoc @@ -168,6 +168,122 @@ import io.jooby.jackson.Jackson2Module } ---- +==== Declarative definition + +You can implement the same WebSocket as above using annotated classes in declarative style. +Ensure that `jooby-apt` is in the annotation processor path, annotate the class with javadoc:annotation.Path[], +and mark methods with javadoc:annotation.ws.OnConnect[], javadoc:annotation.ws.OnMessage[], javadoc:annotation.ws.OnClose[], and javadoc:annotation.ws.OnError[]. Compile code to generate an extension javadoc:Extension[] and register it by calling javadoc:Jooby[ws, io.jooby.Extension]. + +When a lifecycle method **returns** a value, that value is written to the client automatically: plain text or binary for `String`, `byte[]`, and `ByteBuffer`, and structured values (for example JSON) using the same encoders as in <>. Alternatively, use a **void** method and send with `ws.send(...)` on the javadoc:WebSocket[] argument. + +.Java +[source,java,role="primary"] +---- +@Path("/chat/{room}") // <1> +public class ChatSocket { + + @OnConnect + public String onConnect(WebSocket ws, Context ctx) { // <2> + return "welcome"; + } + + @OnMessage + public Map onMessage(WebSocket ws, Context ctx, WebSocketMessage message) { // <3> + return Map.of("echo", message.value()); + // ws.send(message.value()); // <4> + } + + @OnClose + public void onClose(WebSocket ws, Context ctx, WebSocketCloseStatus status) {} + + @OnError + public void onError(WebSocket ws, Context ctx, Throwable cause) {} +} + +// Application startup: +{ + ws(new ChatSocketWs_()); // <5> +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Path("/chat/{room}") // <1> +class ChatSocket { + + @OnConnect + fun onConnect(ws: WebSocket, ctx: Context): String { // <2> + return "welcome" + } + + @OnMessage + fun onMessage(ws: WebSocket, ctx: Context, message: WebSocketMessage): Map { // <3> + return mapOf("echo" to message.value()) + // ws.send(message.value()) // <4> + } + + @OnClose + fun onClose(ws: WebSocket, ctx: Context, status: WebSocketCloseStatus) {} + + @OnError + fun onError(ws: WebSocket, ctx: Context, cause: Throwable) {} +} + +// Application startup: +{ + ws(ChatSocketWs_()) // <5> +} +---- + +<1> WebSocket route patterns for this handler. +<2> Returning a value sends it to the client without calling `send`. +<3> Return a value for automatic encoding (see <>) +<4> You still can use `ws.send(...)` if method return type is `void`. +<5> Register the generated extension with javadoc:Jooby[ws, io.jooby.Extension]. + +`@OnMessage` handlers also support parsing messages into structured data, similar to MVC methods: + +.Java +[source,java,role="primary"] +---- +@Path("/chat/{room}") +public class ChatSocket { + + record ChatMessage(String username, String message, String type) {} + + @OnMessage + public Map onMessage(ChatMessage message) { // <1> + return Map.of("echo", message); + } + + ... +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Path("/chat/{room}") +class ChatSocket { + + data class ChatMessage( + val username: String, + val message: String, + val type: String + ) + + @OnMessage + fun onMessage(message: ChatMessage): Map { // <1> + return mapOf("echo" to message) + } + + ... +} + +---- +<1> WebSocket message is automatically decoded into `ChatMessage` structure. + ==== Options ===== Connection Timeouts @@ -192,3 +308,6 @@ websocket.maxSize = 128K ---- See the Typesafe Config documentation for the supported https://github.com/lightbend/config/blob/master/HOCON.md#size-in-bytes-format[size in bytes format]. + + + diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index 13b50a1537..ea65b320a1 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -520,6 +520,16 @@ public Route.Set mvc(Extension router) { } } + /** + * Add websocket routes from a generated handler extension. + * + * @param router Websocket extension. + * @return Route set. + */ + public Route.Set ws(Extension router) { + return mvc(router); + } + @Override public Route ws(String pattern, WebSocket.Initializer handler) { return router.ws(pattern, handler); diff --git a/jooby/src/main/java/io/jooby/annotation/ws/OnClose.java b/jooby/src/main/java/io/jooby/annotation/ws/OnClose.java new file mode 100644 index 0000000000..a83b9f8ff8 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/ws/OnClose.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation.ws; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks method as WebSocket close callback. + * + * @author kliushnichenko + * @since 4.4.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface OnClose { +} diff --git a/jooby/src/main/java/io/jooby/annotation/ws/OnConnect.java b/jooby/src/main/java/io/jooby/annotation/ws/OnConnect.java new file mode 100644 index 0000000000..39bbfeeae1 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/ws/OnConnect.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.annotation.ws; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks method as WebSocket open callback. + * + * @author kliushnichenko + * @since 4.4.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface OnConnect {} diff --git a/jooby/src/main/java/io/jooby/annotation/ws/OnError.java b/jooby/src/main/java/io/jooby/annotation/ws/OnError.java new file mode 100644 index 0000000000..eecb66a915 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/ws/OnError.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.annotation.ws; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks method as WebSocket error callback. + * + * @author kliushnichenko + * @since 4.4.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface OnError {} diff --git a/jooby/src/main/java/io/jooby/annotation/ws/OnMessage.java b/jooby/src/main/java/io/jooby/annotation/ws/OnMessage.java new file mode 100644 index 0000000000..84a981aa65 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/ws/OnMessage.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.annotation.ws; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks method as WebSocket incoming message callback. + * + * @author kliushnichenko + * @since 4.4.1 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface OnMessage {} 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 10dfe27291..76576ca5b9 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 @@ -25,6 +25,8 @@ import io.jooby.internal.apt.*; +import io.jooby.internal.apt.ws.WsRouter; + /** Process jooby/jakarta annotation and generate source code from MVC controllers. */ @SupportedOptions({ DEBUG, @@ -155,6 +157,11 @@ public boolean process(Set annotations, RoundEnvironment if (!trpcRouter.isEmpty()) { activeRouters.add(trpcRouter); } + + var wsRouter = WsRouter.parse(context, controller); + if (!wsRouter.isEmpty()) { + activeRouters.add(wsRouter); + } } verifyBeanValidationDependency(activeRouters); @@ -276,6 +283,11 @@ public Set getSupportedAnnotationTypes() { supportedTypes.add("io.jooby.annotation.mcp.McpPrompt"); supportedTypes.add("io.jooby.annotation.mcp.McpResource"); supportedTypes.add("io.jooby.annotation.mcp.McpServer"); + // Add WS Annotations + supportedTypes.add("io.jooby.annotation.ws.OnConnect"); + supportedTypes.add("io.jooby.annotation.ws.OnClose"); + supportedTypes.add("io.jooby.annotation.ws.OnMessage"); + supportedTypes.add("io.jooby.annotation.ws.OnError"); return supportedTypes; } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 6a691cf2bc..c3fa5053b1 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -183,4 +183,8 @@ private List annotationFromAnnotationType(Element el public boolean isRequireBeanValidation() { return requireBeanValidation; } + + public VariableElement variableElement() { + return parameter; + } } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index 7f4f09b500..83d7a9b019 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -82,26 +82,12 @@ private Optional mediaType(Function> lookup) { .collect(Collectors.joining(", ", "java.util.List.of(", ")"))); } - private String javadocComment(boolean kt, String routerName) { - if (kt) { - return CodeBlock.statement("/** See [", routerName, ".", getMethodName(), "]", " */"); - } - return CodeBlock.statement( - "/** See {@link ", - routerName, - "#", - getMethodName(), - "(", - String.join(", ", getRawParameterTypes(true, false)), - ") */"); - } - public List generateMapping(boolean kt, String routerName, boolean isLastRoute) { List block = new ArrayList<>(); var methodName = getGeneratedName(); var returnType = getReturnType(); var paramString = String.join(", ", getJavaMethodSignature(kt)); - var javadocLink = javadocComment(kt, routerName); + var javadocLink = seeControllerMethodJavadoc(kt, routerName); var attributeGenerator = new RouteAttributesGenerator(context, hasBeanValidation); var httpMethod = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java index c580323122..2f8341a759 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/WebRoute.java @@ -73,7 +73,7 @@ public List getParameters(boolean skipCoroutine) { .toList(); } - static String leadingSlash(String path) { + public static String leadingSlash(String path) { if (path == null || path.isEmpty() || path.equals("/")) { return "/"; } @@ -124,6 +124,25 @@ public List getRawParameterTypes( .toList(); } + public String seeControllerMethodJavadoc(boolean kt, CharSequence controllerSimpleName) { + if (kt) { + return CodeBlock.statement( + "/** See [", controllerSimpleName, ".", getMethodName(), "]", " */"); + } + return CodeBlock.statement( + "/** See {@link ", + controllerSimpleName, + "#", + getMethodName(), + "(", + String.join(", ", getRawParameterTypes(true, false)), + ")} */"); + } + + public String seeControllerMethodJavadoc(boolean kt) { + return seeControllerMethodJavadoc(kt, getRouter().getTargetType().getSimpleName()); + } + /** * Returns the return type of the route method. Used to determine if the route returns a reactive * type that requires static imports. diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsLifecycle.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsLifecycle.java new file mode 100644 index 0000000000..2c4df1afb2 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsLifecycle.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.internal.apt.ws; + +public enum WsLifecycle { + CONNECT, + MESSAGE, + CLOSE, + ERROR +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsParamTypes.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsParamTypes.java new file mode 100644 index 0000000000..3c060b6e25 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsParamTypes.java @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.ws; + +import java.util.EnumMap; +import java.util.Set; + +final class WsParamTypes { + + static final String RAW_WEBSOCKET = "io.jooby.WebSocket"; + static final String RAW_CONTEXT = "io.jooby.Context"; + static final String RAW_MESSAGE = "io.jooby.WebSocketMessage"; + static final String RAW_CLOSE_STATUS = "io.jooby.WebSocketCloseStatus"; + static final String RAW_THROWABLE = "java.lang.Throwable"; + + private static final EnumMap> ALLOWED_TYPES = + new EnumMap<>(WsLifecycle.class); + + static { + ALLOWED_TYPES.put(WsLifecycle.CONNECT, Set.of(RAW_WEBSOCKET, RAW_CONTEXT)); + ALLOWED_TYPES.put( + WsLifecycle.MESSAGE, Set.of(RAW_WEBSOCKET, RAW_CONTEXT, RAW_MESSAGE)); + ALLOWED_TYPES.put( + WsLifecycle.CLOSE, Set.of(RAW_WEBSOCKET, RAW_CONTEXT, RAW_CLOSE_STATUS)); + ALLOWED_TYPES.put( + WsLifecycle.ERROR, Set.of(RAW_WEBSOCKET, RAW_CONTEXT, RAW_THROWABLE)); + } + + static Set getAllowedTypes(WsLifecycle lifecycle) { + return ALLOWED_TYPES.get(lifecycle); + } + + static String generateArgumentName(String rawType) { + return switch (rawType) { + case RAW_WEBSOCKET -> "ws"; + case RAW_CONTEXT -> "ctx"; + case RAW_MESSAGE -> "message"; + case RAW_CLOSE_STATUS -> "status"; + case RAW_THROWABLE -> "cause"; + default -> null; + }; + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRoute.java new file mode 100644 index 0000000000..9493ee10ae --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRoute.java @@ -0,0 +1,175 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.ws; + +import io.jooby.internal.apt.*; + +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import java.util.Map; +import java.util.StringJoiner; + +import static io.jooby.internal.apt.CodeBlock.*; +import static java.lang.System.lineSeparator; + +public class WsRoute extends WebRoute { + + private static final Map LIFECYCLE_ANNOTATIONS = + Map.of( + "io.jooby.annotation.ws.OnConnect", WsLifecycle.CONNECT, + "io.jooby.annotation.ws.OnMessage", WsLifecycle.MESSAGE, + "io.jooby.annotation.ws.OnClose", WsLifecycle.CLOSE, + "io.jooby.annotation.ws.OnError", WsLifecycle.ERROR); + + private WsLifecycle wsLifecycle; + + public WsRoute(WsRouter router, ExecutableElement method) { + super(router, method); + chekWsAnnotations(); + } + + private void chekWsAnnotations() { + for (var entry : LIFECYCLE_ANNOTATIONS.entrySet()) { + if (AnnotationSupport.findAnnotationByName(this.method, entry.getKey()) != null) { + this.wsLifecycle = entry.getValue(); + validateLifecycleParameters(context, method); + break; + } + } + } + + public WsLifecycle getWsLifecycle() { + return wsLifecycle; + } + + @Override + public boolean hasBeanValidation() { + return false; + } + + @Override + public TypeDefinition getReturnType() { + var types = context.getProcessingEnvironment().getTypeUtils(); + return new TypeDefinition(types, method.getReturnType()); + } + + public void appendBody(boolean kt, StringBuilder buffer, String indent) { + buffer.append(statement(indent, var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); + + TypeDefinition wsReturnType = getReturnType(); + var expr = invocation(kt); + + if (isUncheckedCast()) { + buffer + .append(indent) + .append( + kt ? "@Suppress(\"UNCHECKED_CAST\") " : "@SuppressWarnings(\"unchecked\") ") + .append(lineSeparator()); + } + + if (wsReturnType.isVoid()) { + buffer.append(statement(indent, expr, semicolon(kt))); + return; + } + + buffer.append( + statement( + indent, kt ? "val" : "var", " __wsReturn = ", expr, semicolon(kt))); + String rawErasure = wsReturnType.getRawType().toString(); + switch (rawErasure) { + case "java.lang.String", "byte[]", "java.nio.ByteBuffer" -> + buffer.append(statement(indent, "ws.send(__wsReturn)", semicolon(kt))); + default -> buffer.append(statement(indent, "ws.render(__wsReturn)", semicolon(kt))); + } + } + + public String invocation(boolean kt) { + return makeCall(kt, paramList(kt), false, false); + } + + private String paramList(boolean kt) { + var joiner = new StringJoiner(", ", "(", ")"); + for (var param : getParameters(true)) { + joiner.add(websocketArgumentExpression(param, kt)); + } + return joiner.toString(); + } + + private String websocketArgumentExpression(MvcParameter parameter, boolean kt) { + String rawParamType = parameter.getType().getRawType().toString(); + if (wsLifecycle == WsLifecycle.MESSAGE) { + if (WsParamTypes.getAllowedTypes(WsLifecycle.MESSAGE).contains(rawParamType)) { + return WsParamTypes.generateArgumentName(rawParamType); + } + + var mvcBodyExpr = + ParameterGenerator.BodyParam.toSourceCode( + kt, + this, + null, + parameter.getType(), + parameter.variableElement(), + parameter.getName(), + parameter.isNullable(kt)); + return mvcExprToWsExpr(mvcBodyExpr); + } + + String expr = WsParamTypes.generateArgumentName(rawParamType); + if (expr != null) { + return expr; + } + + getContext() + .error("Unsupported websocket handler parameter type: %s.", rawParamType); + return "null"; + } + + private static String mvcExprToWsExpr(String mvcBodyExpr) { + String s = mvcBodyExpr; + s = s.replace("ctx.body().", "message."); + s = s.replace("ctx.body(", "message.to("); + return s; + } + + private void validateLifecycleParameters(MvcContext context, ExecutableElement method) { + var env = context.getProcessingEnvironment(); + var types = env.getTypeUtils(); + var throwableType = env.getElementUtils().getTypeElement(Throwable.class.getName()).asType(); + var allowed = WsParamTypes.getAllowedTypes(wsLifecycle); + + for (VariableElement parameter : method.getParameters()) { + TypeMirror rawMirror = websocketParameterRawType(types, parameter); + var raw = rawMirror.toString(); + if (allowed.contains(raw)) { + continue; + } + + if (wsLifecycle == WsLifecycle.ERROR + && throwableType != null + && types.isAssignable(rawMirror, throwableType)) { + continue; + } + + if (wsLifecycle == WsLifecycle.MESSAGE) { + continue; + } + + context.error( + "Illegal parameter type %s on websocket %s method %s#%s", + raw, + wsLifecycle.name().toLowerCase(), + ((TypeElement) method.getEnclosingElement()).getQualifiedName(), + method.getSimpleName()); + } + } + + private static TypeMirror websocketParameterRawType(javax.lang.model.util.Types types, + VariableElement parameter) { + return new TypeDefinition(types, parameter.asType()).getRawType(); + } +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRouter.java new file mode 100644 index 0000000000..ab4e58ee29 --- /dev/null +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ws/WsRouter.java @@ -0,0 +1,167 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.apt.ws; + +import io.jooby.internal.apt.*; + +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import java.util.List; + +import static io.jooby.internal.apt.CodeBlock.*; +import static java.lang.System.lineSeparator; + +public class WsRouter extends WebRouter { + + public WsRouter(MvcContext context, TypeElement clazz) { + super(context, clazz); + } + + public static WsRouter parse(MvcContext context, TypeElement controller) { + var router = new WsRouter(context, controller); + + for (var enclosed : controller.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + var route = new WsRoute(router, (ExecutableElement) enclosed); + if (route.getWsLifecycle() != null) { + router.routes.put(route.getWsLifecycle().name(), route); + } + } + } + + boolean isWsHandler = router.routes.containsKey(WsLifecycle.CONNECT.name()) + || router.routes.containsKey(WsLifecycle.MESSAGE.name()); + + if (!isWsHandler) { + return new WsRouter(context, controller); + } + + return router; + } + + @Override + public String getGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName() + "Ws"); + } + + private List websocketPaths() { + var declared = HttpPath.PATH.path(clazz); + if (declared.isEmpty()) { + return List.of("/"); + } + + return declared.stream() + .map(WebRoute::leadingSlash) + .distinct() + .toList(); + } + + @Override + public String toSourceCode(boolean kt) { + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = getGeneratedType().substring(getGeneratedType().lastIndexOf('.') + 1); + var paths = websocketPaths(); + + var buffer = new StringBuilder(); + + if (kt) { + buffer.append(indent(4)).append("@Throws(Exception::class)").append(lineSeparator()); + buffer + .append(indent(4)) + .append("override fun install(app: io.jooby.Jooby) {") + .append(lineSeparator()); + } else { + buffer + .append(indent(4)) + .append("public void install(io.jooby.Jooby app) throws Exception {") + .append(lineSeparator()); + } + + for (var path : paths) { + buffer.append( + statement( + indent(6), + "app.ws(", + CodeBlock.string(path), + ", ", + "this::wsInit", + ")", + semicolon(kt)) + ); + } + + trimr(buffer); + buffer.append(lineSeparator()).append(indent(4)).append("}").append(lineSeparator()); + + buffer.append(lineSeparator()).append(generateWsInitMethod(kt)); + + return getTemplate(kt) + .replace("${packageName}", getPackageName()) + .replace("${imports}", "") + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${implements}", "io.jooby.Extension") + .replace("${constructors}", constructors(generatedClass, kt)) + .replace("${methods}", trimr(buffer)); + } + + private String generateWsInitMethod(boolean kt) { + var buffer = new StringBuilder(); + if (!kt) { + buffer.append( + statement( + indent(4), + "private void wsInit(io.jooby.Context ctx, io.jooby.WebSocketConfigurer" + + " configurer) {")); + } else { + buffer.append( + statement( + indent(4), + "private fun wsInit(ctx: io.jooby.Context, configurer:" + + " io.jooby.WebSocketConfigurer) {")); + } + + appendLifecycle(kt, buffer, WsLifecycle.CONNECT); + appendLifecycle(kt, buffer, WsLifecycle.MESSAGE); + appendLifecycle(kt, buffer, WsLifecycle.CLOSE); + appendLifecycle(kt, buffer, WsLifecycle.ERROR); + + buffer.append(indent(4)).append("}").append(lineSeparator()); + return buffer.toString(); + } + + private void appendLifecycle(boolean kt, StringBuilder buffer, WsLifecycle lc) { + var handler = routes.get(lc.name()); + if (handler == null) { + return; + } + + var open = + switch (lc) { + case CONNECT -> kt ? "configurer.onConnect { ws ->" : "configurer.onConnect(ws -> {"; + case MESSAGE -> kt + ? "configurer.onMessage { ws, message ->" + : "configurer.onMessage((ws, message) -> {"; + case CLOSE -> + kt ? "configurer.onClose { ws, status ->" : "configurer.onClose((ws, status) -> {"; + case ERROR -> + kt ? "configurer.onError { ws, cause ->" : "configurer.onError((ws, cause) -> {"; + }; + appendCallback(kt, buffer, handler, open, kt ? "}" : "});"); + } + + private void appendCallback(boolean kt, + StringBuilder buffer, + WsRoute route, + String openLine, + String closeToken) { + buffer.append(indent(6)).append(route.seeControllerMethodJavadoc(kt)); + buffer.append(indent(6)).append(openLine).append(lineSeparator()); + route.appendBody(kt, buffer, indent(8)); + buffer.append(indent(6)).append(closeToken).append(lineSeparator()).append(lineSeparator()); + } +} diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index f18b4e9521..75ebe490d8 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -186,6 +186,10 @@ public ProcessorRunner withRpcCode(SneakyThrows.Consumer consumer) { return withSourceCode(false, it -> it.endsWith("Rpc_"), consumer); } + public ProcessorRunner withWsCode(SneakyThrows.Consumer consumer) { + return withSourceCode(false, it -> it.endsWith("Ws_"), consumer); + } + public ProcessorRunner withSourceCode(boolean kt, SneakyThrows.Consumer consumer) { consumer.accept( kt diff --git a/modules/jooby-apt/src/test/java/tests/ws/ChatWebsocket.java b/modules/jooby-apt/src/test/java/tests/ws/ChatWebsocket.java new file mode 100644 index 0000000000..015f117e1c --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/ws/ChatWebsocket.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.ws; + +import java.util.Map; + +import io.jooby.Context; +import io.jooby.WebSocket; +import io.jooby.WebSocketCloseStatus; +import io.jooby.WebSocketMessage; +import io.jooby.annotation.ws.OnClose; +import io.jooby.annotation.ws.OnConnect; +import io.jooby.annotation.ws.OnError; +import io.jooby.annotation.ws.OnMessage; +import io.jooby.annotation.Path; + +@Path("/chat/{username}") +public class ChatWebsocket { + + @OnConnect + public String onConnect(WebSocket ws, Context ctx) { + return "welcome"; + } + + @OnMessage + public Map onMessage(WebSocket ws, Context ctx, WebSocketMessage message) { + return Map.of("echo", message.value()); + } + + @OnClose + public void onClose(WebSocket ws, Context ctx, WebSocketCloseStatus status) {} + + @OnError + public void onError(WebSocket ws, Context ctx, Throwable cause) {} +} diff --git a/modules/jooby-apt/src/test/java/tests/ws/WebsocketBeanMessage.java b/modules/jooby-apt/src/test/java/tests/ws/WebsocketBeanMessage.java new file mode 100644 index 0000000000..508deee55b --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/ws/WebsocketBeanMessage.java @@ -0,0 +1,23 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.ws; + +import io.jooby.Context; +import io.jooby.WebSocket; +import io.jooby.annotation.ws.OnMessage; + +import java.util.Map; + +public class WebsocketBeanMessage { + + public record Incoming(String text) { + } + + @OnMessage + public Map onMessage(WebSocket ws, Context ctx, Incoming msg) { + return Map.of("echo", msg.text()); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/ws/WebsocketGeneratorTest.java b/modules/jooby-apt/src/test/java/tests/ws/WebsocketGeneratorTest.java new file mode 100644 index 0000000000..94bb3a779c --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/ws/WebsocketGeneratorTest.java @@ -0,0 +1,47 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.ws; + +import io.jooby.apt.ProcessorRunner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WebsocketGeneratorTest { + + @Test + public void chatWebsocketMatchesGeneratedSource() throws Exception { + var expected = new String( + getClass() + .getResourceAsStream("/tests/ws/ChatWebsocketWs_expected.java") + .readAllBytes() + ); + + new ProcessorRunner(new ChatWebsocket()) + .withWsCode(source -> assertThat(normalize(source)) + .isEqualTo(normalize(expected)) + ); + } + + @Test + public void beanMessageWebsocketMatchesGeneratedSource() throws Exception { + var expected = new String( + getClass() + .getResourceAsStream("/tests/ws/WebsocketBeanMessageWs_expected.java") + .readAllBytes() + ); + + new ProcessorRunner(new WebsocketBeanMessage()) + .withWsCode(source -> assertThat(normalize(source)) + .isEqualTo(normalize(expected)) + ); + } + + private static String normalize(String source) { + return source.replace("\r\n", "\n").replace('\r', '\n') + .stripTrailing(); + } +} diff --git a/modules/jooby-apt/src/test/resources/tests/ws/ChatWebsocketWs_expected.java b/modules/jooby-apt/src/test/resources/tests/ws/ChatWebsocketWs_expected.java new file mode 100644 index 0000000000..205175b037 --- /dev/null +++ b/modules/jooby-apt/src/test/resources/tests/ws/ChatWebsocketWs_expected.java @@ -0,0 +1,59 @@ +package tests.ws; + +@io.jooby.annotation.Generated(ChatWebsocket.class) +public class ChatWebsocketWs_ implements io.jooby.Extension { + protected java.util.function.Function factory; + + public ChatWebsocketWs_() { + this(io.jooby.SneakyThrows.singleton(ChatWebsocket::new)); + } + + public ChatWebsocketWs_(ChatWebsocket instance) { + setup(ctx -> instance); + } + + public ChatWebsocketWs_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> provider.get()); + } + + public ChatWebsocketWs_(io.jooby.SneakyThrows.Function, ChatWebsocket> provider) { + setup(ctx -> provider.apply(ChatWebsocket.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + public void install(io.jooby.Jooby app) throws Exception { + app.ws("/chat/{username}", this::wsInit); + } + + private void wsInit(io.jooby.Context ctx, io.jooby.WebSocketConfigurer configurer) { + /** See {@link ChatWebsocket#onConnect(io.jooby.WebSocket, io.jooby.Context)} */ + configurer.onConnect(ws -> { + var c = this.factory.apply(ctx); + var __wsReturn = c.onConnect(ws, ctx); + ws.send(__wsReturn); + }); + + /** See {@link ChatWebsocket#onMessage(io.jooby.WebSocket, io.jooby.Context, io.jooby.WebSocketMessage)} */ + configurer.onMessage((ws, message) -> { + var c = this.factory.apply(ctx); + var __wsReturn = c.onMessage(ws, ctx, message); + ws.render(__wsReturn); + }); + + /** See {@link ChatWebsocket#onClose(io.jooby.WebSocket, io.jooby.Context, io.jooby.WebSocketCloseStatus)} */ + configurer.onClose((ws, status) -> { + var c = this.factory.apply(ctx); + c.onClose(ws, ctx, status); + }); + + /** See {@link ChatWebsocket#onError(io.jooby.WebSocket, io.jooby.Context, Throwable)} */ + configurer.onError((ws, cause) -> { + var c = this.factory.apply(ctx); + c.onError(ws, ctx, cause); + }); + + } +} diff --git a/modules/jooby-apt/src/test/resources/tests/ws/WebsocketBeanMessageWs_expected.java b/modules/jooby-apt/src/test/resources/tests/ws/WebsocketBeanMessageWs_expected.java new file mode 100644 index 0000000000..3b5ecff588 --- /dev/null +++ b/modules/jooby-apt/src/test/resources/tests/ws/WebsocketBeanMessageWs_expected.java @@ -0,0 +1,40 @@ +package tests.ws; + +@io.jooby.annotation.Generated(WebsocketBeanMessage.class) +public class WebsocketBeanMessageWs_ implements io.jooby.Extension { + protected java.util.function.Function factory; + + public WebsocketBeanMessageWs_() { + this(io.jooby.SneakyThrows.singleton(WebsocketBeanMessage::new)); + } + + public WebsocketBeanMessageWs_(WebsocketBeanMessage instance) { + setup(ctx -> instance); + } + + public WebsocketBeanMessageWs_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> provider.get()); + } + + public WebsocketBeanMessageWs_(io.jooby.SneakyThrows.Function, WebsocketBeanMessage> provider) { + setup(ctx -> provider.apply(WebsocketBeanMessage.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + public void install(io.jooby.Jooby app) throws Exception { + app.ws("/", this::wsInit); + } + + private void wsInit(io.jooby.Context ctx, io.jooby.WebSocketConfigurer configurer) { + /** See {@link WebsocketBeanMessage#onMessage(io.jooby.WebSocket, io.jooby.Context, tests.ws.WebsocketBeanMessage.Incoming)} */ + configurer.onMessage((ws, message) -> { + var c = this.factory.apply(ctx); + var __wsReturn = c.onMessage(ws, ctx, message.to(tests.ws.WebsocketBeanMessage.Incoming.class)); + ws.render(__wsReturn); + }); + + } +}