diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 4daf9865b3..e5eb74a418 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -25,9 +25,11 @@ use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; use Swoole\Process; -use Swoole\Http\Server; +use Swoole\WebSocket\Server; +use Swoole\WebSocket\Frame; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; +use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -885,4 +887,37 @@ function () use ($http) { $app->run($request, $response); }); +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$http->on('open', function (Server $server, SwooleRequest $swooleRequest) use ($realtimeProtocol) { + $path = (string) ($swooleRequest->server['request_uri'] ?? ''); + if ($path !== '/v1/realtime') { + // Reject upgrades on any other path with a policy-violation close. + $server->disconnect($swooleRequest->fd, 1008, 'Invalid realtime path'); + return; + } + + $project = (string) ($swooleRequest->get['project'] ?? ''); + if ($project === '') { + $server->disconnect($swooleRequest->fd, 1008, 'Missing project'); + return; + } + + $realtimeProtocol->open($server, $swooleRequest->fd, $project); +}); + +$http->on('message', function (Server $server, Frame $frame) use ($realtimeProtocol) { + $realtimeProtocol->message($server, $frame->fd, (string) $frame->data); +}); + +$http->on('close', function (Server $server, int $fd) use ($realtimeProtocol) { + $realtimeProtocol->close($fd); +}); + $http->start(); diff --git a/mock-server/src/Utopia/Realtime/Connection.php b/mock-server/src/Utopia/Realtime/Connection.php new file mode 100644 index 0000000000..9fc258bfd0 --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Connection.php @@ -0,0 +1,59 @@ + string[], 'queries' => string[]]`. + * + * @var array + */ + public array $subscriptions = []; + + public function __construct(int $fd, string $project = '') + { + $this->fd = $fd; + $this->project = $project; + } + + public function subscribe(string $subscriptionId, array $channels, array $queries): void + { + $this->subscriptions[$subscriptionId] = [ + 'channels' => array_values($channels), + 'queries' => array_values($queries), + ]; + } + + public function unsubscribe(string $subscriptionId): void + { + unset($this->subscriptions[$subscriptionId]); + } + + /** + * Return subscription IDs whose channel set intersects the given channels. + * + * @param string[] $channels + * @return string[] + */ + public function matchingSubscriptions(array $channels): array + { + $matches = []; + foreach ($this->subscriptions as $id => $sub) { + if (!empty(array_intersect($channels, $sub['channels']))) { + $matches[] = $id; + } + } + return $matches; + } +} diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php new file mode 100644 index 0000000000..9bc8bd517b --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -0,0 +1,189 @@ + server: { type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence' | 'ping', data: ... } + * server -> client: { type: 'connected' | 'event' | 'response' | 'pong' | 'error', data: ... } + * + * The mock does NOT enforce real authorization or query filtering; it + * acknowledges client requests with shaped responses so SDK code paths + * (subscribe/unsubscribe/presence) can be exercised end-to-end. + */ +class Protocol +{ + /** Connection state by Swoole fd. */ + private array $connections = []; + + public function open(Server $server, int $fd, string $project): void + { + $this->connections[$fd] = new Connection($fd, $project); + + $this->send($server, $fd, 'connected', [ + 'channels' => [], + 'user' => null, + ]); + } + + public function close(int $fd): void + { + unset($this->connections[$fd]); + } + + public function message(Server $server, int $fd, string $raw): void + { + $connection = $this->connections[$fd] ?? null; + if ($connection === null) { + return; + } + + $message = json_decode($raw, true); + if (!is_array($message) || !isset($message['type'])) { + $this->error($server, $fd, 'Invalid message', 400); + return; + } + + $type = (string) $message['type']; + $data = $message['data'] ?? null; + + try { + match ($type) { + 'authentication' => $this->handleAuth($connection, $data), + 'subscribe' => $this->handleSubscribe($server, $connection, $data), + 'unsubscribe' => $this->handleUnsubscribe($connection, $data), + 'presence' => $this->handlePresence($server, $connection, $data), + 'ping' => $this->send($server, $fd, 'pong'), + default => $this->error($server, $fd, "Unknown message type: {$type}", 400), + }; + } catch (Exception $e) { + $this->error($server, $fd, $e->getMessage(), $e->getCode() ?: 400); + } catch (\Throwable $e) { + $this->error($server, $fd, $e->getMessage(), 500); + } + } + + private function handleAuth(Connection $connection, mixed $data): void + { + // Mock accepts any session and synthesises a user object. + if (is_array($data) && !empty($data['session'])) { + $connection->user = [ + '$id' => 'user_' . substr(md5((string) $data['session']), 0, 8), + 'name' => 'Mock User', + ]; + } + } + + private function handleSubscribe(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'subscribe payload must be an array', 400); + } + + // SDKs send a list of subscription rows: [{ subscriptionId, channels, queries }] + $rows = $this->isList($data) ? $data : [$data]; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $subscriptionId = isset($row['subscriptionId']) ? (string) $row['subscriptionId'] : ''; + $channels = isset($row['channels']) && is_array($row['channels']) ? $row['channels'] : []; + $queries = isset($row['queries']) && is_array($row['queries']) ? $row['queries'] : []; + + if ($subscriptionId === '' || empty($channels)) { + continue; + } + $connection->subscribe($subscriptionId, $channels, $queries); + + // Confirm the subscription by emitting a synthetic event on the + // requested channels. The payload mirrors what the existing + // language test fixtures look for ("WS:/v1/realtime:passed"). + $this->send($server, $connection->fd, 'event', [ + 'channels' => array_values($channels), + 'events' => ['test.event'], + 'timestamp' => gmdate('Y-m-d\TH:i:s.000\+00:00'), + 'payload' => ['response' => 'WS:/v1/realtime:passed'], + 'subscriptions' => [$subscriptionId], + ]); + } + } + + private function handleUnsubscribe(Connection $connection, mixed $data): void + { + if (!is_array($data)) { + return; + } + $rows = $this->isList($data) ? $data : [$data]; + foreach ($rows as $row) { + if (is_array($row) && isset($row['subscriptionId'])) { + $connection->unsubscribe((string) $row['subscriptionId']); + } + } + } + + private function handlePresence(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data) || !isset($data['status'])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'presence payload requires status', 400); + } + + $now = gmdate('Y-m-d\TH:i:s.000\+00:00'); + $presence = [ + '$id' => $data['presenceId'] ?? ('presence_' . bin2hex(random_bytes(6))), + '$sequence' => '1', + '$createdAt' => $now, + '$updatedAt' => $now, + '$permissions' => $data['permissions'] ?? [], + 'userInternalId' => '1', + 'userId' => $connection->user['$id'] ?? '674af8f3e12a5f9ac0be', + 'status' => (string) $data['status'], + 'source' => 'WS', + ]; + if (isset($data['metadata']) && is_array($data['metadata'])) { + $presence['metadata'] = $data['metadata']; + } + if (isset($data['expiresAt'])) { + $presence['expiry'] = (string) $data['expiresAt']; + } + + $this->send($server, $connection->fd, 'response', [ + 'to' => 'presence', + 'presence' => $presence, + ]); + } + + private function send(Server $server, int $fd, string $type, mixed $data = null): void + { + if (!$server->isEstablished($fd)) { + return; + } + $payload = ['type' => $type]; + if ($data !== null) { + $payload['data'] = $data; + } + $server->push($fd, json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + + private function error(Server $server, int $fd, string $message, int $code = 400): void + { + $this->send($server, $fd, 'error', [ + 'message' => $message, + 'code' => $code, + ]); + } + + private function isList(array $value): bool + { + if ($value === []) { + return false; + } + return array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/src/Spec/Swagger2.php b/src/Spec/Swagger2.php index dcb4c120bc..b32752cdbd 100644 --- a/src/Spec/Swagger2.php +++ b/src/Spec/Swagger2.php @@ -598,7 +598,8 @@ public function getDefinitions(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { @@ -660,7 +661,8 @@ public function getRequestModels(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { diff --git a/templates/android/library/src/main/java/io/package/models/Model.kt.twig b/templates/android/library/src/main/java/io/package/models/Model.kt.twig index 2f71cedc0a..39fd401632 100644 --- a/templates/android/library/src/main/java/io/package/models/Model.kt.twig +++ b/templates/android/library/src/main/java/io/package/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 888d0fa6e2..cf1df68a33 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -43,6 +43,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var socket: RealWebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() + private val pendingPresenceRequests = ArrayDeque>>() private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -50,6 +51,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() + private val presenceLock = Any() } private fun createSocket() { @@ -191,6 +193,51 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } } + /** + * Create or upsert a presence entry for the current authenticated user + * over the existing realtime connection. + * + * Requires an authenticated user and an open WebSocket connection + * (subscribe to a channel first if you don't have one yet). + * + * @param status Presence status (required). + * @param permissions Optional permission list to attach to the presence document. + * @param metadata Optional metadata payload. + * @param presenceId Optional presence ID. Defaults server-side to a new unique ID. + * @return The created or updated presence document as a map. + */ + suspend fun createPresence( + status: String, + permissions: List? = null, + metadata: Map? = null, + presenceId: String? = null, + ): Map { + val ws = socket ?: throw {{ spec.title | caseUcfirst }}Exception( + "Realtime connection is not open. Subscribe to a channel first." + ) + + val data = mutableMapOf("status" to status) + permissions?.let { data["permissions"] = it } + metadata?.let { data["metadata"] = it } + presenceId?.let { data["presenceId"] = it } + + val deferred = CompletableDeferred>() + synchronized(presenceLock) { + pendingPresenceRequests.addLast(deferred) + } + + try { + ws.send(mapOf("type" to "presence", "data" to data).toJson()) + } catch (e: Throwable) { + synchronized(presenceLock) { + pendingPresenceRequests.remove(deferred) + } + throw e + } + + return deferred.await() + } + fun subscribe( vararg channels: Channel<*>, callback: (RealtimeResponseEvent) -> Unit, @@ -357,13 +404,43 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun handleResponseAction(message: RealtimeResponse) { + val data = message.data?.jsonCast>() + if (data != null && data["to"] == "presence") { + @Suppress("UNCHECKED_CAST") + val presence = data["presence"] as? Map + if (presence != null) { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + val deferred: CompletableDeferred>? + synchronized(presenceLock) { + deferred = if (pendingPresenceRequests.isNotEmpty()) { + pendingPresenceRequests.removeFirst() + } else { + null + } + } + deferred?.complete(presence) + return + } + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. } private fun handleResponseError(message: RealtimeResponse) { - throw message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + val ex = message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving deferred awaits hanging. + val pending: List>> + synchronized(presenceLock) { + pending = pendingPresenceRequests.toList() + pendingPresenceRequests.clear() + } + pending.forEach { it.completeExceptionally(ex) } + + throw ex } private suspend fun handleResponseEvent(message: RealtimeResponse) { diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index e0bef860e9..e78476e458 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,9 +21,11 @@ open class Realtime : Service { private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() + private var pendingPresenceRequests: [CheckedContinuation<[String: Any], Swift.Error>] = [] private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") + let presenceSync = DispatchQueue(label: "PresenceSync") private var subCallDepth = 0 private var reconnectAttempts = 0 @@ -196,6 +198,57 @@ open class Realtime : Service { return channel.toString() } + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// - Parameters: + /// - status: The presence status (required). + /// - permissions: Optional permission list to attach to the presence document. + /// - metadata: Optional metadata payload. + /// - presenceId: Optional presence ID. Defaults server-side to a new unique ID. + /// - Returns: The created or updated presence document as a dictionary. + public func createPresence( + status: String, + permissions: [String]? = nil, + metadata: [String: Any]? = nil, + presenceId: String? = nil + ) async throws -> [String: Any] { + guard let ws = socketClient, ws.isConnected else { + throw {{ spec.title | caseUcfirst }}Error(message: "Realtime connection is not open. Subscribe to a channel first.") + } + + var data: [String: Any] = ["status": status] + if let permissions = permissions { + data["permissions"] = permissions + } + if let metadata = metadata { + data["metadata"] = metadata + } + if let presenceId = presenceId { + data["presenceId"] = presenceId + } + + let payload: [String: Any] = [ + "type": "presence", + "data": data + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: jsonData, encoding: .utf8) else { + throw {{ spec.title | caseUcfirst }}Error(message: "Failed to encode presence payload") + } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Swift.Error>) in + presenceSync.sync { + pendingPresenceRequests.append(continuation) + } + ws.send(text: text) + } + } + public func subscribe( channel: ChannelValue, callback: @escaping (RealtimeResponseEvent) -> Void, @@ -360,6 +413,21 @@ extension Realtime: WebSocketClientDelegate { } private func handleResponseAction(from json: [String: Any]) { + if let data = json["data"] as? [String: Any], + let to = data["to"] as? String, + to == "presence", + let presence = data["presence"] as? [String: Any] { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + var continuation: CheckedContinuation<[String: Any], Swift.Error>? + presenceSync.sync { + if !pendingPresenceRequests.isEmpty { + continuation = pendingPresenceRequests.removeFirst() + } + } + continuation?.resume(returning: presence) + return + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. @@ -421,7 +489,21 @@ extension Realtime: WebSocketClientDelegate { } func handleResponseError(from json: [String: Any]) throws { - throw {{ spec.title | caseUcfirst }}Error(message: json["message"] as? String ?? "Unknown error") + let message = json["message"] as? String ?? "Unknown error" + let error = {{ spec.title | caseUcfirst }}Error(message: message) + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving continuations hanging. + var pending: [CheckedContinuation<[String: Any], Swift.Error>] = [] + presenceSync.sync { + pending = pendingPresenceRequests + pendingPresenceRequests.removeAll() + } + for continuation in pending { + continuation.resume(throwing: error) + } + + throw error } func handleResponseEvent(from json: [String: Any]) { diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index 6ee3db0310..5f6861da4c 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -9,7 +9,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% endfor %} {%~ if definition.additionalProperties %} - final Map data; + final Map {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}; {% endif %} {{ definition.name | caseUcfirst | overrideIdentifier}}({% if definition.properties | length or definition.additionalProperties %}{{ '{' }}{% endif %} @@ -18,7 +18,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% if property.required %}required {% endif %}this.{{ property.name | escapeKeyword }}, {% endfor %} {% if definition.additionalProperties %} - required this.data, + required this.{{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} {% if definition.properties | length or definition.additionalProperties %}{{ '}' }}{% endif %}); @@ -54,7 +54,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {%- endif -%}, {% endfor %} {% if definition.additionalProperties %} - data: map["data"] ?? map, + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"] ?? map, {% endif %} ); } @@ -66,13 +66,13 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model "{{ property.name | escapeDollarSign }}": {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword}}.map((p) => p.toMap()).toList(){% else %}{{property.name | escapeKeyword}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword }}{% endif %}, {% endfor %} {% if definition.additionalProperties %} - "data": data, + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}": {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} }; } {% if definition.additionalProperties %} - T convertTo(T Function(Map) fromJson) => fromJson(data); + T convertTo(T Function(Map) fromJson) => fromJson({{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}); {% endif %} {% for property in definition.properties %} {% if property.sub_schema %} diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index c0fff895a9..ed66b1b269 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -17,7 +17,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - public Dictionary Data { get; private set; } + public Dictionary {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} { get; private set; } {%~ endif %} public {{ definition.name | caseUcfirst | overrideIdentifier }}( @@ -26,7 +26,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - Dictionary data + Dictionary {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }} {%~ endif %} ) { @@ -34,7 +34,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property_name(definition, property) | overrideProperty(definition.name) }} = {{ property.name | caseCamel | escapeKeyword }}; {%~ endfor %} {%~ if definition.additionalProperties %} - Data = data; + {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}; {%~ endif %} } @@ -81,8 +81,8 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} {%- if definition.additionalProperties %} - data: map.TryGetValue("data", out var dataValue) - ? (Dictionary)dataValue + {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}: map.TryGetValue("{{ definition.additionalPropertiesKey | default('data') }}", out var additionalPropsValue) + ? (Dictionary)additionalPropsValue : map {%- endif ~%} ); @@ -94,13 +94,13 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - { "data", Data } + { "{{ definition.additionalPropertiesKey | default('data') }}", {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} } {%~ endif %} }; {%~ if definition.additionalProperties %} public T ConvertTo(Func, T> fromJson) => - fromJson.Invoke(Data); + fromJson.Invoke({{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }}); {%~ endif %} {%~ for property in definition.properties %} {%~ if property.sub_schema %} diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index 2126620288..e83611ab96 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -60,6 +60,25 @@ abstract class Realtime extends Service { /// subscription when you want to tear everything down. Future disconnect(); + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// ```dart + /// final presence = await realtime.createPresence( + /// status: 'online', + /// metadata: {'device': 'web'}, + /// ); + /// ``` + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }); + /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. /// /// Before the connection has been closed, this will be `null`. diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index 11c7a4ffdf..b041571502 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -10,4 +10,12 @@ abstract class RealtimeBase implements Realtime { @override Future disconnect(); + + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }); } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index 8ae3c74033..b211033736 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -41,4 +41,19 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { }) { return subscribeTo(channels, queries); } + + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) { + return createPresenceTo( + status: status, + permissions: permissions, + metadata: metadata, + presenceId: presenceId, + ); + } } diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 1735580b26..5a9bfd2034 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -50,6 +50,21 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { return subscribeTo(channels, queries); } + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) { + return createPresenceTo( + status: status, + permissions: permissions, + metadata: metadata, + presenceId: presenceId, + ); + } + // https://github.com/jonataslaw/getsocket/blob/f25b3a264d8cc6f82458c949b86d286cd0343792/lib/src/io.dart#L104 // and from official dart sdk websocket_impl.dart connect method Future _connectForSelfSignedCert( diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 4eab921346..bd2ed4a133 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,6 +30,7 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; + final List>> _pendingPresenceRequests = []; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -127,6 +128,24 @@ mixin RealtimeMixin { _startHeartbeat(); // Start heartbeat after successful connection break; case 'response': + final responseData = data.data is Map + ? data.data as Map + : null; + if (responseData != null && responseData['to'] == 'presence') { + final presence = responseData['presence']; + if (presence is Map) { + // Presence responses arrive in the same order as the requests + // on a single websocket, so a FIFO queue is enough to correlate + // them. + if (_pendingPresenceRequests.isNotEmpty) { + final completer = _pendingPresenceRequests.removeAt(0); + if (!completer.isCompleted) { + completer.complete(presence); + } + } + } + break; + } // The SDK generates subscriptionIds client-side and sends them on // every subscribe/unsubscribe, so subscribe/unsubscribe acks carry // no state the SDK needs to reconcile. @@ -353,10 +372,62 @@ mixin RealtimeMixin { } void handleError(RealtimeResponse response) { + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving futures hanging. + if (_pendingPresenceRequests.isNotEmpty) { + final ex = {{spec.title | caseUcfirst}}Exception( + response.data["message"], + response.data["code"], + ); + final pending = List>>.from(_pendingPresenceRequests); + _pendingPresenceRequests.clear(); + for (final completer in pending) { + if (!completer.isCompleted) { + completer.completeError(ex); + } + } + } + if (response.data['code'] == status.policyViolation) { throw {{spec.title | caseUcfirst}}Exception(response.data["message"], response.data["code"]); } else { _retry(); } } + + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + Future> createPresenceTo({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) async { + final ws = _websok; + if (ws == null || ws.closeCode != null) { + throw {{spec.title | caseUcfirst}}Exception( + 'Realtime connection is not open. Subscribe to a channel first.', + ); + } + + final data = {'status': status}; + if (permissions != null) data['permissions'] = permissions; + if (metadata != null) data['metadata'] = metadata; + if (presenceId != null) data['presenceId'] = presenceId; + + final completer = Completer>(); + _pendingPresenceRequests.add(completer); + + try { + ws.sink.add(jsonEncode({'type': 'presence', 'data': data})); + } catch (e) { + _pendingPresenceRequests.remove(completer); + rethrow; + } + + return completer.future; + } } \ No newline at end of file diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig index 2f71cedc0a..39fd401632 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index eed33bc427..98dda521b1 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -52,7 +52,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} * @param {{ paramDocType | raw }}|null ${{ property.name | caseCamel }} {{ property.description | unescape | lower | raw }} {% endfor %} {% if definition.additionalProperties %} - * @param array $data Additional properties. + * @param array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} Additional properties. {% endif %} */ public function __construct( @@ -63,7 +63,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} public ?{{ property | typeName }} ${{ property.name | caseCamel }} = null{{ (not loop.last or definition.additionalProperties) ? ',' : '' }} {% endfor %} {% if definition.additionalProperties %} - public array $data = [] + public array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} = [] {% endif %} ) { } @@ -140,7 +140,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - data: $additionalProperties + {{ definition.additionalPropertiesKey | default('data') | caseCamel }}: $additionalProperties {% endif %} ); {% endif %} @@ -161,7 +161,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} ]; {% if definition.additionalProperties %} - foreach (static::serializeAdditionalProperties($this->data) as $field => $value) { + foreach (static::serializeAdditionalProperties($this->{{ definition.additionalPropertiesKey | default('data') | caseCamel }}) as $field => $value) { $result[$field] = $value; } {% endif %} diff --git a/templates/python/package/models/model.py.twig b/templates/python/package/models/model.py.twig index 0ff5843e70..663720a293 100644 --- a/templates/python/package/models/model.py.twig +++ b/templates/python/package/models/model.py.twig @@ -70,7 +70,7 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener internal_fields = {k: v for k, v in data.items() if k.startswith('$')} user_data = {k: v for k, v in data.items() if not k.startswith('$')} instance = cls.model_validate(internal_fields) - instance._data = model_type(**user_data) if model_type is not dict else user_data + instance._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} = model_type(**user_data) if model_type is not dict else user_data return instance {% else %} instance = cls.model_validate(data) @@ -94,24 +94,24 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener {% endif %} {% if definition.additionalProperties %} - _data: Any = PrivateAttr(default_factory=dict) + _{{ definition.additionalPropertiesKey | default('data') | caseSnake }}: Any = PrivateAttr(default_factory=dict) @property - def data(self) -> T: - return cast(T, self._data) + def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self) -> T: + return cast(T, self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}) - @data.setter - def data(self, value: T) -> None: - object.__setattr__(self, '_data', value) + @{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.setter + def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self, value: T) -> None: + object.__setattr__(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}', value) def to_dict(self) -> Dict[str, Any]: result = super().to_dict() - if hasattr(self, '_data'): - if isinstance(self._data, dict): - result['data'] = self._data - elif hasattr(self._data, 'model_dump'): - result['data'] = self._data.model_dump(mode='json') + if hasattr(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}'): + if isinstance(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, dict): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} + elif hasattr(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, 'model_dump'): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.model_dump(mode='json') else: - result['data'] = self._data + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} return result {% endif %} diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index 0163b0614c..004df76a4e 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -8,7 +8,7 @@ module {{ spec.title | caseUcfirst }} attr_reader :{{ property.name | caseSnake | escapeKeyword }} {% endfor %} {% if definition.additionalProperties %} - attr_reader :data + attr_reader :{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} def initialize( @@ -17,7 +17,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: {% endif %} ) {% for property in definition.properties %} @@ -32,7 +32,7 @@ module {{ spec.title | caseUcfirst }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - @data = data + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} end @@ -43,7 +43,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: map["data"] || map + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') }}"] || map {% endif %} ) end @@ -55,14 +55,14 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - "data": @data + "{{ definition.additionalPropertiesKey | default('data') }}": @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} } end {% if definition.additionalProperties %} def convert_to(from_json) - from_json.call(data) + from_json.call({{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}) end {% endif %} {% for property in definition.properties %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig index c973cdbd2f..d7e6ee9673 100644 --- a/templates/rust/src/models/model.rs.twig +++ b/templates/rust/src/models/model.rs.twig @@ -26,7 +26,7 @@ pub struct {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} #[serde(flatten)] - pub data: HashMap, + pub {{ definition.additionalPropertiesKey | default('data') | caseSnake }}: HashMap, {% endif %} } @@ -57,12 +57,12 @@ impl {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} pub fn get(&self, key: &str) -> Option { - self.data.get(key) + self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()) } - pub fn data(&self) -> &HashMap { - &self.data + pub fn {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(&self) -> &HashMap { + &self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }} } {% endif %} } diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index 76aa83f143..a17abfc442 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -15,7 +15,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { case {{ property.name | escapeSwiftKeyword | removeDollarSign }} = "{{ property.name }}" {%~ endfor %} {%~ if definition.additionalProperties %} - case data + case {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} {%~ endif %} } @@ -26,7 +26,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} /// Additional properties - public let data: T + public let {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} init( @@ -35,14 +35,14 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: T + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} ) { {%~ for property in definition.properties %} self.{{ property.name | escapeSwiftKeyword | removeDollarSign }} = {{ property.name | escapeSwiftKeyword | removeDollarSign }} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = data + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} {%~ endif %} } @@ -65,7 +65,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endif %} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = try container.decode(T.self, forKey: .data) + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = try container.decode(T.self, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -76,7 +76,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { try container.encode{% if not property.required %}IfPresent{% endif %}({{ property.name | escapeSwiftKeyword | removeDollarSign }}{% if property.enum %}{% if property.required %}.rawValue{% else %}?.rawValue{% endif %}{% endif %}, forKey: .{{ property.name | escapeSwiftKeyword | removeDollarSign }}) {%~ endfor %} {%~ if definition.additionalProperties %} - try container.encode(data, forKey: .data) + try container.encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -87,7 +87,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - "data": try! JSONEncoder().encode(data) + "{{ definition.additionalPropertiesKey | default('data') }}": try! JSONEncoder().encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} ] } @@ -115,7 +115,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["data"] as? [String: Any] ?? map, options: [])) + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["{{ definition.additionalPropertiesKey | default('data') }}"] as? [String: Any] ?? map, options: [])) {%~ endif %} ) } diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index ec7d3cf593..90e5415154 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -55,10 +55,31 @@ export type RealtimeResponseConnected = { } export type RealtimeRequest = { - type: 'authentication' | 'subscribe' | 'unsubscribe'; + type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence'; data: any; } +export type RealtimePresence = { + $id: string; + $sequence?: string | number; + $createdAt: string; + $updatedAt: string; + $permissions: string[]; + userInternalId: string; + userId: string; + status?: string; + source: string; + expiry?: string; + metadata?: Record; +} + +export type RealtimePresenceCreate = { + status: string; + permissions?: string[]; + metadata?: Record; + presenceId?: string; +} + type RealtimeRequestSubscribeRow = { subscriptionId?: string; channels: string[]; @@ -84,6 +105,10 @@ export class Realtime { private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); + private pendingPresenceRequests: Array<{ + resolve: (presence: RealtimePresence) => void; + reject: (error: Error) => void; + }> = []; private heartbeatTimer?: number; private subCallDepth = 0; @@ -544,6 +569,51 @@ export class Realtime { return { unsubscribe, update, close }; } + /** + * Create or upsert a presence entry for the current authenticated user + * over the existing realtime connection. + * + * Requires an authenticated user and an open WebSocket connection + * (subscribe to a channel first if you don't have one yet). + * + * @param {RealtimePresenceCreate} params - Presence payload (status required, permissions/metadata/presenceId optional) + * @returns {Promise} The created or updated presence document + */ + public async createPresence(params: RealtimePresenceCreate): Promise { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new {{spec.title | caseUcfirst}}Exception('Realtime connection is not open. Subscribe to a channel first.'); + } + + const data: Record = { + status: params.status, + }; + if (params.permissions !== undefined) { + data.permissions = params.permissions; + } + if (params.metadata !== undefined) { + data.metadata = params.metadata; + } + if (params.presenceId !== undefined) { + data.presenceId = params.presenceId; + } + + return new Promise((resolve, reject) => { + this.pendingPresenceRequests.push({ resolve, reject }); + try { + this.socket!.send(JSONbig.stringify({ + type: 'presence', + data + })); + } catch (error) { + const idx = this.pendingPresenceRequests.findIndex(r => r.resolve === resolve); + if (idx !== -1) { + this.pendingPresenceRequests.splice(idx, 1); + } + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + private handleMessage(message: RealtimeResponse): void { if (!message.type) { return; @@ -605,6 +675,14 @@ export class Realtime { message.data?.message || 'Unknown error' ); const statusCode = message.data?.code; + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving promises hanging. + while (this.pendingPresenceRequests.length > 0) { + const pending = this.pendingPresenceRequests.shift(); + pending?.reject(error); + } + this.onErrorCallbacks.forEach(callback => callback(error, statusCode)); } @@ -639,7 +717,15 @@ export class Realtime { } } - private handleResponseAction(_message: RealtimeResponse): void { + private handleResponseAction(message: RealtimeResponse): void { + const data = message.data; + if (data?.to === 'presence' && data.presence !== undefined) { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + const pending = this.pendingPresenceRequests.shift(); + pending?.resolve(data.presence as RealtimePresence); + return; + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. diff --git a/tests/Base.php b/tests/Base.php index af56229171..36f5bac96c 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -116,6 +116,7 @@ abstract class Base extends TestCase 'Realtime unsubscribe:passed', 'Realtime update:passed', 'Realtime disconnect:passed', + 'Realtime presence:passed', ]; protected const QUERY_HELPER_RESPONSES = [ diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index d32e0ed6a4..99d02957a2 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -269,6 +269,39 @@ class ServiceTest { writeToFile("Realtime disconnect:failed") } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + try { + val presenceClient = Client(ApplicationProvider.getApplicationContext()) + .setProject("123456") + .setEndpointRealtime("ws://mockapi/v1") + val presenceRealtime = Realtime(presenceClient) + + // createPresence requires an open WebSocket; subscribing opens one. + presenceRealtime.subscribe( + "tests", + payloadType = TestPayload::class.java, + ) { /* no-op */ } + + val presence = presenceRealtime.createPresence( + status = "online", + metadata = mapOf("page" to "/home"), + presenceId = "p-test", + ) + + if (presence["\$id"] == "p-test" + && presence["status"] == "online" + && presence["source"] == "WS") { + writeToFile("Realtime presence:passed") + } else { + writeToFile("Realtime presence:failed") + } + + presenceRealtime.disconnect() + } catch (e: Exception) { + writeToFile("Realtime presence:failed") + } + // mock = general.setCookie() // writeToFile(mock.result) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index ed1314b328..fffae99182 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -243,6 +243,37 @@ class Tests: XCTestCase { print("Realtime disconnect:failed") } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + do { + let presenceClient = Client() + .setProject("123456") + .setEndpointRealtime("ws://mockapi/v1") + let presenceRealtime = Realtime(presenceClient) + + // createPresence requires an open WebSocket; subscribing opens one. + _ = try await presenceRealtime.subscribe(channels: ["tests"]) { _ in } + + let presence = try await presenceRealtime.createPresence( + status: "online", + metadata: ["page": "/home"], + presenceId: "p-test" + ) + + let id = presence["$id"] as? String + let status = presence["status"] as? String + let source = presence["source"] as? String + if id == "p-test" && status == "online" && source == "WS" { + print("Realtime presence:passed") + } else { + print("Realtime presence:failed") + } + + try await presenceRealtime.disconnect() + } catch { + print("Realtime presence:failed") + } + mock = try await general.setCookie() print(mock.result) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 913ed43aaf..15b8de2384 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -220,6 +220,36 @@ void main() async { print("Realtime disconnect:failed"); } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + try { + final presenceClient = Client() + .setProject('123456') + .setEndPointRealtime('ws://mockapi/v1'); + final presenceRealtime = Realtime(presenceClient); + + // createPresence requires an open WebSocket; subscribing opens one. + presenceRealtime.subscribe(['tests']); + + final presence = await presenceRealtime.createPresence( + status: 'online', + presenceId: 'p-test', + metadata: {'page': '/home'}, + ); + + if (presence[r'$id'] == 'p-test' + && presence['status'] == 'online' + && presence['source'] == 'WS') { + print("Realtime presence:passed"); + } else { + print("Realtime presence:failed"); + } + + await presenceRealtime.disconnect(); + } catch (e) { + print("Realtime presence:failed"); + } + response = await general.setCookie(); print(response.result); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index a953a6b6b0..904a53f900 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -326,6 +326,37 @@ console.log('Realtime disconnect:failed'); } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime to avoid the cloud.appwrite.io endpoint set above. + try { + const presenceClient = new Client(); + presenceClient.setProject('123456'); + presenceClient.setEndpointRealtime('ws://mockapi/v1'); + + const presenceRealtime = new Realtime(presenceClient); + + // createPresence requires an open WebSocket; subscribing opens one. + await presenceRealtime.subscribe(['tests'], () => {}); + + const presence = await presenceRealtime.createPresence({ + status: 'online', + presenceId: 'p-test', + metadata: { page: '/home' }, + }); + + if (presence?.$id === 'p-test' + && presence?.status === 'online' + && presence?.source === 'WS') { + console.log('Realtime presence:passed'); + } else { + console.log('Realtime presence:failed'); + } + + await presenceRealtime.disconnect(); + } catch (e) { + console.log('Realtime presence:failed'); + } + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index 340a779a1b..00d378d61c 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -193,6 +193,7 @@ async function start() { console.log('Realtime unsubscribe:passed'); // Skip new realtime API tests on Node.js console.log('Realtime update:passed'); console.log('Realtime disconnect:passed'); + console.log('Realtime presence:passed'); // Skip realtime presence test on Node.js // Query helper tests console.log(Query.equal("released", [true]));