Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebSockets Next: make it possible to store user data in a connection #43787

Merged
merged 1 commit into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/src/main/asciidoc/websockets-next-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,41 @@
There are also other convenient methods.
For example, `OpenConnections#findByEndpointId(String)` makes it easy to find connections for a specific endpoint.

==== User data

It is also possible to associate arbitrary user data with a specific connection.
The `io.quarkus.websockets.next.UserData` object obtained by the `WebSocketConnection#userData()` method represents mutable user data associated with a connection.

[source, java]
----
import io.quarkus.websockets.next.WebSocketConnection;
import io.quarkus.websockets.next.UserData.TypedKey;

@WebSocket(path = "/endpoint/{username}")
class MyEndpoint {

@Inject
CoolService service;

@OnOpen
void open(WebSocketConnection connection) {
connection.userData().put(TypedKey.forBoolean("isCool"), service.isCool(connection.pathParam("username"))); <1>
}

@OnTextMessage
String process(String message) {
if (connection.userData().get(TypedKey.forBoolean("isCool"))) { <2>
return "Cool message processed!";
} else {
return "Message processed!";
}
}
}
----
<1> `CoolService#isCool()` returns `Boolean` that is associated with the current connection.
<2> The `TypedKey.forBoolean("isCool")` is the key used to obtain the data stored when the connection was created.

[[server-cdi-events]]

Check warning on line 677 in docs/src/main/asciidoc/websockets-next-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.HeadingPunctuation] Do not use end punctuation in headings. Raw Output: {"message": "[Quarkus.HeadingPunctuation] Do not use end punctuation in headings.", "location": {"path": "docs/src/main/asciidoc/websockets-next-reference.adoc", "range": {"start": {"line": 677, "column": 12}}}, "severity": "INFO"}

Check warning on line 677 in docs/src/main/asciidoc/websockets-next-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in '6.3.3. CDI events'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in '6.3.3. CDI events'.", "location": {"path": "docs/src/main/asciidoc/websockets-next-reference.adoc", "range": {"start": {"line": 677, "column": 12}}}, "severity": "INFO"}
==== CDI events

Quarkus fires a CDI event of type `io.quarkus.websockets.next.WebSocketConnection` with qualifier `@io.quarkus.websockets.next.Open` asynchronously when a new connection is opened.
Expand Down Expand Up @@ -997,7 +1031,42 @@
There are also other convenient methods.
For example, `OpenClientConnections#findByClientId(String)` makes it easy to find connections for a specific endpoint.

==== User data

It is also possible to associate arbitrary user data with a specific connection.
The `io.quarkus.websockets.next.UserData` object obtained by the `WebSocketClientConnection#userData()` method represents mutable user data associated with a connection.

[source, java]
----
import io.quarkus.websockets.next.WebSocketClientConnection;
import io.quarkus.websockets.next.UserData.TypedKey;

@WebSocketClient(path = "/endpoint/{username}")
class MyEndpoint {

@Inject
CoolService service;

@OnOpen
void open(WebSocketClientConnection connection) {
connection.userData().put(TypedKey.forBoolean("isCool"), service.isCool(connection.pathParam("username"))); <1>
}

@OnTextMessage
String process(String message) {
if (connection.userData().get(TypedKey.forBoolean("isCool"))) { <2>
return "Cool message processed!";
} else {
return "Message processed!";
}
}
}
----
<1> `CoolService#isCool()` returns `Boolean` that is associated with the current connection.
<2> The `TypedKey.forBoolean("isCool")` is the key used to obtain the data stored when the connection was created.


[[client-cdi-events]]

Check warning on line 1069 in docs/src/main/asciidoc/websockets-next-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in '7.2.3. CDI events'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in '7.2.3. CDI events'.", "location": {"path": "docs/src/main/asciidoc/websockets-next-reference.adoc", "range": {"start": {"line": 1069, "column": 14}}}, "severity": "INFO"}

Check warning on line 1069 in docs/src/main/asciidoc/websockets-next-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.HeadingPunctuation] Do not use end punctuation in headings. Raw Output: {"message": "[Quarkus.HeadingPunctuation] Do not use end punctuation in headings.", "location": {"path": "docs/src/main/asciidoc/websockets-next-reference.adoc", "range": {"start": {"line": 1069, "column": 14}}}, "severity": "INFO"}
==== CDI events

Quarkus fires a CDI event of type `io.quarkus.websockets.next.WebSocketClientConnection` with qualifier `@io.quarkus.websockets.next.Open` asynchronously when a new connection is opened.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.quarkus.websockets.next.test.connection;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.net.URI;
import java.util.List;

import jakarta.inject.Inject;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.OpenConnections;
import io.quarkus.websockets.next.UserData.TypedKey;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import io.quarkus.websockets.next.test.utils.WSClient;
import io.vertx.core.Vertx;

