From c81366e5a3f2818e83a25432edfd378e886e9306 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 4 Sep 2026 11:53:49 +0300 Subject: [PATCH 1/2] Require non-empty message type --- docs/guide/en/message-handler-advanced.md | 2 +- docs/guide/en/messages-and-handlers.md | 2 +- docs/guide/en/migrating-from-yii2-queue.md | 2 +- .../MessageClassResolverInterface.php | 3 +- src/Message/GenericMessage.php | 14 ++++++++-- src/Message/Handler/HandlerResolver.php | 9 ++---- src/Message/MessageInterface.php | 7 +++-- src/Message/Serializer/MessageSerializer.php | 5 +++- tests/Unit/Message/GenericMessageTest.php | 28 +++++++++++++++++++ .../Handler/Resolver/HandlerResolverTest.php | 12 -------- .../Serializer/MessageSerializerTest.php | 12 ++++++++ 11 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 tests/Unit/Message/GenericMessageTest.php diff --git a/docs/guide/en/message-handler-advanced.md b/docs/guide/en/message-handler-advanced.md index 706b3345..a7fff967 100644 --- a/docs/guide/en/message-handler-advanced.md +++ b/docs/guide/en/message-handler-advanced.md @@ -15,7 +15,7 @@ Handler definitions are configured in: ### Handlers mapped by short message type Use a short stable message type instead of a PHP class name. That decoupling would allow you to refactor the code and handle the message with external handler. -Define a dedicated message class where `getType()` returns that type: +Define a dedicated message class where `getType()` returns that type. The type must be a non-empty string: ```php use Yiisoft\Queue\Message\Message; diff --git a/docs/guide/en/messages-and-handlers.md b/docs/guide/en/messages-and-handlers.md index 7c08654b..533e95dc 100644 --- a/docs/guide/en/messages-and-handlers.md +++ b/docs/guide/en/messages-and-handlers.md @@ -89,7 +89,7 @@ new SendEmailMessage('user@example.com', 'Welcome', 'Thank you for registering.' The message has: -- A **message type** — a string used by the worker to look up the correct handler. +- A **message type** — a non-empty string used by the worker to look up the correct handler. - A **data payload** — typed properties serialized via `getPayload()`. Must contain only `null`, scalars (`bool`, `int`, `float`, `string`), or arrays composed of the same types recursively. The message has no business logic, no dependencies. It is a value object — a typed data wrapper. diff --git a/docs/guide/en/migrating-from-yii2-queue.md b/docs/guide/en/migrating-from-yii2-queue.md index 38620f09..8542ec02 100644 --- a/docs/guide/en/migrating-from-yii2-queue.md +++ b/docs/guide/en/migrating-from-yii2-queue.md @@ -18,7 +18,7 @@ There was a concept in [yiisoft/yii2-queue] called `Job`: you had to push it to being consumed. In the new package, it is divided into two different concepts: a message and a handler. - A `Message` is a class implementing `MessageInterface`. It contains two types of data: - - Type. The worker uses it to find the right handler for a message. + - Type. A non-empty string. The worker uses it to find the right handler for a message. - Payload. Any serializable data that should be used by the message handler. All the message payload is fully serializable (that means message `payload` must be serializable too). It allows you to diff --git a/src/Message/ClassResolver/MessageClassResolverInterface.php b/src/Message/ClassResolver/MessageClassResolverInterface.php index 96e821e5..b379e603 100644 --- a/src/Message/ClassResolver/MessageClassResolverInterface.php +++ b/src/Message/ClassResolver/MessageClassResolverInterface.php @@ -14,10 +14,11 @@ interface MessageClassResolverInterface /** * Returns the message class for the given type, or `null` if the type is not registered. * - * @param string $type Message type. + * @param string $type Message type. Must be a non-empty string. * * @return string|null Message class, or `null` if the type is not registered. * + * @psalm-param non-empty-string $type * @psalm-return class-string|null */ public function resolve(string $type): ?string; diff --git a/src/Message/GenericMessage.php b/src/Message/GenericMessage.php index 71b880a1..86751644 100644 --- a/src/Message/GenericMessage.php +++ b/src/Message/GenericMessage.php @@ -4,6 +4,8 @@ namespace Yiisoft\Queue\Message; +use InvalidArgumentException; + /** * A general-purpose immutable {@see MessageInterface} implementation that holds a message type and its payload data. * @@ -14,16 +16,24 @@ final class GenericMessage extends Message { /** - * @param string $type A message type used to resolve the handler. + * @param string $type A message type used to resolve the handler. Must be a non-empty string. * @param bool|int|float|string|array|null $payload Message payload data. Must contain only `null`, scalars (`bool`, * `int`, `float`, `string`), or arrays composed of the same types recursively. * + * @psalm-param non-empty-string $type * @psalm-param MessagePayload $payload */ public function __construct( private readonly string $type, private readonly bool|int|float|string|array|null $payload, - ) {} + ) { + /** + * @psalm-suppress TypeDoesNotContainType Guard against an empty type passed without static analysis. + */ + if ($this->type === '') { + throw new InvalidArgumentException('Message type must be a non-empty string.'); + } + } public static function fromPayload(string $type, bool|int|float|string|array|null $payload): static { diff --git a/src/Message/Handler/HandlerResolver.php b/src/Message/Handler/HandlerResolver.php index cf6d3db9..c1b4dec4 100644 --- a/src/Message/Handler/HandlerResolver.php +++ b/src/Message/Handler/HandlerResolver.php @@ -4,7 +4,6 @@ namespace Yiisoft\Queue\Message\Handler; -use LogicException; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Yiisoft\Injector\Injector; @@ -51,7 +50,9 @@ public function __construct( /** * Get a handler for the given message type. * - * @param string $messageType Message type. + * @param string $messageType Message type. Must be a non-empty string. + * + * @psalm-param non-empty-string $messageType * * @throws HandlerNotFoundException If no handler exists for the message type. * @throws InvalidHandlerConfigurationException If the handler definition is configured incorrectly. @@ -59,10 +60,6 @@ public function __construct( */ public function resolve(string $messageType): HandlerInterface { - if ($messageType === '') { - throw new LogicException('Message type cannot be empty.'); - } - if (array_key_exists($messageType, $this->cache)) { return $this->cache[$messageType]; } diff --git a/src/Message/MessageInterface.php b/src/Message/MessageInterface.php index 7956c3da..100effca 100644 --- a/src/Message/MessageInterface.php +++ b/src/Message/MessageInterface.php @@ -15,10 +15,11 @@ interface MessageInterface /** * Creates a new message instance from the given type and payload data. * - * @param string $type Message type. + * @param string $type Message type. Must be a non-empty string. * @param bool|int|float|string|array|null $payload Message payload data. Must contain only `null`, scalars (`bool`, * `int`, `float`, `string`), or arrays composed of the same types recursively. * + * @psalm-param non-empty-string $type * @psalm-param MessagePayload $payload * * @return static Instance of the called class with the given type and payload. @@ -28,7 +29,9 @@ public static function fromPayload(string $type, bool|int|float|string|array|nul /** * Returns message type. * - * @return string Message type. + * @return string Message type. Always a non-empty string. + * + * @psalm-return non-empty-string */ public function getType(): string; diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index 21dfb43c..cb0bb73e 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -61,9 +61,12 @@ public function unserialize(string $value): MessageInterface } $type = $data['type'] ?? null; - if (!isset($type) || !is_string($type)) { + if (!is_string($type)) { throw new MessageSerializerException('Message type must be a string. Got ' . get_debug_type($type) . '.'); } + if ($type === '') { + throw new MessageSerializerException('Message type must be a non-empty string.'); + } $meta = $data['meta'] ?? []; if (!is_array($meta)) { diff --git a/tests/Unit/Message/GenericMessageTest.php b/tests/Unit/Message/GenericMessageTest.php new file mode 100644 index 00000000..1575f7ec --- /dev/null +++ b/tests/Unit/Message/GenericMessageTest.php @@ -0,0 +1,28 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Message type must be a non-empty string.'); + + new GenericMessage('', null); + } + + public function testFromPayloadThrowsWhenTypeIsEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Message type must be a non-empty string.'); + + GenericMessage::fromPayload('', null); + } +} diff --git a/tests/Unit/Message/Handler/Resolver/HandlerResolverTest.php b/tests/Unit/Message/Handler/Resolver/HandlerResolverTest.php index e741ecf1..ac0f13a5 100644 --- a/tests/Unit/Message/Handler/Resolver/HandlerResolverTest.php +++ b/tests/Unit/Message/Handler/Resolver/HandlerResolverTest.php @@ -4,7 +4,6 @@ namespace Yiisoft\Queue\Tests\Unit\Message\Handler\Resolver; -use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Yiisoft\Test\Support\Container\SimpleContainer; @@ -182,17 +181,6 @@ public function handle(): void {} $resolver->resolve('invalid'); } - - public function testResolveThrowsWhenMessageTypeIsEmpty(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Message type cannot be empty.'); - - $container = new SimpleContainer(); - $resolver = new HandlerResolver([], $container); - - $resolver->resolve(''); - } } function namedFunctionHandler(MessageInterface $message): void diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index 9913d76c..1d560a1f 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -46,6 +46,18 @@ public function testUnsupportedType(mixed $type): void $this->createSerializer()->unserialize($value); } + public function testEmptyType(): void + { + $value = json_encode( + ['type' => '', 'payload' => 'test', 'meta' => []], + JSON_THROW_ON_ERROR, + ); + + $this->expectException(MessageSerializerException::class); + $this->expectExceptionMessage('Message type must be a non-empty string.'); + $this->createSerializer()->unserialize($value); + } + #[TestWith([''])] #[TestWith([1])] #[TestWith([true])] From 7c8c12930bb2ccd4a8893d3f2b72b34b30ea06cf Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 4 Sep 2026 11:56:51 +0300 Subject: [PATCH 2/2] improve --- src/Message/Serializer/MessageSerializer.php | 6 +++--- tests/Unit/Message/Serializer/MessageSerializerTest.php | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Message/Serializer/MessageSerializer.php b/src/Message/Serializer/MessageSerializer.php index cb0bb73e..f99bfc95 100644 --- a/src/Message/Serializer/MessageSerializer.php +++ b/src/Message/Serializer/MessageSerializer.php @@ -57,15 +57,15 @@ public function unserialize(string $value): MessageInterface $data = $this->encoder->decode($value); if (!is_array($data)) { - throw new MessageSerializerException('Decoded data must be array. Got ' . get_debug_type($data) . '.'); + throw new MessageSerializerException('Decoded data must be an array. Got ' . get_debug_type($data) . '.'); } $type = $data['type'] ?? null; if (!is_string($type)) { - throw new MessageSerializerException('Message type must be a string. Got ' . get_debug_type($type) . '.'); + throw new MessageSerializerException('Message type must be a non-empty string. Got ' . get_debug_type($type) . '.'); } if ($type === '') { - throw new MessageSerializerException('Message type must be a non-empty string.'); + throw new MessageSerializerException('Message type must be a non-empty string. Got empty string.'); } $meta = $data['meta'] ?? []; diff --git a/tests/Unit/Message/Serializer/MessageSerializerTest.php b/tests/Unit/Message/Serializer/MessageSerializerTest.php index 1d560a1f..8ded9e6a 100644 --- a/tests/Unit/Message/Serializer/MessageSerializerTest.php +++ b/tests/Unit/Message/Serializer/MessageSerializerTest.php @@ -26,7 +26,7 @@ final class MessageSerializerTest extends TestCase public function testNonArrayPayload(string $json, string $type): void { $this->expectException(MessageSerializerException::class); - $this->expectExceptionMessage(sprintf('Decoded data must be array. Got %s.', $type)); + $this->expectExceptionMessage(sprintf('Decoded data must be an array. Got %s.', $type)); $this->createSerializer()->unserialize($json); } @@ -42,7 +42,9 @@ public function testUnsupportedType(mixed $type): void ); $this->expectException(MessageSerializerException::class); - $this->expectExceptionMessage(sprintf('Message type must be a string. Got %s.', get_debug_type($type))); + $this->expectExceptionMessage( + sprintf('Message type must be a non-empty string. Got %s.', get_debug_type($type)), + ); $this->createSerializer()->unserialize($value); } @@ -54,7 +56,7 @@ public function testEmptyType(): void ); $this->expectException(MessageSerializerException::class); - $this->expectExceptionMessage('Message type must be a non-empty string.'); + $this->expectExceptionMessage('Message type must be a non-empty string. Got empty string.'); $this->createSerializer()->unserialize($value); }