Skip to content
Open
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
37 changes: 36 additions & 1 deletion mock-server/app/http.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -885,4 +887,37 @@ function () use ($http) {
$app->run($request, $response);
});

/**
* Realtime WebSocket mock at /v1/realtime?project=<id>.
*
* 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();
59 changes: 59 additions & 0 deletions mock-server/src/Utopia/Realtime/Connection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Utopia\MockServer\Utopia\Realtime;

/**
* Per-connection state for a single WebSocket client.
*
* One instance lives per Swoole `fd` for as long as the socket is open.
*/
class Connection
{
public int $fd;
public string $project = '';
public ?array $user = null;

/**
* Active subscriptions keyed by client-supplied subscriptionId.
* Each value is `['channels' => string[], 'queries' => string[]]`.
*
* @var array<string, array{channels: string[], queries: string[]}>
*/
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;
}
}
189 changes: 189 additions & 0 deletions mock-server/src/Utopia/Realtime/Protocol.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

namespace Utopia\MockServer\Utopia\Realtime;

use Swoole\WebSocket\Server;
use Utopia\MockServer\Utopia\Exception;

/**
* Realtime WebSocket protocol handler for the mock server.
*
* Mirrors the message contract the Appwrite realtime SDKs expect:
*
* client -> 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);
}
}
6 changes: 4 additions & 2 deletions src/Spec/Swagger2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ 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<String, Any> = mapOf(
{%~ for property in definition.properties %}
"{{ 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 %}
)

Expand All @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }}
{{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map<String, Any>') | raw }},
{%~ endfor %}
{%~ if definition.additionalProperties %}
data: Map<String, Any>
{{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map<String, Any>
{%~ endif %}
) = {{ definition | modelType(spec, 'Map<String, Any>') | raw }}(
{%~ for property in definition.properties %}
{{ property.name | escapeKeyword | removeDollarSign }},
{%~ endfor %}
{%~ if definition.additionalProperties %}
data
{{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}
{%~ endif %}
)
{%~ endif %}
Expand All @@ -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 %}
)
}
Expand Down
Loading
Loading