diff --git a/README.md b/README.md index 3e09786..dd66529 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,21 @@ $nc->onError(static function (\Throwable $e): void { }); ``` +#### Validation + +Subjects and names are checked before anything is sent, following the rules of nats-server and nats.go: + +- A subject is non-empty, without whitespace and empty tokens (a leading or trailing `.`, `..`): `Nats\Exception\InvalidSubject`. + `publish()`, `request()` and reply-to subjects must not contain the wildcards `*` and `>`, `subscribe()` and stream, filter and micro endpoint subjects + may contain `*` as a whole token and `>` as the last token. A wildcard inside a token, as in `foo*`, is a literal character. +- A queue group is non-empty and without whitespace: `Nats\Exception\InvalidSubject`. +- A stream, consumer or counter name is non-empty, at most 255 bytes, without `.`, `*`, `>`, `/`, `\`, whitespace and control characters: `Nats\Exception\InvalidName`. +- A priority group of a pull consumer matches `^[a-zA-Z0-9/_=-]{1,16}$`, as nats-server requires: `Nats\Exception\InvalidName`. +- A key-value or object store bucket name matches `^[a-zA-Z0-9_-]+$`: `Nats\Exception\InvalidBucketName`. +- A key-value key matches `^[-/_=.a-zA-Z0-9]+$` and is a valid subject; the keys to watch may contain wildcards: `Nats\Exception\InvalidKey`. +- An object name is non-empty: `Nats\Exception\ObjectIsInvalid`. +- A micro endpoint name matches `^[A-Za-z0-9_-]+$` (`Nats\Exception\InvalidName`), a group name is a subject without `>` (`Nats\Exception\InvalidSubject`). + ## Nats JetStream JetStream is the built-in NATS persistence system. The library provides both JetStream entity management (streams, consumers) and message publishing/consumption capabilities. @@ -835,7 +850,8 @@ $srv = $nc->createService(new Micro\ServiceConfig('EchoService', '1.0.0')); #### Service endpoints -After a service is created, endpoints can be added. By default, an endpoint is available via its name. +After a service is created, endpoints can be added. An endpoint name is an identifier matching `^[A-Za-z0-9_-]+$`, it is reported in the service info and stats. +The subject the endpoint listens on is set separately and defaults to the name. ```php createService(new Micro\ServiceConfig('EchoService', '1.0.0')); $srv - ->addEndpoint(new Micro\EndpointConfig('srv.echo'), static function (Micro\Request $request): void { + ->addEndpoint(new Micro\EndpointConfig('echo', subject: 'srv.echo'), static function (Micro\Request $request): void { $request->respond(new Micro\Response($request->data)); }); dump($nc->request('srv.echo', new Nats\Message('ping'))->message->payload); ``` -If the subject for the endpoint is more complex (e.g., contains a `*` or `>`), the subject can be specified separately from the name. +The subject may contain wildcards: `*` as a whole token and `>` as the last token. ```php createService(new Micro\ServiceConfig('EchoService', '1.0.0')); $srv - ->addEndpoint(new Micro\EndpointConfig('srv.echo', subject: 'srv.echo.*'), static function (Micro\Request $request): void { + ->addEndpoint(new Micro\EndpointConfig('echo', subject: 'srv.echo.*'), static function (Micro\Request $request): void { $request->respond(new Micro\Response($request->subject)); }); @@ -890,6 +906,7 @@ dump($nc->request('srv.echo.x', new Nats\Message('ping'))->message->payload); #### Groups Endpoints can also be aggregated using groups. A group represents a common subject prefix used by all endpoints associated with it. +The prefix is prepended to the endpoint's subject (to its name, if no subject is set), the endpoint name stays the same. ```php rawPublish($subject, $message, $replyTo, $cancellation); + } + + /** + * Publishes without validating the subjects: the JetStream API embeds subject filters, wildcards included, + * into its request subjects, e.g. $JS.API.CONSUMER.CREATE.... + * + * @internal + * @param non-empty-string $subject + * @param ?non-empty-string $replyTo + * @throws NatsException + */ + public function rawPublish( + string $subject, + Message $message = new Message(), + ?string $replyTo = null, + ?Cancellation $cancellation = null, ): void { $connection = $this->connection($cancellation); @@ -185,6 +210,12 @@ public function subscribe( int $bufferSize = 1_000, ?Cancellation $cancellation = null, ): Subscription { + Validate::subscribeSubject($subject); + + if ($queueGroup !== null) { + Validate::queueGroup($queueGroup); + } + $subscriptionId = $this->subscriptionIdGenerator->nextId(); $unsubscribe = $this->unsubscribe(...); @@ -216,11 +247,29 @@ public function subscribe( /** * @param non-empty-string $subject + * @throws NatsException */ public function request( string $subject, Message $message = new Message(), ?Cancellation $cancellation = null, + ): Delivery { + Validate::publishSubject($subject); + + return $this->rawRequest($subject, $message, $cancellation); + } + + /** + * Requests without validating the subject, see {@see rawPublish()}. + * + * @internal + * @param non-empty-string $subject + * @throws NatsException + */ + public function rawRequest( + string $subject, + Message $message = new Message(), + ?Cancellation $cancellation = null, ): Delivery { $subscribe = $this->subscribeCallback(...); $this->rpc ??= async(function () use ($subscribe): Rpc\Handler { diff --git a/src/Exception/InvalidBucketName.php b/src/Exception/InvalidBucketName.php new file mode 100644 index 0000000..0c4bad8 --- /dev/null +++ b/src/Exception/InvalidBucketName.php @@ -0,0 +1,20 @@ +publish($subject, $message, $replyTo->subject, $cancellation); + $client->rawPublish($subject, $message, $replyTo->subject, $cancellation); return $deferred->getFuture()->await($cancellation); } finally { diff --git a/src/Internal/Validation/Validate.php b/src/Internal/Validation/Validate.php new file mode 100644 index 0000000..8f57779 --- /dev/null +++ b/src/Internal/Validation/Validate.php @@ -0,0 +1,218 @@ +\/\\\\\x00-\x20\x7f]/'; + private const string BUCKET_REGEX = '/^[a-zA-Z0-9_-]+$/'; + private const string KEY_REGEX = '/^[-\/_=.a-zA-Z0-9]+$/'; + private const string KEY_FILTER_REGEX = '/^[-\/_=.a-zA-Z0-9*>]+$/'; + private const string ENDPOINT_NAME_REGEX = '/^[A-Za-z0-9\-_]+$/'; + + /** nats-server validGroupName. */ + private const string PRIORITY_GROUP_REGEX = '/^[a-zA-Z0-9\/_=-]{1,16}$/'; + + /** + * A literal subject, e.g. of publish, request or reply-to: wildcards are not allowed. + * + * @throws InvalidSubject + */ + public static function publishSubject(string $subject): void + { + $reason = self::subjectError($subject, partialWildcard: false, fullWildcard: false); + if ($reason !== null) { + throw InvalidSubject::because($subject, $reason); + } + } + + /** + * A subject that may match many, e.g. of subscribe or a stream: '*' may stand for any whole token, + * '>' for the whole rest of the subject, so only as the last token. + * + * @throws InvalidSubject + */ + public static function subscribeSubject(string $subject): void + { + $reason = self::subjectError($subject, partialWildcard: true, fullWildcard: true); + if ($reason !== null) { + throw InvalidSubject::because($subject, $reason); + } + } + + /** + * A subject that more tokens are appended to, e.g. a micro group: '>' would leave nothing to append after. + * + * @throws InvalidSubject + */ + public static function subjectPrefix(string $subject): void + { + $reason = self::subjectError($subject, partialWildcard: true, fullWildcard: false); + if ($reason !== null) { + throw InvalidSubject::because($subject, $reason); + } + } + + /** + * @throws InvalidSubject + */ + public static function queueGroup(string $queueGroup): void + { + $reason = match (true) { + $queueGroup === '' => 'it is empty', + preg_match(self::WHITESPACE, $queueGroup) === 1 => 'it contains whitespace', + default => null, + }; + + if ($reason !== null) { + throw InvalidSubject::forQueueGroup($queueGroup, $reason); + } + } + + /** + * A stream, consumer or counter name: it becomes a single subject token of the JetStream API. + * + * @throws InvalidName + */ + public static function name(string $name): void + { + $reason = match (true) { + $name === '' => 'it is empty', + \strlen($name) > self::MAX_NAME_LENGTH => \sprintf('it is longer than %d bytes', self::MAX_NAME_LENGTH), + preg_match(self::INVALID_NAME_CHARS, $name) === 1 => "it must not contain '.', '*', '>', '/', '\\', whitespace or control characters", + default => null, + }; + + if ($reason !== null) { + throw InvalidName::because($name, $reason); + } + } + + /** + * @throws InvalidName + */ + public static function endpointName(string $name): void + { + if (preg_match(self::ENDPOINT_NAME_REGEX, $name) !== 1) { + throw InvalidName::because($name, "only 'A-Z', 'a-z', '0-9', '-' and '_' are allowed"); + } + } + + /** + * A priority group of a pull consumer: it goes into the request payload, not into the subject, + * so the rule is nats-server's own and differs from the one of stream and consumer names. + * + * @throws InvalidName + */ + public static function priorityGroup(string $group): void + { + if (preg_match(self::PRIORITY_GROUP_REGEX, $group) !== 1) { + throw InvalidName::because($group, "only 'A-Z', 'a-z', '0-9', '-', '_', '/' and '=' are allowed, at most 16 characters"); + } + } + + /** + * A key-value or object store bucket name. + * + * @throws InvalidBucketName + */ + public static function bucket(string $bucket): void + { + if (preg_match(self::BUCKET_REGEX, $bucket) !== 1) { + throw InvalidBucketName::because($bucket, "only 'A-Z', 'a-z', '0-9', '-' and '_' are allowed"); + } + } + + /** + * @throws InvalidKey + */ + public static function key(string $key): void + { + $reason = preg_match(self::KEY_REGEX, $key) !== 1 + ? "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=' and '.' are allowed" + : self::subjectError($key, partialWildcard: false, fullWildcard: false); + + if ($reason !== null) { + throw InvalidKey::because($key, $reason); + } + } + + /** + * A key or a key pattern with wildcards, e.g. to watch. + * + * @throws InvalidKey + */ + public static function keyFilter(string $key): void + { + $reason = preg_match(self::KEY_FILTER_REGEX, $key) !== 1 + ? "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=', '.', '*' and '>' are allowed" + : self::subjectError($key, partialWildcard: true, fullWildcard: true); + + if ($reason !== null) { + throw InvalidKey::because($key, $reason); + } + } + + /** + * An object store object name: any non-empty string, it is base64-encoded into the subject. + * + * @throws ObjectIsInvalid + */ + public static function objectName(string $name): void + { + if ($name === '') { + throw new ObjectIsInvalid('Object name is empty.'); + } + } + + /** + * Like nats-server, a wildcard is only a whole token: in "foo*" the '*' is a literal character. + */ + private static function subjectError(string $subject, bool $partialWildcard, bool $fullWildcard): ?string + { + if ($subject === '') { + return 'it is empty'; + } + + if (preg_match(self::WHITESPACE, $subject) === 1) { + return 'it contains whitespace'; + } + + $tokens = explode('.', $subject); + $last = \count($tokens) - 1; + + foreach ($tokens as $i => $token) { + $error = match (true) { + $token === '' => 'it contains an empty token', + $token === '*' && !$partialWildcard, + $token === '>' && !$fullWildcard => "the wildcard '{$token}' is not allowed", + $token === '>' && $i !== $last => "the wildcard '>' must be the last token", + default => null, + }; + + if ($error !== null) { + return $error; + } + } + + return null; + } +} diff --git a/src/JetStream.php b/src/JetStream.php index ba43578..0c7a057 100644 --- a/src/JetStream.php +++ b/src/JetStream.php @@ -8,6 +8,7 @@ use Thesis\Nats\Exception\NoServerResponse; use Thesis\Nats\Exception\StreamNotFound; use Thesis\Nats\Internal\Id; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream\Api; use Thesis\Nats\JetStream\Api\Mapping; use Thesis\Nats\JetStream\Api\Result\Result; @@ -429,6 +430,8 @@ public function createOrUpdateKeyValue(KeyValue\BucketConfig $config): KeyValue\ */ public function deleteKeyValue(string $bucket): void { + Validate::bucket($bucket); + try { $this->deleteStream("KV_{$bucket}"); } catch (StreamNotFound) { @@ -440,6 +443,8 @@ public function deleteKeyValue(string $bucket): void */ public function keyValue(string $bucket): ?KeyValue\Bucket { + Validate::bucket($bucket); + $stream = $this->stream("KV_{$bucket}"); if ($stream !== null) { @@ -529,6 +534,8 @@ public function createOrUpdateObjectStore(ObjectStore\StoreConfig $config): Obje */ public function deleteObjectStore(string $bucket): void { + Validate::bucket($bucket); + try { $this->deleteStream("OBJ_{$bucket}"); } catch (StreamNotFound) { @@ -540,6 +547,8 @@ public function deleteObjectStore(string $bucket): void */ public function objectStore(string $store): ?ObjectStore\Store { + Validate::bucket($store); + $stream = $this->stream("OBJ_{$store}"); if ($stream !== null) { @@ -622,6 +631,8 @@ public function createOrUpdateCounter(Counter\CounterConfig $config): Counter\Co */ public function deleteCounter(string $name): void { + Validate::name($name); + try { $this->deleteStream("CNT_{$name}"); } catch (StreamNotFound) { @@ -635,7 +646,7 @@ public function deleteCounter(string $name): void */ public function rawPublish(string $subject, mixed $payload = null, ?string $replyTo = null): void { - $this->nats->publish( + $this->nats->rawPublish( subject: $this->router->route($subject), message: new Message( payload: $payload !== null ? $this->encoder->encode($payload) : null, @@ -651,7 +662,7 @@ public function rawPublish(string $subject, mixed $payload = null, ?string $repl public function rawRequest(string $endpoint, mixed $payload = null): Message { return $this->nats - ->request( + ->rawRequest( subject: $this->router->route($endpoint), message: new Message( payload: $payload !== null ? $this->encoder->encode($payload) : null, diff --git a/src/JetStream/Api/ConsumerConfig.php b/src/JetStream/Api/ConsumerConfig.php index e5c75fc..a44c134 100644 --- a/src/JetStream/Api/ConsumerConfig.php +++ b/src/JetStream/Api/ConsumerConfig.php @@ -4,6 +4,9 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Exception\InvalidName; +use Thesis\Nats\Exception\InvalidSubject; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Time\TimeSpan; /** @@ -20,6 +23,8 @@ * @param ?list $filterSubjects * @param ?array $metadata * @param ?list $priorityGroups + * @throws InvalidName + * @throws InvalidSubject */ public function __construct( public ?string $name = null, @@ -55,7 +60,34 @@ public function __construct( public ?PriorityPolicy $priorityPolicy = null, public ?TimeSpan $priorityTimeout = null, public ?array $priorityGroups = null, - ) {} + ) { + foreach ([$this->name, $this->durableName] as $consumerName) { + // Empty means no name: the consumer gets a generated one. + if ($consumerName !== null && $consumerName !== '') { + Validate::name($consumerName); + } + } + + if ($this->deliverSubject !== null) { + Validate::publishSubject($this->deliverSubject); + } + + if ($this->deliverGroup !== null) { + Validate::queueGroup($this->deliverGroup); + } + + if ($this->filterSubject !== null && $this->filterSubject !== '') { + Validate::subscribeSubject($this->filterSubject); + } + + foreach ($this->filterSubjects ?? [] as $subject) { + Validate::subscribeSubject($subject); + } + + foreach ($this->priorityGroups ?? [] as $group) { + Validate::priorityGroup($group); + } + } /** * @return array diff --git a/src/JetStream/Api/ConsumerInfoRequest.php b/src/JetStream/Api/ConsumerInfoRequest.php index 6130404..7b82703 100644 --- a/src/JetStream/Api/ConsumerInfoRequest.php +++ b/src/JetStream/Api/ConsumerInfoRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -17,7 +19,10 @@ public function __construct( private string $stream, private string $consumer, - ) {} + ) { + Validate::name($this->stream); + Validate::name($this->consumer); + } public function endpoint(): string { diff --git a/src/JetStream/Api/ConsumerListRequest.php b/src/JetStream/Api/ConsumerListRequest.php index 1a5b32a..31ba402 100644 --- a/src/JetStream/Api/ConsumerListRequest.php +++ b/src/JetStream/Api/ConsumerListRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements PaginatedRequest @@ -17,7 +19,9 @@ public function __construct( private string $stream, private ?int $offset = null, - ) {} + ) { + Validate::name($this->stream); + } public function withOffset(int $offset): self { diff --git a/src/JetStream/Api/ConsumerNamesRequest.php b/src/JetStream/Api/ConsumerNamesRequest.php index 44d8249..4188a2e 100644 --- a/src/JetStream/Api/ConsumerNamesRequest.php +++ b/src/JetStream/Api/ConsumerNamesRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements PaginatedRequest @@ -19,7 +21,13 @@ public function __construct( private string $stream, private ?string $subject = null, private ?int $offset = null, - ) {} + ) { + Validate::name($this->stream); + + if ($this->subject !== null) { + Validate::subscribeSubject($this->subject); + } + } public function withOffset(int $offset): PaginatedRequest { diff --git a/src/JetStream/Api/CreateConsumerRequest.php b/src/JetStream/Api/CreateConsumerRequest.php index e591f33..9b08313 100644 --- a/src/JetStream/Api/CreateConsumerRequest.php +++ b/src/JetStream/Api/CreateConsumerRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -23,7 +25,10 @@ public function __construct( private string $consumer, private ConsumerConfig $config, private ?string $action = null, - ) {} + ) { + Validate::name($this->stream); + Validate::name($this->consumer); + } public function endpoint(): string { diff --git a/src/JetStream/Api/DeleteConsumerRequest.php b/src/JetStream/Api/DeleteConsumerRequest.php index 3a17200..2bd4d5c 100644 --- a/src/JetStream/Api/DeleteConsumerRequest.php +++ b/src/JetStream/Api/DeleteConsumerRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -17,7 +19,10 @@ public function __construct( private string $stream, private string $consumer, - ) {} + ) { + Validate::name($this->stream); + Validate::name($this->consumer); + } public function endpoint(): string { diff --git a/src/JetStream/Api/DeleteMessageRequest.php b/src/JetStream/Api/DeleteMessageRequest.php index 88a16bd..8da312f 100644 --- a/src/JetStream/Api/DeleteMessageRequest.php +++ b/src/JetStream/Api/DeleteMessageRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -18,7 +20,9 @@ public function __construct( private string $stream, private int $seq, private ?bool $noErase = null, - ) {} + ) { + Validate::name($this->stream); + } public function endpoint(): string { diff --git a/src/JetStream/Api/DeleteStreamRequest.php b/src/JetStream/Api/DeleteStreamRequest.php index bd87942..6f284aa 100644 --- a/src/JetStream/Api/DeleteStreamRequest.php +++ b/src/JetStream/Api/DeleteStreamRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -15,7 +17,9 @@ */ public function __construct( private string $name, - ) {} + ) { + Validate::name($this->name); + } public function endpoint(): string { diff --git a/src/JetStream/Api/GetMessageRequest.php b/src/JetStream/Api/GetMessageRequest.php index eabdc91..e9178f2 100644 --- a/src/JetStream/Api/GetMessageRequest.php +++ b/src/JetStream/Api/GetMessageRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -21,7 +23,15 @@ public function __construct( private ?string $lastBySubject = null, private ?string $nextBySubject = null, private ?array $multiLast = null, - ) {} + ) { + Validate::name($this->stream); + + foreach ([$this->lastBySubject, $this->nextBySubject, ...$this->multiLast ?? []] as $subject) { + if ($subject !== null) { + Validate::subscribeSubject($subject); + } + } + } public function endpoint(): string { diff --git a/src/JetStream/Api/PauseConsumerRequest.php b/src/JetStream/Api/PauseConsumerRequest.php index 8566f35..dae9eda 100644 --- a/src/JetStream/Api/PauseConsumerRequest.php +++ b/src/JetStream/Api/PauseConsumerRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -18,7 +20,10 @@ public function __construct( private string $stream, private string $consumer, private ?\DateTimeImmutable $pauseUntil = null, - ) {} + ) { + Validate::name($this->stream); + Validate::name($this->consumer); + } public function endpoint(): string { diff --git a/src/JetStream/Api/PurgeStreamRequest.php b/src/JetStream/Api/PurgeStreamRequest.php index 9595770..eedff12 100644 --- a/src/JetStream/Api/PurgeStreamRequest.php +++ b/src/JetStream/Api/PurgeStreamRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -20,7 +22,13 @@ public function __construct( private ?int $sequence = null, private ?int $keep = null, private ?string $subject = null, - ) {} + ) { + Validate::name($this->name); + + if ($this->subject !== null && $this->subject !== '') { + Validate::subscribeSubject($this->subject); + } + } public function endpoint(): string { diff --git a/src/JetStream/Api/StreamConfig.php b/src/JetStream/Api/StreamConfig.php index 6129faf..3692b43 100644 --- a/src/JetStream/Api/StreamConfig.php +++ b/src/JetStream/Api/StreamConfig.php @@ -4,6 +4,9 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Exception\InvalidName; +use Thesis\Nats\Exception\InvalidSubject; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Time\TimeSpan; /** @@ -18,6 +21,8 @@ * @param int<1, 5> $replicas * @param ?list $sources * @param ?array $metadata + * @throws InvalidName + * @throws InvalidSubject */ public function __construct( public string $name, @@ -57,7 +62,13 @@ public function __construct( public ?bool $allowMsgSchedules = null, public ?bool $allowAtomicPublish = null, public ?PersistMode $persistMode = null, - ) {} + ) { + Validate::name($this->name); + + foreach ($this->subjects ?? [] as $subject) { + Validate::subscribeSubject($subject); + } + } public function seal(): self { diff --git a/src/JetStream/Api/StreamInfoRequest.php b/src/JetStream/Api/StreamInfoRequest.php index 3dceadf..c1ad1aa 100644 --- a/src/JetStream/Api/StreamInfoRequest.php +++ b/src/JetStream/Api/StreamInfoRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -15,7 +17,9 @@ */ public function __construct( private string $name, - ) {} + ) { + Validate::name($this->name); + } public function endpoint(): string { diff --git a/src/JetStream/Api/StreamListRequest.php b/src/JetStream/Api/StreamListRequest.php index a8ec0d6..5c815c5 100644 --- a/src/JetStream/Api/StreamListRequest.php +++ b/src/JetStream/Api/StreamListRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements PaginatedRequest @@ -17,7 +19,11 @@ public function __construct( private ?string $subject = null, private ?int $offset = null, - ) {} + ) { + if ($this->subject !== null) { + Validate::subscribeSubject($this->subject); + } + } public function withOffset(int $offset): self { diff --git a/src/JetStream/Api/StreamNamesRequest.php b/src/JetStream/Api/StreamNamesRequest.php index e028c35..ae53fda 100644 --- a/src/JetStream/Api/StreamNamesRequest.php +++ b/src/JetStream/Api/StreamNamesRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements PaginatedRequest @@ -17,7 +19,11 @@ public function __construct( private ?string $subject = null, private ?int $offset = null, - ) {} + ) { + if ($this->subject !== null) { + Validate::subscribeSubject($this->subject); + } + } public function withOffset(int $offset): self { diff --git a/src/JetStream/Api/StreamSource.php b/src/JetStream/Api/StreamSource.php index 7377f7c..7476fb7 100644 --- a/src/JetStream/Api/StreamSource.php +++ b/src/JetStream/Api/StreamSource.php @@ -4,6 +4,10 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Exception\InvalidName; +use Thesis\Nats\Exception\InvalidSubject; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api */ @@ -12,6 +16,8 @@ /** * @param non-empty-string $name * @param ?list $subjectTransforms + * @throws InvalidName + * @throws InvalidSubject */ public function __construct( public string $name, @@ -20,7 +26,13 @@ public function __construct( public ?string $filterSubject = null, public ?array $subjectTransforms = null, public ?ExternalStream $external = null, - ) {} + ) { + Validate::name($this->name); + + if ($this->filterSubject !== null && $this->filterSubject !== '') { + Validate::subscribeSubject($this->filterSubject); + } + } /** * @return array diff --git a/src/JetStream/Api/UnpinConsumerRequest.php b/src/JetStream/Api/UnpinConsumerRequest.php index 3b86042..67ce229 100644 --- a/src/JetStream/Api/UnpinConsumerRequest.php +++ b/src/JetStream/Api/UnpinConsumerRequest.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Api; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api * @template-implements Request @@ -19,7 +21,11 @@ public function __construct( private string $stream, private string $consumer, private string $group, - ) {} + ) { + Validate::name($this->stream); + Validate::name($this->consumer); + Validate::priorityGroup($this->group); + } public function endpoint(): string { diff --git a/src/JetStream/Counter/CounterConfig.php b/src/JetStream/Counter/CounterConfig.php index 0ebaf3f..af0d1fb 100644 --- a/src/JetStream/Counter/CounterConfig.php +++ b/src/JetStream/Counter/CounterConfig.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\Counter; +use Thesis\Nats\Exception\InvalidName; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream\Api\StorageType; /** @@ -14,11 +16,14 @@ /** * @param non-empty-string $name * @param int<1, 5> $replicas + * @throws InvalidName */ public function __construct( public string $name, public ?string $description = null, public StorageType $storageType = StorageType::File, public int $replicas = 1, - ) {} + ) { + Validate::name($this->name); + } } diff --git a/src/JetStream/Counter/CounterStore.php b/src/JetStream/Counter/CounterStore.php index dd91d0b..28fa463 100644 --- a/src/JetStream/Counter/CounterStore.php +++ b/src/JetStream/Counter/CounterStore.php @@ -6,6 +6,7 @@ use Thesis\Nats\Header\Incr; use Thesis\Nats\Header\Subject; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream; use Thesis\Nats\Message; @@ -32,6 +33,8 @@ public function __construct( */ public function add(string $subject, int $value): int { + Validate::publishSubject($subject); + $ack = $this->js->publish( ($this->publishPrefix)($subject), Message::incr($value), @@ -45,6 +48,8 @@ public function add(string $subject, int $value): int */ public function get(string $subject): ?Entry { + Validate::publishSubject($subject); + $message = $this->stream->getLastMessageForSubject("{$this->prefix}{$subject}"); if ($message !== null) { @@ -72,6 +77,10 @@ public function getMultiple(?array $subjects = []): iterable $subjects = ['>']; } + foreach ($subjects as $subject) { + Validate::subscribeSubject($subject); + } + $subjects = array_map( fn(string $subject): string => "{$this->prefix}{$subject}", $subjects, diff --git a/src/JetStream/KeyValue/Bucket.php b/src/JetStream/KeyValue/Bucket.php index 1b41e07..2cb0a9d 100644 --- a/src/JetStream/KeyValue/Bucket.php +++ b/src/JetStream/KeyValue/Bucket.php @@ -8,6 +8,7 @@ use Thesis\Nats\Header; use Thesis\Nats\Headers; use Thesis\Nats\Internal\Id; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream; use Thesis\Nats\JetStream\Api\DeliverPolicy; use Thesis\Nats\JetStream\Api\ReplayPolicy; @@ -43,6 +44,8 @@ public function __construct( */ public function get(string $key, int $revision = 0): ?Entry { + Validate::key($key); + $subject = "{$this->prefix}{$key}"; $message = match ($revision) { @@ -163,6 +166,10 @@ public function watch( $keys = [$keys]; } + foreach ($keys as $key) { + Validate::keyFilter($key); + } + $keys = array_map( fn(string $key): string => "{$this->prefix}{$key}", $keys !== [] ? $keys : [self::ALL_KEYS], @@ -230,6 +237,8 @@ static function (Delivery $delivery, Subscription $subscription) use ( */ private function prefixedSubject(string $cmd): string { + Validate::key($cmd); + $subject = ''; if ($this->jsPrefix !== null) { diff --git a/src/JetStream/KeyValue/BucketConfig.php b/src/JetStream/KeyValue/BucketConfig.php index bd73292..311c5b0 100644 --- a/src/JetStream/KeyValue/BucketConfig.php +++ b/src/JetStream/KeyValue/BucketConfig.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\KeyValue; +use Thesis\Nats\Exception\InvalidBucketName; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream\Api\Placement; use Thesis\Nats\JetStream\Api\RePublish; use Thesis\Nats\JetStream\Api\StorageType; @@ -20,6 +22,7 @@ * @param int<1, 64> $history * @param int<1, 5> $replicas * @param ?list $sources + * @throws InvalidBucketName */ public function __construct( public string $bucket, @@ -36,5 +39,7 @@ public function __construct( public ?array $sources = null, public bool $compression = false, public ?TimeSpan $limitMarkerTtl = null, - ) {} + ) { + Validate::bucket($this->bucket); + } } diff --git a/src/JetStream/ObjectStore/ObjectMeta.php b/src/JetStream/ObjectStore/ObjectMeta.php index 3eca67f..349ac74 100644 --- a/src/JetStream/ObjectStore/ObjectMeta.php +++ b/src/JetStream/ObjectStore/ObjectMeta.php @@ -4,6 +4,9 @@ namespace Thesis\Nats\JetStream\ObjectStore; +use Thesis\Nats\Exception\ObjectIsInvalid; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api */ @@ -14,6 +17,7 @@ * @param array> $headers * @param array $metadata * @param ?positive-int $chunkSize + * @throws ObjectIsInvalid */ public function __construct( public string $name, @@ -21,5 +25,7 @@ public function __construct( public array $headers = [], public array $metadata = [], public ?int $chunkSize = null, - ) {} + ) { + Validate::objectName($this->name); + } } diff --git a/src/JetStream/ObjectStore/Store.php b/src/JetStream/ObjectStore/Store.php index 79944d4..1505b46 100644 --- a/src/JetStream/ObjectStore/Store.php +++ b/src/JetStream/ObjectStore/Store.php @@ -14,6 +14,7 @@ use Thesis\Nats\Header\Timestamp; use Thesis\Nats\Headers; use Thesis\Nats\Internal\Id; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream; use Thesis\Nats\JetStream\Api\DeliverPolicy; use Thesis\Nats\JetStream\Delivery as JetStreamDelivery; @@ -218,6 +219,8 @@ public function delete(string $name): void */ public function info(string $name): ?ObjectInfo { + Validate::objectName($name); + $msg = $this->stream->getLastMessageForSubject("\$O.{$this->name}.M." . self::base64encode($name)); if ($msg !== null && $msg->payload !== null && $msg->payload !== '') { diff --git a/src/JetStream/ObjectStore/StoreConfig.php b/src/JetStream/ObjectStore/StoreConfig.php index 15e06be..dea9eff 100644 --- a/src/JetStream/ObjectStore/StoreConfig.php +++ b/src/JetStream/ObjectStore/StoreConfig.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\JetStream\ObjectStore; +use Thesis\Nats\Exception\InvalidBucketName; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Nats\JetStream\Api\Placement; use Thesis\Nats\JetStream\Api\StorageType; use Thesis\Time\TimeSpan; @@ -17,6 +19,7 @@ * @param non-empty-string $store * @param int<1, 5> $replicas * @param array $metadata + * @throws InvalidBucketName */ public function __construct( public string $store, @@ -28,5 +31,7 @@ public function __construct( public ?Placement $placement = null, public bool $compression = false, public array $metadata = [], - ) {} + ) { + Validate::bucket($this->store); + } } diff --git a/src/JetStream/PullConsumeConfig.php b/src/JetStream/PullConsumeConfig.php index 8de3887..f049ae2 100644 --- a/src/JetStream/PullConsumeConfig.php +++ b/src/JetStream/PullConsumeConfig.php @@ -4,6 +4,7 @@ namespace Thesis\Nats\JetStream; +use Thesis\Nats\Internal\Validation\Validate; use Thesis\Time\TimeSpan; /** @@ -50,6 +51,10 @@ public function __construct( public ?string $group = null, public ?int $priority = null, ) { + if ($this->group !== null) { + Validate::priorityGroup($this->group); + } + $expires ??= TimeSpan::fromSeconds(self::DEFAULT_PULL_EXPIRES_SECS); if ($maxMessages !== null && $maxBytes !== null) { diff --git a/src/Micro/EndpointConfig.php b/src/Micro/EndpointConfig.php index 930e144..9c6d362 100644 --- a/src/Micro/EndpointConfig.php +++ b/src/Micro/EndpointConfig.php @@ -4,6 +4,10 @@ namespace Thesis\Nats\Micro; +use Thesis\Nats\Exception\InvalidName; +use Thesis\Nats\Exception\InvalidSubject; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api */ @@ -14,11 +18,23 @@ * @param ?non-empty-string $subject * @param ?non-empty-string $queueGroup * @param array $metadata + * @throws InvalidName + * @throws InvalidSubject */ public function __construct( public string $name, public ?string $subject = null, public ?string $queueGroup = null, public array $metadata = [], - ) {} + ) { + Validate::endpointName($this->name); + + if ($this->subject !== null) { + Validate::subscribeSubject($this->subject); + } + + if ($this->queueGroup !== null) { + Validate::queueGroup($this->queueGroup); + } + } } diff --git a/src/Micro/Group.php b/src/Micro/Group.php index d2c4523..8bc9940 100644 --- a/src/Micro/Group.php +++ b/src/Micro/Group.php @@ -34,8 +34,8 @@ public function addEndpoint( ): self { $this->svc->addEndpoint( config: new EndpointConfig( - name: "{$this->name}.{$config->name}", - subject: $config->subject, + name: $config->name, + subject: "{$this->name}." . ($config->subject ?? $config->name), queueGroup: $config->queueGroup ?? $this->queueGroup, metadata: $config->metadata, ), diff --git a/src/Micro/GroupConfig.php b/src/Micro/GroupConfig.php index 5ab7a82..ab1ee86 100644 --- a/src/Micro/GroupConfig.php +++ b/src/Micro/GroupConfig.php @@ -4,17 +4,27 @@ namespace Thesis\Nats\Micro; +use Thesis\Nats\Exception\InvalidSubject; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api */ final readonly class GroupConfig { /** - * @param non-empty-string $name + * @param non-empty-string $name the subject prefix of the group's endpoints * @param ?non-empty-string $queueGroup + * @throws InvalidSubject */ public function __construct( public string $name, public ?string $queueGroup = null, - ) {} + ) { + Validate::subjectPrefix($this->name); + + if ($this->queueGroup !== null) { + Validate::queueGroup($this->queueGroup); + } + } } diff --git a/src/Micro/ServiceConfig.php b/src/Micro/ServiceConfig.php index c9cbaec..8a4d6ab 100644 --- a/src/Micro/ServiceConfig.php +++ b/src/Micro/ServiceConfig.php @@ -4,6 +4,8 @@ namespace Thesis\Nats\Micro; +use Thesis\Nats\Internal\Validation\Validate; + /** * @api */ @@ -32,5 +34,9 @@ public function __construct( if (preg_match(self::SEMVER_REGEX, $this->version) !== 1) { throw new \InvalidArgumentException('Service version is not a valid semver string.'); } + + if ($this->queueGroup !== null) { + Validate::queueGroup($this->queueGroup); + } } } diff --git a/tests/Internal/Validation/ValidateTest.php b/tests/Internal/Validation/ValidateTest.php new file mode 100644 index 0000000..88c9073 --- /dev/null +++ b/tests/Internal/Validation/ValidateTest.php @@ -0,0 +1,249 @@ +'])] // not whole tokens, so literal characters, as in nats-server + public function testValidPublishSubject(string $subject): void + { + $this->expectNotToPerformAssertions(); + Validate::publishSubject($subject); + } + + #[TestWith(['', 'it is empty'])] + #[TestWith(['foo bar', 'it contains whitespace'])] + #[TestWith(["foo\tbar", 'it contains whitespace'])] + #[TestWith(["foo\r\nbar", 'it contains whitespace'])] + #[TestWith(["foo\x00bar", 'it contains whitespace'])] + #[TestWith(['.foo', 'it contains an empty token'])] + #[TestWith(['foo.', 'it contains an empty token'])] + #[TestWith(['foo..bar', 'it contains an empty token'])] + #[TestWith(['foo.*', "the wildcard '*' is not allowed"])] + #[TestWith(['foo.>', "the wildcard '>' is not allowed"])] + #[TestWith(['*', "the wildcard '*' is not allowed"])] + public function testInvalidPublishSubject(string $subject, string $reason): void + { + $this->expectExceptionObject(InvalidSubject::because($subject, $reason)); + Validate::publishSubject($subject); + } + + #[TestWith(['foo'])] + #[TestWith(['foo.*'])] + #[TestWith(['*.bar.*'])] + #[TestWith(['foo.>'])] + #[TestWith(['>'])] + #[TestWith(['foo.*.>'])] + public function testValidSubscribeSubject(string $subject): void + { + $this->expectNotToPerformAssertions(); + Validate::subscribeSubject($subject); + } + + #[TestWith(['', 'it is empty'])] + #[TestWith(['foo bar', 'it contains whitespace'])] + #[TestWith(['foo..*', 'it contains an empty token'])] + #[TestWith(['foo..>', 'it contains an empty token'])] + #[TestWith(['>.foo', "the wildcard '>' must be the last token"])] + #[TestWith(['foo.>.*', "the wildcard '>' must be the last token"])] + public function testInvalidSubscribeSubject(string $subject, string $reason): void + { + $this->expectExceptionObject(InvalidSubject::because($subject, $reason)); + Validate::subscribeSubject($subject); + } + + #[TestWith(['srv.api', null])] + #[TestWith(['srv.*', null])] + #[TestWith(['srv.>', "the wildcard '>' is not allowed"])] + #[TestWith(['srv..api', 'it contains an empty token'])] + public function testSubjectPrefix(string $subject, ?string $reason): void + { + if ($reason !== null) { + $this->expectExceptionObject(InvalidSubject::because($subject, $reason)); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::subjectPrefix($subject); + } + + #[TestWith(['workers', null])] + #[TestWith(['workers.v1', null])] + #[TestWith(['', 'it is empty'])] + #[TestWith(['work ers', 'it contains whitespace'])] + #[TestWith(["workers\n", 'it contains whitespace'])] + public function testQueueGroup(string $queueGroup, ?string $reason): void + { + if ($reason !== null) { + $this->expectExceptionObject(InvalidSubject::forQueueGroup($queueGroup, $reason)); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::queueGroup($queueGroup); + } + + #[TestWith(['ORDERS'])] + #[TestWith(['orders-v1_2'])] + #[TestWith(['KV_bucket'])] + #[TestWith(['имя'])] + public function testValidName(string $name): void + { + $this->expectNotToPerformAssertions(); + Validate::name($name); + } + + #[TestWith(['orders.v1'])] + #[TestWith(['orders*'])] + #[TestWith(['orders>'])] + #[TestWith(['orders/v1'])] + #[TestWith(['orders\v1'])] + #[TestWith(['my orders'])] + #[TestWith(["orders\t"])] + #[TestWith(["orders\x7f"])] + public function testNameWithForbiddenCharacters(string $name): void + { + $this->expectExceptionObject(InvalidName::because( + $name, + "it must not contain '.', '*', '>', '/', '\\', whitespace or control characters", + )); + Validate::name($name); + } + + public function testEmptyName(): void + { + $this->expectExceptionObject(InvalidName::because('', 'it is empty')); + Validate::name(''); + } + + public function testNameLength(): void + { + Validate::name(str_repeat('a', 255)); + + $name = str_repeat('a', 256); + $this->expectExceptionObject(InvalidName::because($name, 'it is longer than 255 bytes')); + Validate::name($name); + } + + #[TestWith(['Echo', true])] + #[TestWith(['echo-v1_2', true])] + #[TestWith(['', false])] + #[TestWith(['api.echo', false])] + #[TestWith(['echo*', false])] + #[TestWith(['echo service', false])] + public function testEndpointName(string $name, bool $valid): void + { + if (!$valid) { + $this->expectException(InvalidName::class); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::endpointName($name); + } + + #[TestWith(['jobs', true])] + #[TestWith(['A-z_0/9=', true])] + #[TestWith(['sixteen-chars-ok', true])] + #[TestWith(['', false])] + #[TestWith(['seventeen-chars-x', false])] + #[TestWith(['jobs.high', false])] + #[TestWith(['jobs*', false])] + #[TestWith(['my jobs', false])] + public function testPriorityGroup(string $group, bool $valid): void + { + if (!$valid) { + $this->expectExceptionObject(InvalidName::because( + $group, + "only 'A-Z', 'a-z', '0-9', '-', '_', '/' and '=' are allowed, at most 16 characters", + )); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::priorityGroup($group); + } + + #[TestWith(['bucket', true])] + #[TestWith(['my-Bucket_1', true])] + #[TestWith(['', false])] + #[TestWith(['my.bucket', false])] + #[TestWith(['my bucket', false])] + #[TestWith(['bucket*', false])] + #[TestWith(['bucket/1', false])] + public function testBucket(string $bucket, bool $valid): void + { + if (!$valid) { + $this->expectExceptionObject(InvalidBucketName::because($bucket, "only 'A-Z', 'a-z', '0-9', '-' and '_' are allowed")); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::bucket($bucket); + } + + #[TestWith(['key'])] + #[TestWith(['a.b.c'])] + #[TestWith(['path/to/key=1_2-3'])] + public function testValidKey(string $key): void + { + $this->expectNotToPerformAssertions(); + Validate::key($key); + } + + #[TestWith(['', "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=' and '.' are allowed"])] + #[TestWith(['my key', "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=' and '.' are allowed"])] + #[TestWith(['key*', "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=' and '.' are allowed"])] + #[TestWith(['a.>', "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=' and '.' are allowed"])] + #[TestWith(['.key', 'it contains an empty token'])] + #[TestWith(['key.', 'it contains an empty token'])] + #[TestWith(['a..b', 'it contains an empty token'])] + public function testInvalidKey(string $key, string $reason): void + { + $this->expectExceptionObject(InvalidKey::because($key, $reason)); + Validate::key($key); + } + + #[TestWith(['key', null])] + #[TestWith(['a.*', null])] + #[TestWith(['a.*.c', null])] + #[TestWith(['>', null])] + #[TestWith(['a.>', null])] + #[TestWith(['a.>.b', "the wildcard '>' must be the last token"])] + #[TestWith(['a..b', 'it contains an empty token'])] + #[TestWith(['a b', "only 'A-Z', 'a-z', '0-9', '-', '/', '_', '=', '.', '*' and '>' are allowed"])] + public function testKeyFilter(string $key, ?string $reason): void + { + if ($reason !== null) { + $this->expectExceptionObject(InvalidKey::because($key, $reason)); + } else { + $this->expectNotToPerformAssertions(); + } + + Validate::keyFilter($key); + } + + public function testObjectName(): void + { + Validate::objectName('any name, even with spaces / and dots.'); + + $this->expectExceptionObject(new ObjectIsInvalid('Object name is empty.')); + Validate::objectName(''); + } +} diff --git a/tests/JetStream/Api/ConsumerConfigTest.php b/tests/JetStream/Api/ConsumerConfigTest.php new file mode 100644 index 0000000..f446ac3 --- /dev/null +++ b/tests/JetStream/Api/ConsumerConfigTest.php @@ -0,0 +1,58 @@ +expectNotToPerformAssertions(); + + new ConsumerConfig(); + // Empty means no name: the consumer gets a generated one. + new ConsumerConfig(name: '', durableName: '', filterSubject: ''); + new ConsumerConfig( + name: 'processor', + durableName: 'processor', + deliverSubject: 'deliver.processor', + deliverGroup: 'workers', + filterSubject: 'orders.*', + filterSubjects: ['orders.>'], + priorityGroups: ['jobs', 'jobs/high'], + ); + } + + /** + * @param \Closure(): ConsumerConfig $create + * @param class-string<\Throwable> $exception + */ + #[DataProvider('provideInvalidCases')] + public function testInvalid(\Closure $create, string $exception): void + { + $this->expectException($exception); + $create(); + } + + /** + * @return iterable}> + */ + public static function provideInvalidCases(): iterable + { + yield 'name' => [static fn() => new ConsumerConfig(name: 'my.consumer'), InvalidName::class]; + yield 'durable name' => [static fn() => new ConsumerConfig(durableName: 'my consumer'), InvalidName::class]; + yield 'deliver subject with wildcard' => [static fn() => new ConsumerConfig(deliverSubject: 'deliver.*'), InvalidSubject::class]; + yield 'deliver group' => [static fn() => new ConsumerConfig(deliverSubject: 'deliver', deliverGroup: 'my workers'), InvalidSubject::class]; + yield 'filter subject' => [static fn() => new ConsumerConfig(filterSubject: 'orders.'), InvalidSubject::class]; + yield 'filter subjects' => [static fn() => new ConsumerConfig(filterSubjects: ['orders.*', '>.orders']), InvalidSubject::class]; + yield 'priority group' => [static fn() => new ConsumerConfig(priorityGroups: ['jobs', 'jobs.high']), InvalidName::class]; + } +} diff --git a/tests/JetStream/Api/StreamConfigTest.php b/tests/JetStream/Api/StreamConfigTest.php new file mode 100644 index 0000000..7af64da --- /dev/null +++ b/tests/JetStream/Api/StreamConfigTest.php @@ -0,0 +1,56 @@ +'], + mirror: new StreamSource('MIRROR'), + sources: [new StreamSource('SOURCE', filterSubject: 'orders.>')], + ); + + self::assertSame('ORDERS', $config->seal()->name); + } + + public function testInvalidName(): void + { + $this->expectException(InvalidName::class); + new StreamConfig(name: 'orders.v1'); + } + + public function testInvalidSubject(): void + { + $this->expectExceptionObject(InvalidSubject::because('orders..*', 'it contains an empty token')); + new StreamConfig(name: 'ORDERS', subjects: ['orders.>', 'orders..*']); + } + + public function testInvalidSourceName(): void + { + $this->expectException(InvalidName::class); + new StreamSource('my mirror'); + } + + public function testInvalidSourceFilter(): void + { + $this->expectException(InvalidSubject::class); + new StreamSource('SOURCE', filterSubject: '>.orders'); + } + + public function testEmptySourceFilterMeansNone(): void + { + self::assertSame('', (new StreamSource('SOURCE', filterSubject: ''))->filterSubject); + } +} diff --git a/tests/Micro/ServiceTest.php b/tests/Micro/ServiceTest.php index 414a445..3ed3d18 100644 --- a/tests/Micro/ServiceTest.php +++ b/tests/Micro/ServiceTest.php @@ -125,7 +125,7 @@ public function testControlResponsesMatchMicroSchema(): void description: 'Service for schema testing', )); - $srv->addEndpoint(new EndpointConfig('schema.echo'), static function (Request $request): void { + $srv->addEndpoint(new EndpointConfig('echo', subject: 'schema.echo'), static function (Request $request): void { $request->respond(new Response($request->data)); }); @@ -227,7 +227,7 @@ public function testQueueGroups(): void queueGroup: "q-{$i}", )); - $srv->addEndpoint(new EndpointConfig('svc.echo'), static function (Request $request) use ($i): void { + $srv->addEndpoint(new EndpointConfig('echo', subject: 'svc.echo'), static function (Request $request) use ($i): void { $request->respond(new Response("echo#{$i}")); }); } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php new file mode 100644 index 0000000..6617eea --- /dev/null +++ b/tests/ValidationTest.php @@ -0,0 +1,306 @@ + $exception + */ + #[DataProvider('provideInvalidCallFailsBeforeConnectingCases')] + public function testInvalidCallFailsBeforeConnecting(\Closure $call, string $exception): void + { + $server = listen('127.0.0.1:0'); + $connections = 0; + + async(static function () use ($server, &$connections): void { + while (($socket = $server->accept()) !== null) { + ++$connections; + $socket->close(); + } + })->ignore(); + + try { + $nc = $this->clientWithUrls([self::address($server)]); + + try { + $call($nc, new JetStream($nc)); + self::fail("{$exception} must be thrown."); + } catch (NatsException $e) { + self::assertInstanceOf($exception, $e, $e->getMessage()); + } + + self::assertSame(0, $connections, 'Nothing must be sent to the server.'); + } finally { + $server->close(); + } + } + + /** + * @return iterable}> + */ + public static function provideInvalidCallFailsBeforeConnectingCases(): iterable + { + $noop = static function (): void {}; + + yield 'publish with a wildcard' => [static fn(Client $nc) => $nc->publish('orders.*'), InvalidSubject::class]; + yield 'publish with whitespace' => [static fn(Client $nc) => $nc->publish('orders new'), InvalidSubject::class]; + yield 'publish with an invalid reply-to' => [static fn(Client $nc) => $nc->publish('orders', replyTo: 'inbox.>'), InvalidSubject::class]; + yield 'request with an empty token' => [static fn(Client $nc) => $nc->request('orders..new'), InvalidSubject::class]; + yield 'subscribe with > not last' => [static fn(Client $nc) => $nc->subscribe('orders.>.new', $noop), InvalidSubject::class]; + yield 'subscribe with an invalid queue group' => [static fn(Client $nc) => $nc->subscribe('orders', $noop, queueGroup: 'my workers'), InvalidSubject::class]; + yield 'subscribe iterator' => [static fn(Client $nc) => $nc->subscribeIterator('.orders'), InvalidSubject::class]; + yield 'jetstream publish' => [static fn(Client $nc, JetStream $js) => $js->publish('orders.*', new Message()), InvalidSubject::class]; + yield 'create stream' => [static fn(Client $nc, JetStream $js) => $js->createStream(new StreamConfig('orders.v1')), InvalidName::class]; + yield 'create stream with an invalid subject' => [ + static fn(Client $nc, JetStream $js) => $js->createStream(new StreamConfig('ORDERS', subjects: ['orders..*'])), + InvalidSubject::class, + ]; + yield 'update stream' => [static fn(Client $nc, JetStream $js) => $js->updateStream(new StreamConfig('my orders')), InvalidName::class]; + yield 'stream' => [static fn(Client $nc, JetStream $js) => $js->stream('orders/v1'), InvalidName::class]; + yield 'delete stream' => [static fn(Client $nc, JetStream $js) => $js->deleteStream('orders*'), InvalidName::class]; + yield 'purge stream with an invalid subject' => [ + static fn(Client $nc, JetStream $js) => $js->purgeStream('ORDERS', subject: '>.orders'), + InvalidSubject::class, + ]; + yield 'stream names' => [static fn(Client $nc, JetStream $js) => iterator_to_array($js->streamNames('orders..*')), InvalidSubject::class]; + yield 'create consumer' => [ + static fn(Client $nc, JetStream $js) => $js->createConsumer('ORDERS', new ConsumerConfig(durableName: 'my.consumer')), + InvalidName::class, + ]; + yield 'create consumer with an invalid filter' => [ + static fn(Client $nc, JetStream $js) => $js->createConsumer('ORDERS', new ConsumerConfig(filterSubject: 'orders.')), + InvalidSubject::class, + ]; + yield 'create consumer on an invalid stream' => [ + static fn(Client $nc, JetStream $js) => $js->createOrUpdateConsumer('ORDERS.v1'), + InvalidName::class, + ]; + yield 'consumer' => [static fn(Client $nc, JetStream $js) => $js->consumer('ORDERS', 'processor>'), InvalidName::class]; + yield 'delete consumer' => [static fn(Client $nc, JetStream $js) => $js->deleteConsumer('ORDERS', 'my processor'), InvalidName::class]; + yield 'unpin consumer with an invalid group' => [ + static fn(Client $nc, JetStream $js) => $js->unpinConsumer('ORDERS', 'processor', 'jobs.high'), + InvalidName::class, + ]; + yield 'create consumer with an invalid priority group' => [ + static fn(Client $nc, JetStream $js) => $js->createConsumer('ORDERS', new ConsumerConfig(priorityGroups: ['seventeen-chars-x'])), + InvalidName::class, + ]; + yield 'pull consume config with an invalid group' => [ + static fn() => new PullConsumeConfig(group: 'my jobs'), + InvalidName::class, + ]; + yield 'consumer list' => [static fn(Client $nc, JetStream $js) => iterator_to_array($js->consumerList('ORDERS.*')), InvalidName::class]; + yield 'key value' => [static fn(Client $nc, JetStream $js) => $js->keyValue('my.bucket'), InvalidBucketName::class]; + yield 'create key value' => [ + static fn(Client $nc, JetStream $js) => $js->createOrUpdateKeyValue(new BucketConfig('my bucket')), + InvalidBucketName::class, + ]; + yield 'delete key value' => [static fn(Client $nc, JetStream $js) => $js->deleteKeyValue('bucket*'), InvalidBucketName::class]; + yield 'object store' => [static fn(Client $nc, JetStream $js) => $js->objectStore('my/store'), InvalidBucketName::class]; + yield 'create object store' => [ + static fn(Client $nc, JetStream $js) => $js->createOrUpdateObjectStore(new StoreConfig('my.store')), + InvalidBucketName::class, + ]; + yield 'delete object store' => [static fn(Client $nc, JetStream $js) => $js->deleteObjectStore('my store'), InvalidBucketName::class]; + yield 'create counter' => [ + static fn(Client $nc, JetStream $js) => $js->createOrUpdateCounter(new CounterConfig('my.counter')), + InvalidName::class, + ]; + yield 'delete counter' => [static fn(Client $nc, JetStream $js) => $js->deleteCounter('my counter'), InvalidName::class]; + } + + public function testValidSubjectsWithWildcardsStillWork(): void + { + $nc = $this->client(); + $js = $nc->jetStream(); + + $stream = $js->createStream(new StreamConfig( + name: $name = generateUniqueId(10), + subjects: ["{$name}.*.>"], + )); + + try { + $js->publish("{$name}.orders.new", new Message('x')); + + // The filter with a wildcard is part of the JetStream API request subject, which must not be rejected. + $consumer = $stream->createConsumer(new ConsumerConfig( + durableName: 'processor', + filterSubject: "{$name}.*.new", + )); + self::assertSame("{$name}.*.new", $consumer->info->config->filterSubject); + + self::assertNotNull($stream->getLastMessageForSubject("{$name}.orders.new")); + } finally { + $js->deleteStream($name); + } + } + + public function testInvalidKeyValueKey(): void + { + $js = $this->client()->jetStream(); + $kv = $js->createOrUpdateKeyValue(new BucketConfig($bucket = generateUniqueId(10))); + + try { + foreach ([ + static fn() => $kv->put('my key', 'x'), + static fn() => $kv->put('.key', 'x'), + static fn() => $kv->put('a.*', 'x'), + static fn() => $kv->update('a..b', 1, 'x'), + static fn() => $kv->get('key.'), + static fn() => $kv->delete('key>'), + static fn() => $kv->purge('a b'), + static fn() => $kv->watch(static function (): void {}, ['a.>.b']), + ] as $i => $call) { + try { + $call(); + self::fail("Call #{$i} must fail."); + } catch (InvalidKey) { + } + } + + self::assertSame(0, $js->streamInfo("KV_{$bucket}")->state->messages); + + // Wildcards are allowed in watch filters. + $kv->watch(static function (): void {}, ['a.*', 'b.>'])->stop(); + } finally { + $js->deleteKeyValue($bucket); + } + } + + public function testInvalidCounterSubject(): void + { + $js = $this->client()->jetStream(); + $counter = $js->createOrUpdateCounter(new CounterConfig($name = generateUniqueId(10))); + + try { + foreach ([ + static fn() => $counter->add('clicks.*', 1), + static fn() => $counter->get('clicks.>'), + static fn() => iterator_to_array($counter->getMultiple(['clicks..x'])), + ] as $i => $call) { + try { + $call(); + self::fail("Call #{$i} must fail."); + } catch (InvalidSubject) { + } + } + + self::assertSame(0, $js->streamInfo("CNT_{$name}")->state->messages); + } finally { + $js->deleteCounter($name); + } + } + + public function testEmptyObjectName(): void + { + $js = $this->client()->jetStream(); + $store = $js->createOrUpdateObjectStore(new StoreConfig($bucket = generateUniqueId(10))); + + try { + $name = ''; + + try { + /** @phpstan-ignore argument.type */ + $store->put(new ObjectMeta($name), 'x'); + self::fail('Empty object name must be rejected.'); + } catch (ObjectIsInvalid) { + } + + try { + /** @phpstan-ignore argument.type */ + $store->info($name); + self::fail('Empty object name must be rejected.'); + } catch (ObjectIsInvalid) { + } + + self::assertSame(0, $js->streamInfo("OBJ_{$bucket}")->state->messages); + } finally { + $js->deleteObjectStore($bucket); + } + } + + public function testInvalidMicroConfigs(): void + { + foreach ([ + static fn() => new EndpointConfig('api.echo'), + static fn() => new EndpointConfig('echo', subject: 'echo.>.x'), + static fn() => new EndpointConfig('echo', queueGroup: 'my workers'), + static fn() => new GroupConfig('api.>'), + static fn() => new GroupConfig('api', queueGroup: 'my workers'), + static fn() => new ServiceConfig('Echo', '1.0.0', queueGroup: 'my workers'), + ] as $i => $create) { + try { + $create(); + self::fail("Config #{$i} must be rejected."); + } catch (InvalidName|InvalidSubject) { + } + } + + $this->expectNotToPerformAssertions(); + } + + public function testGroupEndpointSubject(): void + { + $nc = $this->client(); + + $srv = $nc->createService(new ServiceConfig('EchoService', '1.0.0')); + $srv + ->addGroup(new GroupConfig('api')) + ->addEndpoint(new EndpointConfig('echo', subject: 'echo.*'), static function (Request $request): void { + $request->respond(new Response($request->subject)); + }); + + // The group prefixes the endpoint's own subject, and the endpoint keeps its name, as in nats.go. + self::assertSame('api.echo.x', $nc->request('api.echo.x')->message->payload); + $endpoint = $srv->info()->endpoints[0] ?? null; + self::assertNotNull($endpoint); + self::assertSame('echo', $endpoint->name); + self::assertSame('api.echo.*', $endpoint->subject); + + $srv->stop(); + } + + /** + * @return non-empty-string + */ + private static function address(ServerSocket $server): string + { + $address = $server->getAddress()->toString(); + \assert($address !== ''); + + return $address; + } +}