WebSocket API

This commit is contained in:
2023-03-14 16:22:50 +01:00
parent b6dba62fa4
commit fd828d600e
16 changed files with 910 additions and 3 deletions

24
pandalib-ws/pom.xml Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>pandalib-parent</artifactId>
<groupId>fr.pandacube.lib</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>pandalib-ws</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>fr.pandacube.lib</groupId>
<artifactId>pandalib-core</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -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.
* <p>
* <b>It is not advised for subclasses to call directly this method.
* Please use {@link #sendAsJson(Payload)} or {@link #sendAsJson(String, Object, boolean)} instead.</b>
* @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<IOException> 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;
}
}

View File

@@ -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<String, Class<? extends Payload>> 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 '<type>" + PAYLOAD_TYPE_SEPARATOR + "<jsonObject>'.");
}
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);
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
package fr.pandacube.lib.ws.payloads;
/**
* Superclass of all payload sent through our websockets.
*/
public abstract class Payload {
}