public class ConnectionUserDataTest {

@RegisterExtension
public static final QuarkusUnitTest test = new QuarkusUnitTest()
.withApplicationRoot(root -> {
root.addClasses(MyEndpoint.class, WSClient.class);
});

@Inject
Vertx vertx;

@TestHTTPResource("/end")
URI baseUri;

@Inject
OpenConnections connections;

@Test
void testConnectionData() {
try (WSClient client = WSClient.create(vertx).connect(baseUri)) {
assertEquals("5", client.sendAndAwaitReply("bar").toString());
assertNotNull(connections.stream().filter(c -> c.userData().get(TypedKey.forString("username")) != null).findFirst()
.orElse(null));
assertEquals("FOOMartin", client.sendAndAwaitReply("foo").toString());
assertEquals("0", client.sendAndAwaitReply("bar").toString());
}
}

@WebSocket(path = "/end")
public static class MyEndpoint {

@OnOpen
void onOpen(WebSocketConnection connection) {
connection.userData().put(TypedKey.forInt("baz"), 5);
connection.userData().put(TypedKey.forLong("foo"), 42l);
connection.userData().put(TypedKey.forString("username"), "Martin");
connection.userData().put(TypedKey.forBoolean("isActive"), true);
connection.userData().put(new TypedKey<List<String>>("list"), List.of());
}

@OnTextMessage
public String onMessage(String message, WebSocketConnection connection) {
if ("bar".equals(message)) {
return connection.userData().size() + "";
}
try {
connection.userData().get(TypedKey.forString("foo")).toString();
throw new IllegalStateException();
} catch (ClassCastException expected) {
}
if (!connection.userData().get(TypedKey.forBoolean("isActive"))
|| !connection.userData().get(new TypedKey<List<String>>("list")).isEmpty()) {
return "NOK";
}
if (connection.userData().remove(TypedKey.forLong("foo")) != 42l) {
throw new IllegalStateException();
}
if (connection.userData().remove(TypedKey.forInt("baz")) != 5) {
throw new IllegalStateException();
}
String ret = message.toUpperCase() + connection.userData().get(TypedKey.forString("username"));
connection.userData().clear();
return ret;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package io.quarkus.websockets.next;

import java.time.Instant;

import io.smallrye.common.annotation.CheckReturnValue;
import io.smallrye.mutiny.Uni;

/**
* WebSocket connection.
*
* @see WebSocketConnection
* @see WebSocketClientConnection
*/
public interface Connection extends BlockingSender {

/**
*
* @return the unique identifier assigned to this connection
*/
String id();

/**
*
* @param name
* @return the value of the path parameter or {@code null}
* @see WebSocketClient#path()
*/
String pathParam(String name);

/**
* @return {@code true} if the HTTP connection is encrypted via SSL/TLS
*/
boolean isSecure();

/**
* @return {@code true} if the WebSocket is closed
*/
boolean isClosed();

/**
*
* @return the close reason or {@code null} if the connection is not closed
*/
CloseReason closeReason();

/**
*
* @return {@code true} if the WebSocket is open
*/
default boolean isOpen() {
return !isClosed();
}

/**
* Close the connection.
*
* @return a new {@link Uni} with a {@code null} item
*/
@CheckReturnValue
default Uni<Void> close() {
return close(CloseReason.NORMAL);
}

/**
* Close the connection with a specific reason.
*
* @param reason
* @return a new {@link Uni} with a {@code null} item
*/
Uni<Void> close(CloseReason reason);

/**
* Close the connection and wait for the completion.
*/
default void closeAndAwait() {
close().await().indefinitely();
}

/**
* Close the connection with a specific reason and wait for the completion.
*/
default void closeAndAwait(CloseReason reason) {
close(reason).await().indefinitely();
}

/**
*
* @return the handshake request
*/
HandshakeRequest handshakeRequest();

/**
*
* @return the time when this connection was created
*/
Instant creationTime();

/**
*
* @return the user data associated with this connection
*/
UserData userData();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.quarkus.websockets.next;

/**
* Mutable user data associated with a connection. Implementations must be thread-safe.
*/
public interface UserData {

/**
*
* @param <VALUE>
* @param key
* @return the value or {@code null} if no mapping is found
*/
<VALUE> VALUE get(TypedKey<VALUE> key);

/**
* Associates the specified value with the specified key. An old value is replaced by the specified value.
*
* @param <ConnectionData.VALUE>
* @param key
* @param value
* @return the previous value associated with {@code key}, or {@code null} if no mapping exists
*/
<VALUE> VALUE put(TypedKey<VALUE> key, VALUE value);

/**
*
* @param <VALUE>
* @param key
*/
<VALUE> VALUE remove(TypedKey<VALUE> key);

int size();

void clear();

/**
* @param <TYPE> The type this key is used for.
*/
record TypedKey<TYPE>(String value) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't really need the string key. The object identity of the key is itself good enough.

This

static class Key<T> {
}

is all you need.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really. This way you would need to use the object reference as a key. Always. That's not what we want/need. A string-based key is IMO more practical.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, you're risking collisions, so the string keys will have to be namespaced... Not a fan. But your choice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for user data. I don't expect collisions there. Anyway, we risk collisions pretty much everywhere when it comes to MP Config and application properties 🤷.


public static TypedKey<Integer> forInt(String key) {
return new TypedKey<>(key);
}

public static TypedKey<Long> forLong(String key) {
return new TypedKey<>(key);
}

public static TypedKey<String> forString(String key) {
return new TypedKey<>(key);
}

public static TypedKey<Boolean> forBoolean(String key) {
return new TypedKey<>(key);
}
}

}
Loading