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 extends Payload> 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 extends Payload> 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