From fd828d600ebbf1f5a720d871ddeed6494d5bf5fe Mon Sep 17 00:00:00 2001 From: Marc Baloup Date: Tue, 14 Mar 2023 16:22:50 +0100 Subject: [PATCH] WebSocket API --- .../fr/pandacube/lib/util/ThrowableUtil.java | 11 +- pandalib-ws-client/pom.xml | 24 ++ .../lib/ws/client/AbstractClientWS.java | 117 ++++++++ .../lib/ws/client/KeyProtectedClientWS.java | 97 +++++++ pandalib-ws-server/pom.xml | 29 ++ .../lib/ws/server/AbstractServerWS.java | 60 ++++ .../lib/ws/server/KeyProtectedServerWS.java | 95 +++++++ pandalib-ws/pom.xml | 24 ++ .../java/fr/pandacube/lib/ws/AbstractWS.java | 257 ++++++++++++++++++ .../fr/pandacube/lib/ws/PayloadRegistry.java | 101 +++++++ .../lib/ws/payloads/ErrorPayload.java | 36 +++ .../lib/ws/payloads/LoginPayload.java | 22 ++ .../lib/ws/payloads/LoginSucceedPayload.java | 7 + .../lib/ws/payloads/MessagePayload.java | 22 ++ .../fr/pandacube/lib/ws/payloads/Payload.java | 8 + pom.xml | 3 + 16 files changed, 910 insertions(+), 3 deletions(-) create mode 100644 pandalib-ws-client/pom.xml create mode 100644 pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/AbstractClientWS.java create mode 100644 pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/KeyProtectedClientWS.java create mode 100644 pandalib-ws-server/pom.xml create mode 100644 pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/AbstractServerWS.java create mode 100644 pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/KeyProtectedServerWS.java create mode 100644 pandalib-ws/pom.xml create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/PayloadRegistry.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/ErrorPayload.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginPayload.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginSucceedPayload.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/MessagePayload.java create mode 100644 pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/Payload.java diff --git a/pandalib-util/src/main/java/fr/pandacube/lib/util/ThrowableUtil.java b/pandalib-util/src/main/java/fr/pandacube/lib/util/ThrowableUtil.java index 53f0ba6..e338541 100644 --- a/pandalib-util/src/main/java/fr/pandacube/lib/util/ThrowableUtil.java +++ b/pandalib-util/src/main/java/fr/pandacube/lib/util/ThrowableUtil.java @@ -96,9 +96,14 @@ public class ThrowableUtil { } - - - private static RuntimeException uncheck(Throwable t, boolean convertReflectionExceptionToError) { + /** + * Makes the provided Throwable unckecked if necessary. + * @param t the throwable to eventually wrap into a {@link RuntimeException}. + * @param convertReflectionExceptionToError true to convert reflection related exception to their error counterpart. + * @return a {@link RuntimeException} + * @throws Error if one is passed as the parameter. + */ + public static RuntimeException uncheck(Throwable t, boolean convertReflectionExceptionToError) { if (t instanceof Error er) { throw er; } diff --git a/pandalib-ws-client/pom.xml b/pandalib-ws-client/pom.xml new file mode 100644 index 0000000..4b7dde6 --- /dev/null +++ b/pandalib-ws-client/pom.xml @@ -0,0 +1,24 @@ + + + + pandalib-parent + fr.pandacube.lib + 1.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + pandalib-ws-client + jar + + + + fr.pandacube.lib + pandalib-ws + ${project.version} + + + + \ No newline at end of file diff --git a/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/AbstractClientWS.java b/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/AbstractClientWS.java new file mode 100644 index 0000000..c8893de --- /dev/null +++ b/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/AbstractClientWS.java @@ -0,0 +1,117 @@ +package fr.pandacube.lib.ws.client; + +import fr.pandacube.lib.util.Log; +import fr.pandacube.lib.util.ThrowableUtil; +import fr.pandacube.lib.ws.AbstractWS; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocket.Listener; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; + +// TODO implement auto-reconnect +/** + * Minimal implementation of a Websocket client endpoint using the java.net.http Websocket API. + */ +public abstract class AbstractClientWS implements AbstractWS { + + private final URI uri; + private boolean autoReconnect; + private final AtomicReference socket = new AtomicReference<>(); + + + private final Listener receiveListener = new Listener() { + @Override + public void onOpen(WebSocket webSocket) { + AbstractClientWS.this.onConnect(); + Listener.super.onOpen(webSocket); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + AbstractClientWS.this.handleReceivedMessage(data.toString()); + return Listener.super.onText(webSocket, data, last); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + AbstractClientWS.this.handleReceivedBinary(); + return Listener.super.onBinary(webSocket, data, last); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + AbstractClientWS.this.onClose(statusCode, reason); + return Listener.super.onClose(webSocket, statusCode, reason); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + AbstractClientWS.this.onError(error); + Listener.super.onError(webSocket, error); + } + }; + + + /** + * Creates a new Websocket client that connect to the provided url. + * @param uri the destination endpoint. + * @param autoReconnect if this websocket should automatically try to reconnect when disconnected. + * @throws URISyntaxException if the provided URI is invalid. + */ + public AbstractClientWS(String uri, boolean autoReconnect) throws URISyntaxException { + this.uri = new URI(uri); + this.autoReconnect = autoReconnect; + if (autoReconnect) { + Log.warning("Websocket client auto-reconnect is not yet implemented."); + } + connect(); + } + + + private void connect() { + synchronized (socket) { + socket.set(HttpClient.newHttpClient() + .newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .buildAsync(uri, receiveListener) + .join() + ); + } + } + + + @Override + public final void sendString(String message) throws IOException { + try { + synchronized (socket) { + socket.get().sendText(message, true).join(); + } + } catch (CompletionException ce) { + if (ce.getCause() instanceof IOException ioe) + throw ioe; + throw ThrowableUtil.uncheck(ce.getCause(), false); + } + } + + @Override + public String getRemoteIdentifier() { + return uri.toString(); + } + + @Override + public final void sendClose(int code, String reason) throws IOException { + synchronized (socket) { + autoReconnect = false; // if we ask for closing connection, dont reconnect automatically + socket.get().sendClose(code, reason); + } + } + +} diff --git a/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/KeyProtectedClientWS.java b/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/KeyProtectedClientWS.java new file mode 100644 index 0000000..5b9ae06 --- /dev/null +++ b/pandalib-ws-client/src/main/java/fr/pandacube/lib/ws/client/KeyProtectedClientWS.java @@ -0,0 +1,97 @@ +package fr.pandacube.lib.ws.client; + +import fr.pandacube.lib.ws.payloads.ErrorPayload; +import fr.pandacube.lib.ws.payloads.LoginPayload; +import fr.pandacube.lib.ws.payloads.LoginSucceedPayload; +import fr.pandacube.lib.ws.payloads.Payload; + +import java.net.URISyntaxException; + +/** + * Websocket client that implements the login logic with a key protected server endpoint. + */ +public abstract class KeyProtectedClientWS extends AbstractClientWS { + + private final String key; + + private boolean loginSucceed = false; + + /** + * Creates a new Websocket client that connect to the provided url and uses the provided key. + * @param uri the destination endpoint. + * @param autoReconnect if this websocket should automatically try to reconnect when disconnected. + * @param key the login key. + * @throws URISyntaxException if the provided URI is invalid. + */ + public KeyProtectedClientWS(String uri, boolean autoReconnect, String key) throws URISyntaxException { + super(uri, autoReconnect); + this.key = key; + } + + + @Override + public final void onConnect() { + trySendAsJson(new LoginPayload(key)); + } + + @Override + public final void onReceivePayload(Payload payload) { + if (loginSucceed) { + onReceivePayloadLoggedIn(payload); + } + else if (payload instanceof LoginSucceedPayload) { + loginSucceed = true; + onLoginSucceed(); + } + else if (payload instanceof ErrorPayload err){ + logError("Received ErrorPayload instead of LoginSuccessPayload: " + err.message, err.throwable); + trySendClose(); + } + else { + logError("Received unexpected Payload instead of LoginSuccessPayload: " + payload.getClass().getSimpleName()); + trySendClose(); + } + } + + @Override + public final void onClose(int code, String reason) { + if (loginSucceed) { + loginSucceed = false; + onCloseLoggedIn(code, reason); + } + } + + @Override + public final void onError(Throwable cause) { + if (loginSucceed) { + loginSucceed = false; + onErrorLoggedIn(cause); + } + } + + /** + * Called when this Websocket is succesfully logged in to the server. + */ + public abstract void onLoginSucceed(); + + /** + * Called on reception of a valid payload when already logged in. + * @param payload the received payload. + */ + public abstract void onReceivePayloadLoggedIn(Payload payload); + + /** + * Called on reception of a websocket Close packet, only if this client is already logged in. + * The connection is closed after this method call. + * @param code the close code. 1000 for a normal closure. + * @param reason the close reason. + */ + public abstract void onCloseLoggedIn(int code, String reason); + + /** + * Called when an error occurs with the websocket API, only if this client is already logged in. + * The connection is already closed when this method is called. + * @param cause the error cause. + */ + public abstract void onErrorLoggedIn(Throwable cause); +} diff --git a/pandalib-ws-server/pom.xml b/pandalib-ws-server/pom.xml new file mode 100644 index 0000000..b646c9c --- /dev/null +++ b/pandalib-ws-server/pom.xml @@ -0,0 +1,29 @@ + + + + pandalib-parent + fr.pandacube.lib + 1.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + pandalib-ws-server + jar + + + + fr.pandacube.lib + pandalib-ws + ${project.version} + + + org.eclipse.jetty.websocket + websocket-jetty-api + 10.0.5 + + + + \ No newline at end of file diff --git a/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/AbstractServerWS.java b/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/AbstractServerWS.java new file mode 100644 index 0000000..b48ef63 --- /dev/null +++ b/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/AbstractServerWS.java @@ -0,0 +1,60 @@ +package fr.pandacube.lib.ws.server; + +import fr.pandacube.lib.ws.AbstractWS; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketAdapter; + +import java.io.IOException; + +/** + * Minimal implementation of a Websocket server endpoint using the Jetty Websocket API. + */ +public abstract class AbstractServerWS extends WebSocketAdapter implements AbstractWS { + + @Override + public final void onWebSocketConnect(Session sess) + { + super.onWebSocketConnect(sess); + onConnect(); + } + + @Override + public final void onWebSocketBinary(byte[] payload, int offset, int len) { + handleReceivedBinary(); + } + + @Override + public final void onWebSocketText(String message) { + handleReceivedMessage(message); + } + + @Override + public final void onWebSocketClose(int statusCode, String reason) { + onClose(statusCode, reason); + } + + @Override + public final void onWebSocketError(Throwable cause) { + onError(cause); + } + + + + + + + + public final void sendString(String message) throws IOException { + getSession().getRemote().sendString(message); + } + + @Override + public final void sendClose(int code, String reason) throws IOException { + getSession().close(code, reason); + } + + @Override + public String getRemoteIdentifier() { + return getSession().getRemoteAddress().toString(); + } +} diff --git a/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/KeyProtectedServerWS.java b/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/KeyProtectedServerWS.java new file mode 100644 index 0000000..dfefe84 --- /dev/null +++ b/pandalib-ws-server/src/main/java/fr/pandacube/lib/ws/server/KeyProtectedServerWS.java @@ -0,0 +1,95 @@ +package fr.pandacube.lib.ws.server; + +import fr.pandacube.lib.ws.payloads.ErrorPayload; +import fr.pandacube.lib.ws.payloads.LoginPayload; +import fr.pandacube.lib.ws.payloads.LoginSucceedPayload; +import fr.pandacube.lib.ws.payloads.Payload; + +import java.util.function.Supplier; + +/** + * Websocket server endpoint that is protected with a key. + */ +public abstract class KeyProtectedServerWS extends AbstractServerWS { + + private boolean loginSucceed = false; + + private final Supplier keySupplier; + + /** + * Creates a websocket server endpoint protected by the key given by the provided {@link Supplier}. + * @param keySupplier a {@link Supplier} for the key. + */ + public KeyProtectedServerWS(Supplier keySupplier) { + this.keySupplier = keySupplier; + } + + @Override + public final void onConnect() { + // nothing, just wait for the client to login + } + + @Override + public final void onReceivePayload(Payload payload) { + if (loginSucceed) { + onReceivePayloadLoggedIn(payload); + } + else if (payload instanceof LoginPayload login) { + if (keySupplier.get().equals(login.key)) { + loginSucceed = true; + trySendAsJson(new LoginSucceedPayload()); + onLoginSucceed(); + } + else { + logAndTrySendError(new ErrorPayload("Bad key")); + trySendClose(); + } + } + else { + logAndTrySendError(new ErrorPayload("Please use the login packet first. Received " + payload.getClass().getSimpleName() + " instead.")); + trySendClose(); + } + } + + @Override + public final void onClose(int code, String reason) { + if (loginSucceed) { + onCloseLoggedIn(code, reason); + } + } + + @Override + public final void onError(Throwable cause) { + if (loginSucceed) { + onErrorLoggedIn(cause); + } + } + + /** + * Called when the client endpoint is succesfully logged in. + */ + public abstract void onLoginSucceed(); + + /** + * Called on reception of a valid payload from the already logged in client. + * @param payload the received payload. + */ + public abstract void onReceivePayloadLoggedIn(Payload payload); + + /** + * Called on reception of a websocket Close packet, only if the client endpoint is already logged in. + * The connection is closed after this method call. + * @param code the close code. 1000 for a normal closure. + * @param reason the close reason. + */ + public abstract void onCloseLoggedIn(int code, String reason); + + /** + * Called when an error occurs with the websocket API, only if the client endpoint is already logged in. + * The connection is already closed when this method is called. + * @param cause the error cause. + */ + public abstract void onErrorLoggedIn(Throwable cause); + + +} diff --git a/pandalib-ws/pom.xml b/pandalib-ws/pom.xml new file mode 100644 index 0000000..6dc80c4 --- /dev/null +++ b/pandalib-ws/pom.xml @@ -0,0 +1,24 @@ + + + + pandalib-parent + fr.pandacube.lib + 1.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + pandalib-ws + jar + + + + fr.pandacube.lib + pandalib-core + ${project.version} + + + + \ No newline at end of file diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java new file mode 100644 index 0000000..74ca61d --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java @@ -0,0 +1,257 @@ +package fr.pandacube.lib.ws; + +import fr.pandacube.lib.util.Log; +import fr.pandacube.lib.util.ThrowableUtil.RunnableException; +import fr.pandacube.lib.ws.payloads.ErrorPayload; +import fr.pandacube.lib.ws.payloads.Payload; + +import java.io.IOException; +import java.util.logging.Level; + +/** + * Super interface for all websocket endpoint, either client or server, that provides minimal implementation of our + * custom sub-protocol. + */ +public interface AbstractWS { + + /* + * Receiving + */ + + /** + * Handles the reception of a message, by deserializing the payloads and handling eventual deserialization errors. + * @param message the raw message received. + */ + default void handleReceivedMessage(String message) { + Payload payload; + try { + payload = PayloadRegistry.fromString(message); + } catch (IllegalArgumentException e) { + logAndTrySendError(new ErrorPayload(e.getMessage())); // no need to log or send full exception stack trace + return; + } + + try { + onReceivePayload(payload); + } catch (Throwable t) { + trySendAsJson(new ErrorPayload("Error handling payload: " + t)); + if (t instanceof Exception) + logError("Error handling payload", t); + else + throw t; + } + } + + /** + * Handles the reception of binary data. The default implementation reject any binary data by sending an + * {@link ErrorPayload} to the remote endpoint. + */ + default void handleReceivedBinary() { + trySendAsJson(new ErrorPayload("Cannot accept binary payload.")); + } + + /** + * Called when the websocket connection is established. + */ + void onConnect(); + + /** + * Called on reception of a valid {@link Payload}. + * @param payload the received {@link Payload}. + */ + void onReceivePayload(Payload payload); + + /** + * Called on reception of a websocket Close packet. + * The connection is closed after this method call. + * @param code the close code. 1000 for a normal closure. + * @param reason the close reason. + */ + void onClose(int code, String reason); + + /** + * Called when an error occurs with the websocket API. + * The connection is already closed when this method is called. + * @param cause the error cause. + */ + void onError(Throwable cause); + + + + + + + + + /* + * Sending + */ + + /** + * Send the provided raw string to the remote endpoint. + *

+ * It is not advised for subclasses to call directly this method. + * Please use {@link #sendAsJson(Payload)} or {@link #sendAsJson(String, Object, boolean)} instead. + * @param message the raw message to send. + * @throws IOException if an IO error occurs when sending the message. + */ + void sendString(String message) throws IOException; + + /** + * Send the provided type and object that will be serialized using + * {@link PayloadRegistry#arbitraryToString(String, Object, boolean)}. + * @param type the type. + * @param obj the object to Jsonify. + * @param serializeNulls if null propreties must be included in the json object. + * @throws IOException if an IO error occurs when sending the data. + * @see PayloadRegistry#arbitraryToString(String, Object, boolean) + */ + default void sendAsJson(String type, Object obj, boolean serializeNulls) throws IOException { + sendString(PayloadRegistry.arbitraryToString(type, obj, serializeNulls)); + } + + /** + * Send the provided type and object that will be serialized using + * {@link PayloadRegistry#arbitraryToString(String, Object, boolean)}. + * @param type the type. + * @param obj the object to Jsonify. + * @param serializeNulls if null propreties must be included in the json object. + * @return true if the data is sent successfully, false if an IO error occurs. + * @see PayloadRegistry#arbitraryToString(String, Object, boolean) + */ + default boolean trySendAsJson(String type, Object obj, boolean serializeNulls) { + return trySend(() -> sendAsJson(type, obj, serializeNulls), "Error sending object as json"); + } + + /** + * Send the provided {@link Payload} to the remote endpoint. + * @param payload the {@link Payload} to send. + * @throws IOException if an IO error occurs when sending the data. + * @see PayloadRegistry#toString(Payload) + */ + default void sendAsJson(Payload payload) throws IOException { + sendString(PayloadRegistry.toString(payload)); + } + + /** + * Send the provided {@link Payload} to the remote endpoint. + * @param payload the {@link Payload} to send. + * @return true if the data is sent successfully, false if an IO error occurs. + * @see PayloadRegistry#toString(Payload) + */ + default boolean trySendAsJson(Payload payload) { + return trySend(() -> sendAsJson(payload), "Error sending payload as json"); + } + + /** + * Gracefully closes the connection by sending the close packet with the provided data. + * @param code the status code. + * @param reason the reason. + * @throws IOException if an IO error occurs when sending the close packet. + */ + void sendClose(int code, String reason) throws IOException; + + /** + * Gracefully closes the connection by sending the close packet with the provided data. + * @param code the status code. + * @param reason the reason. + * @return true if the data is sent successfully, false if an IO error occurs. + */ + default boolean trySendClose(int code, String reason) { + return trySend(() -> sendClose(code, reason), "Error sending close"); + } + + /** + * Gracefully closes the connection by sending the close packet with the default status code (1000) and an empty + * reason. + * @throws IOException if an IO error occurs when sending the close packet. + */ + default void sendClose() throws IOException { + sendClose(1000, ""); + } + + /** + * Gracefully closes the connection by sending the close packet with the default status code (1000) and an empty + * reason. + * @return true if the data is sent successfully, false if an IO error occurs. + */ + default boolean trySendClose() { + return trySend(this::sendClose, "Error sending close"); + } + + /** + * Logs the error from the provided {@link ErrorPayload} and sends it to the remote endpoint. + * @param p the {@link ErrorPayload}. + * @throws IOException if an IO error occurs when sending the data. + */ + default void logAndSendError(ErrorPayload p) throws IOException { + logError(p.message, p.throwable); + sendAsJson(p); + } + + /** + * Logs the error from the provided {@link ErrorPayload} and sends it to the remote endpoint. + * @param p the {@link ErrorPayload}. + * @return true if the data is sent successfully, false if an IO error occurs. + */ + default boolean logAndTrySendError(ErrorPayload p) { + return trySend(() -> logAndSendError(p), "Error sending error payload as json"); + } + + /** + * Utility method to wrap sending operation into a try-catch. + * @param run the sending operation that may throw an {@link IOException}. + * @param errorMessage the error message to log if the runnable throws an {@link IOException}. + * @return true if the data if the runnable is executed successfully, false if an IO error occurs. + */ + default boolean trySend(RunnableException run, String errorMessage) { + try { + run.run(); + return true; + } catch (IOException e) { + logError(errorMessage, e); + return false; + } + } + + + + /* + * Log + */ + + /** + * Logs the provided message with logger level {@link Level#INFO}, prefixed with infos avout this web-socket. + * @param message the message to log. + */ + default void log(String message) { + Log.info(formatLogMessage(message)); + } + + /** + * Logs the provided message with logger level {@link Level#SEVERE}, prefixed with infos avout this web-socket. + * @param message the message to log. + */ + default void logError(String message) { + logError(message, null); + } + + /** + * Logs the provided message and {@link Throwable} with logger level {@link Level#SEVERE}, prefixed with infos avout this web-socket. + * @param message the message to log. + * @param t the throwable to log. + */ + default void logError(String message, Throwable t) { + Log.severe(formatLogMessage(message), t); + } + + /** + * Gets an identifier for this web-socket, used for logging. May be the remote IP:port or URI. + * @return an identifier for this web-socket. + */ + String getRemoteIdentifier(); + + private String formatLogMessage(String message) { + return "[WS/" + getClass().getSimpleName() + "] [" + getRemoteIdentifier() + "] " + message; + } +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/PayloadRegistry.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/PayloadRegistry.java new file mode 100644 index 0000000..a972080 --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/PayloadRegistry.java @@ -0,0 +1,101 @@ +package fr.pandacube.lib.ws; + +import com.google.gson.JsonParseException; +import fr.pandacube.lib.core.json.Json; +import fr.pandacube.lib.util.BiMap; +import fr.pandacube.lib.ws.payloads.ErrorPayload; +import fr.pandacube.lib.ws.payloads.LoginPayload; +import fr.pandacube.lib.ws.payloads.LoginSucceedPayload; +import fr.pandacube.lib.ws.payloads.MessagePayload; +import fr.pandacube.lib.ws.payloads.Payload; + +import java.util.regex.Pattern; + +/** + * Handles the registration of all the {@link Payload} types, the serialization and deserialization of the payload data. + */ +public class PayloadRegistry { + + private static final String PAYLOAD_TYPE_SEPARATOR = ":"; + + private static final BiMap> payloadClasses = new BiMap<>(); + + + private static boolean isTypeValid(String type) { + return !type.contains(PAYLOAD_TYPE_SEPARATOR); + } + + private static String validateType(String type) { + if (isTypeValid(type)) + return type; + throw new IllegalArgumentException("Invalid characters in type identifier '" + type + "'"); + } + + /** + * Register a new {@link Payload} type. + * @param type the id of the payload type. + * @param clazz the payload class to register. + */ + public static void registerPayloadType(String type, Class clazz) { + payloadClasses.put(validateType(type), clazz); + } + + /** + * Deserialize the provided String into a valid Payload. + * @param message the serialized data. + * @return the {@link Payload}. + * @throws IllegalArgumentException if the serialized data does not have the proper format. + */ + public static Payload fromString(String message) { + String[] split = message.split(Pattern.quote(PAYLOAD_TYPE_SEPARATOR), 2); + if (split.length != 2) { + throw new IllegalArgumentException("Malformed message: does not respect format '" + PAYLOAD_TYPE_SEPARATOR + "'."); + } + + Class detectedClass = payloadClasses.get(split[0]); + if (detectedClass == null) { + throw new IllegalArgumentException("Unrecognized data type '" + split[0] + "'."); + } + + try { + return Json.gson.fromJson(split[1], detectedClass); + } catch (JsonParseException e) { + throw new IllegalArgumentException(e.toString()); + } + } + + /** + * Serialize the provided {@link Payload}. + * @param p the {@link Payload} to serialize. Must be of a registered type. + * @return the serialized data. + */ + public static String toString(Payload p) { + String type = payloadClasses.getKey(p.getClass()); + if (type == null) + throw new IllegalArgumentException(p.getClass() + " is not a registered payload type."); + return arbitraryToString(type, p, false); + } + + + /** + * Serialize the provided arbitrary data, that consist of a type and an object that will be converted + * to a Json string. + * @param type the type + * @param obj the object to Jsonify + * @param serializeNulls if null propreties must be included in the json object. + * @return the String to send through the websocket + */ + public static String arbitraryToString(String type, Object obj, boolean serializeNulls) { + return validateType(type) + PAYLOAD_TYPE_SEPARATOR + (serializeNulls ? Json.gsonSerializeNulls : Json.gson).toJson(obj); + } + + + + static { + registerPayloadType("error", ErrorPayload.class); + registerPayloadType("message", MessagePayload.class); + registerPayloadType("login", LoginPayload.class); + registerPayloadType("login-succeed", LoginSucceedPayload.class); + } + +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/ErrorPayload.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/ErrorPayload.java new file mode 100644 index 0000000..6c69902 --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/ErrorPayload.java @@ -0,0 +1,36 @@ +package fr.pandacube.lib.ws.payloads; + +/** + * Error message payload. + */ +public class ErrorPayload extends Payload { + /** + * The error message. + */ + public String message; + /** + * The error Throwable, may be null. + */ + public Throwable throwable; + + /** + * Initialize an error payload with a message but not throwable. + * @param message the error message. + */ + public ErrorPayload(String message) { + this(message, null); + } + + /** + * Initialize an error payload with a message and a throwable. + * @param message the error message. + * @param throwable the error Throwable, may be null. + */ + public ErrorPayload(String message, Throwable throwable) { + this.message = message; + this.throwable = throwable; + } + + @SuppressWarnings("unused") + private ErrorPayload() { } // for Json deserialization +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginPayload.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginPayload.java new file mode 100644 index 0000000..12982b8 --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginPayload.java @@ -0,0 +1,22 @@ +package fr.pandacube.lib.ws.payloads; + +/** + * Payload used by the client to login to key-protected websocket endpoint. + */ +public class LoginPayload extends Payload { + /** + * The key to use for login. + */ + public String key; + + /** + * Create a new LoginPayload with the provided key. + * @param key the key to use for login. + */ + public LoginPayload(String key) { + this.key = key; + } + + @SuppressWarnings("unused") + private LoginPayload() { } // for Json deserialization +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginSucceedPayload.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginSucceedPayload.java new file mode 100644 index 0000000..160dc8b --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/LoginSucceedPayload.java @@ -0,0 +1,7 @@ +package fr.pandacube.lib.ws.payloads; + +/** + * Payload used by the server in inform the client the login was successfull. + */ +public class LoginSucceedPayload extends Payload { +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/MessagePayload.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/MessagePayload.java new file mode 100644 index 0000000..4130f1c --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/MessagePayload.java @@ -0,0 +1,22 @@ +package fr.pandacube.lib.ws.payloads; + +/** + * A general use message Payload. + */ +public class MessagePayload extends Payload { + /** + * The message. + */ + public String message; + + /** + * Initialite a new MessagePayload with the provided message. + * @param message the message. + */ + public MessagePayload(String message) { + this.message = message; + } + + @SuppressWarnings("unused") + private MessagePayload() { } // for Json deserialization +} diff --git a/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/Payload.java b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/Payload.java new file mode 100644 index 0000000..a7d539f --- /dev/null +++ b/pandalib-ws/src/main/java/fr/pandacube/lib/ws/payloads/Payload.java @@ -0,0 +1,8 @@ +package fr.pandacube.lib.ws.payloads; + +/** + * Superclass of all payload sent through our websockets. + */ +public abstract class Payload { + +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index bd4d1cd..4a0b60e 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,9 @@ pandalib-players-permissible pandalib-reflect pandalib-util + pandalib-ws + pandalib-ws-client + pandalib-ws-server