diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php new file mode 100644 index 0000000..870a908 --- /dev/null +++ b/src/Filter/Filter.php @@ -0,0 +1,191 @@ +isLowercaseHex($key)) { + throw new \RuntimeException("Author pubkeys must be an array of 64-character lowercase hex values"); + } + } + $this->authors = $pubkeys; + return $this; + } + + /** + * Set the kinds for the Filter object. + * + * @param array $kinds The array of kinds to set. + */ + public function setKinds(array $kinds): static + { + $this->kinds = $kinds; + return $this; + } + + /** + * Set the #e tag for the Filter object. + * + * @param array $etag The array of tag to set. + */ + public function setLowercaseETags(array $etags): static + { + foreach($etags as $tag) { + if(!$this->isLowercaseHex($tag)) { + throw new \RuntimeException("#e tags must be an array of 64-character lowercase hex values"); + } + } + $this->etags = $etags; + return $this; + } + + /** + * Set the #p tag for the Filter object. + * + * @param array $ptag The array of tag to set. + */ + public function setLowercasePTags(array $ptags): static + { + // Check IF array contain exact 64-character lowercase hex values + foreach($ptags as $tag) { + if(!$this->isLowercaseHex($tag)) { + throw new \RuntimeException("#p tags must be an array of 64-character lowercase hex values"); + } + } + $this->ptags = $ptags; + return $this; + } + + /** + * Set the since for the Filter object. + * + * @param int $since The limit to set. + */ + public function setSince(int $since): static + { + $this->since = $since; + return $this; + } + + /** + * Set the until for the Filter object. + * + * @param int $until The limit to set. + */ + public function setUntil(int $until): static + { + $this->until = $until; + return $this; + } + + /** + * Set the limit for the Filter object. + * + * @param int $limit The limit to set. + */ + public function setLimit(int $limit): static + { + $this->limit = $limit; + return $this; + } + + /** + * Check if a given string is a 64-character lowercase hexadecimal value. + * + * @param string $string The string to check. + * @return bool True if the string is a 64-character lowercase hexadecimal value, false otherwise. + */ + public function isLowercaseHex($string): bool + { + // Regular expression to match 64-character lowercase hexadecimal value + $pattern = '/^[a-f0-9]{64}$/'; + // Check if the string matches the pattern + return preg_match($pattern, $string) === 1; + } + + /** + * Check if a given timestamp is valid. + * + * @param mixed $timestamp The timestamp to check. + * @return bool True if the timestamp is valid, false otherwise. + */ + public function isValidTimestamp($timestamp): bool + { + // Convert the timestamp to seconds + $timestamp = (int) $timestamp; + // Check if the timestamp is valid + return ($timestamp !== 0 && $timestamp !== false && $timestamp !== -1); + } + + /** + * Return an array representation of the object by iterating through its properties. + * + * @return array The array representation of the object. + */ + public function toArray(): array + { + $array = []; + foreach (get_object_vars($this) as $key => $val) { + if($key === 'etags') { + $array['#e'] = $val; + } elseif($key === 'ptags') { + $array['#p'] = $val; + } else { + $array[$key] = $val; + } + } + return $array; + } +} diff --git a/src/FilterInterface.php b/src/FilterInterface.php new file mode 100644 index 0000000..45a4f6d --- /dev/null +++ b/src/FilterInterface.php @@ -0,0 +1,80 @@ +subscriptionId = $subscriptionId; + } + + /** + * {@inheritdoc} + */ + public function generate(): string + { + return '["CLOSE", "' . $this->subscriptionId . '"]'; + } +} diff --git a/src/Message/RequestMessage.php b/src/Message/RequestMessage.php new file mode 100644 index 0000000..20b9a53 --- /dev/null +++ b/src/Message/RequestMessage.php @@ -0,0 +1,47 @@ +subscriptionId = $subscriptionId; + foreach($filters as $filter) { + $this->filters[] = $filter->toArray(); + } + } + + /** + * Generates a JSON-encoded request array by merging the subscription ID and filters array. + * + * @return string The JSON-encoded request array + */ + public function generate(): string + { + $requestArray = array_merge(["REQ", $this->subscriptionId], $this->filters); + return json_encode($requestArray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/src/Request/Request.php b/src/Request/Request.php new file mode 100644 index 0000000..6f2281b --- /dev/null +++ b/src/Request/Request.php @@ -0,0 +1,83 @@ +url = $websocket; + $this->payload = $message->generate(); + } + + /** + * Method to send a request using WebSocket client, receive responses, and handle errors. + * + * @return array The array of responses received or an error message if connection fails. + */ + public function send(): array + { + try { + $client = new WebSocket\Client($this->url); + $client->text($this->payload); + + $result = []; + + /** + * When sending 'CLOSE' request to close a subscription, it is not guaranteed that we + * will receive a response confirming that the subscription with the given ID is closed + * as the protocol does not mandate a specific response for a "CLOSE" request + * We can handle this either by: + * - closing connection upon sending the request + * - waiting for a certain period to see if further events are received for that subscription ID + * - waiting for ping from server to close connection (in which case the server indicates the + * connection is still alive, but it does not confirm the closure of the subscription) + */ + while ($response = $client->receive()) { + if ($response instanceof WebSocket\Message\Ping) { + $client->disconnect(); + return $result; + } elseif ($response instanceof WebSocket\Message\Text) { + $response = json_decode($response->getContent()); + if ($response[0] === 'NOTICE' || $response[0] === 'CLOSED') { + $client->disconnect(); + throw new \RuntimeException($response[0] === 'NOTICE' ? $response[1] : $response[2]); + } elseif ($response[0] === 'EOSE') { + break; + } else { + $result[] = $response; + } + } + } + $client->disconnect(); + } catch (WebSocket\ConnectionException $e) { + $result = [ + 'ERROR', + '', + false, + $e->getMessage(), + ]; + } + + return $result; + } +} diff --git a/src/RequestInterface.php b/src/RequestInterface.php new file mode 100644 index 0000000..c59cff9 --- /dev/null +++ b/src/RequestInterface.php @@ -0,0 +1,15 @@ +setId(); + + $filter = new Filter(); + $filter->setKinds([1]); + $filter->setLimit(3); + + $filters = [$filter]; + + // Mocking the WebSocket\Client + $mockClient = $this->getMockBuilder(Client::class) + ->setConstructorArgs([$relayUrl]) + ->getMock(); + + $requestMessage = new RequestMessage($subscriptionId, $filters); + $request = new Request($relayUrl, $requestMessage, $mockClient); + + $result = $request->send(); + + $this->assertNotEmpty($result, 'Request send result should not be empty'); + } +}