WebSocket API
This commit is contained in:
		| @@ -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; | ||||
| 		} | ||||
|   | ||||
							
								
								
									
										24
									
								
								pandalib-ws-client/pom.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pandalib-ws-client/pom.xml
									
									
									
									
									
										Normal 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-client</artifactId> | ||||
|     <packaging>jar</packaging> | ||||
|  | ||||
|     <dependencies> | ||||
|         <dependency> | ||||
|             <groupId>fr.pandacube.lib</groupId> | ||||
|             <artifactId>pandalib-ws</artifactId> | ||||
|             <version>${project.version}</version> | ||||
|         </dependency> | ||||
|     </dependencies> | ||||
|  | ||||
| </project> | ||||
| @@ -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<WebSocket> 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); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
							
								
								
									
										29
									
								
								pandalib-ws-server/pom.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pandalib-ws-server/pom.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| <?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-server</artifactId> | ||||
|     <packaging>jar</packaging> | ||||
|  | ||||
|     <dependencies> | ||||
|         <dependency> | ||||
|             <groupId>fr.pandacube.lib</groupId> | ||||
|             <artifactId>pandalib-ws</artifactId> | ||||
|             <version>${project.version}</version> | ||||
|         </dependency> | ||||
|         <dependency> | ||||
|             <groupId>org.eclipse.jetty.websocket</groupId> | ||||
|             <artifactId>websocket-jetty-api</artifactId> | ||||
|             <version>10.0.5</version> | ||||
|         </dependency> | ||||
|     </dependencies> | ||||
|  | ||||
| </project> | ||||
| @@ -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(); | ||||
| 	} | ||||
| } | ||||
| @@ -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<String> 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<String> 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); | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										24
									
								
								pandalib-ws/pom.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pandalib-ws/pom.xml
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										257
									
								
								pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								pandalib-ws/src/main/java/fr/pandacube/lib/ws/AbstractWS.java
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 { | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| package fr.pandacube.lib.ws.payloads; | ||||
|  | ||||
| /** | ||||
|  * Superclass of all payload sent through our websockets. | ||||
|  */ | ||||
| public abstract class Payload { | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user