diff --git a/phpstan.neon b/phpstan.neon index d7f8277..c308dcf 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,4 +1,4 @@ parameters: - level: 6 + level: 8 paths: - src diff --git a/src/Api.php b/src/Api.php index ecf4ef9..902b144 100644 --- a/src/Api.php +++ b/src/Api.php @@ -12,6 +12,7 @@ use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Result; use BushlanovDev\MaxMessengerBot\Models\Subscription; use InvalidArgumentException; +use ReflectionException; /** * The main entry point for interacting with the Max Bot API. @@ -63,6 +64,7 @@ class Api * @throws ClientApiException * @throws NetworkException * @throws SerializationException + * @throws ReflectionException */ public function getBotInfo(): BotInfo { @@ -79,6 +81,7 @@ class Api * @throws ClientApiException * @throws NetworkException * @throws SerializationException + * @throws ReflectionException */ public function getSubscriptions(): array { @@ -92,14 +95,19 @@ class Api * * @param string $url URL webhook. * @param string|null $secret Secret key for verifying the authenticity of requests. - * @param UpdateType[]|null $update_types List of update types. + * @param UpdateType[]|null $updateTypes List of update types. * * @return Result + * + * @throws ClientApiException + * @throws NetworkException + * @throws SerializationException + * @throws ReflectionException */ public function subscribe( string $url, ?string $secret = null, - ?array $update_types = null, + ?array $updateTypes = null, ): Result { return $this->modelFactory->createResult( $this->client->request( @@ -109,7 +117,7 @@ class Api [ 'url' => $url, 'secret' => $secret, - 'update_types' => $update_types ? array_map(fn($type) => $type->value, $update_types) : null, + 'update_types' => !empty($updateTypes) ? array_map(fn($type) => $type->value, $updateTypes) : null, ] ) ); @@ -121,6 +129,11 @@ class Api * @param string $url URL webhook. * * @return Result + * + * @throws ClientApiException + * @throws NetworkException + * @throws SerializationException + * @throws ReflectionException */ public function unsubscribe(string $url): Result { diff --git a/src/Attributes/ArrayOf.php b/src/Attributes/ArrayOf.php new file mode 100644 index 0000000..0f60bd6 --- /dev/null +++ b/src/Attributes/ArrayOf.php @@ -0,0 +1,18 @@ + new UnauthorizedException($errorMessage, $errorCode, $response), 403 => new ForbiddenException($errorMessage, $errorCode, $response), 404 => new NotFoundException($errorMessage, $errorCode, $response), + 405 => new MethodNotAllowedException($errorMessage, $errorCode, $response), + 429 => new RateLimitExceededException($errorMessage, $errorCode, $response), default => new ClientApiException($errorMessage, $errorCode, $response, $statusCode), }; diff --git a/src/Exceptions/MethodNotAllowedException.php b/src/Exceptions/MethodNotAllowedException.php new file mode 100644 index 0000000..45d2ead --- /dev/null +++ b/src/Exceptions/MethodNotAllowedException.php @@ -0,0 +1,20 @@ + $data * * @return Result + * @throws ReflectionException */ public function createResult(array $data): Result { @@ -32,12 +33,10 @@ class ModelFactory * @param array $data * * @return BotInfo + * @throws ReflectionException */ public function createBotInfo(array $data): BotInfo { - $data['commands'] = isset($data['commands']) && is_array($data['commands']) - ? array_map([BotCommand::class, 'fromArray'], $data['commands']) : null; - return BotInfo::fromArray($data); } @@ -47,6 +46,7 @@ class ModelFactory * @param array $data * * @return Subscription + * @throws ReflectionException */ public function createSubscription(array $data): Subscription { @@ -59,6 +59,7 @@ class ModelFactory * @param array $data * * @return Subscription[] + * @throws ReflectionException */ public function createSubscriptions(array $data): array { diff --git a/src/Models/AbstractModel.php b/src/Models/AbstractModel.php index 9f6cad7..8037b23 100644 --- a/src/Models/AbstractModel.php +++ b/src/Models/AbstractModel.php @@ -4,23 +4,159 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Models; +use BackedEnum; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; +use ReflectionClass; +use ReflectionException; +use ReflectionNamedType; +use ReflectionProperty; + abstract readonly class AbstractModel { /** * @param array $data + * + * @return static + * @throws ReflectionException */ - abstract public static function fromArray(array $data): static; + public static function fromArray(array $data): static + { + $reflectionClass = new ReflectionClass(static::class); + $constructorArgs = []; + + foreach ($reflectionClass->getConstructor()?->getParameters() ?? [] as $param) { + $phpPropertyName = $param->getName(); + $property = $reflectionClass->getProperty($phpPropertyName); + + $jsonKey = self::toSnakeCase($phpPropertyName); + $rawValue = $data[$jsonKey] ?? null; + + if (!array_key_exists($jsonKey, $data) && $param->isDefaultValueAvailable()) { + $constructorArgs[$phpPropertyName] = $param->getDefaultValue(); + continue; + } + + $constructorArgs[$phpPropertyName] = self::castValue($rawValue, $property); + } + + return new static(...$constructorArgs); // @phpstan-ignore-line + } + + /** + * @param mixed $value + * @param ReflectionProperty $property + * + * @return mixed + * @throws ReflectionException + */ + private static function castValue(mixed $value, ReflectionProperty $property): mixed + { + $type = $property->getType(); + + if (is_null($value) || !$type instanceof ReflectionNamedType) { + return $value; + } + + $typeName = $type->getName(); + + if ($type->isBuiltin()) { + return match ($typeName) { + 'int' => (int)$value, + 'string' => (string)$value, + 'bool' => (bool)$value, + 'float' => (float)$value, + 'array' => self::castArray($value, $property), + default => $value, + }; + } + + if (is_subclass_of($typeName, BackedEnum::class)) { + return $typeName::from($value); + } + + if (is_subclass_of($typeName, self::class)) { + return $typeName::fromArray($value); + } + + return $value; + } + + /** + * @param mixed $value + * @param ReflectionProperty $property + * + * @return array + * @throws ReflectionException + */ + private static function castArray(mixed $value, ReflectionProperty $property): array + { + if (!is_array($value)) { + return (array)$value; + } + + $attributes = $property->getAttributes(ArrayOf::class); + + if (empty($attributes)) { + return $value; + } + + /** @var ArrayOf $arrayOfAttribute */ + $arrayOfAttribute = $attributes[0]->newInstance(); + $itemClassName = $arrayOfAttribute->class; + + if (is_subclass_of($itemClassName, BackedEnum::class)) { + return array_map(fn($item) => $itemClassName::from($item), $value); + } + + if (is_subclass_of($itemClassName, self::class)) { + return array_map(fn($item) => $itemClassName::fromArray($item), $value); + } + + return $value; + } + + /** + * @param string $input + * + * @return string + */ + private static function toSnakeCase(string $input): string + { + return strtolower((string)preg_replace('/(? + * @throws ReflectionException */ public function toArray(): array { - return array_map(function ($value) { - return $this->convertValue($value); - }, get_object_vars($this)); + $reflectionClass = new ReflectionClass($this); + $properties = $reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC); + $result = []; + + foreach ($properties as $property) { + if (!$property->isInitialized($this)) { + continue; + } + + $phpPropertyName = $property->getName(); + $value = $property->getValue($this); + + $jsonKey = self::toSnakeCase($phpPropertyName); + + $result[$jsonKey] = $this->convertValue($value); + } + + return $result; } + /** + * @param mixed $value + * + * @return mixed + * @throws ReflectionException + */ private function convertValue(mixed $value): mixed { if ($value instanceof AbstractModel) { @@ -31,7 +167,7 @@ abstract readonly class AbstractModel return array_map([$this, 'convertValue'], $value); } - if ($value instanceof \BackedEnum) { + if ($value instanceof BackedEnum) { return $value->value; } diff --git a/src/Models/BotCommand.php b/src/Models/BotCommand.php index ede5c20..dda45ec 100644 --- a/src/Models/BotCommand.php +++ b/src/Models/BotCommand.php @@ -18,15 +18,4 @@ final readonly class BotCommand extends AbstractModel public ?string $description, ) { } - - /** - * @inheritdoc - */ - public static function fromArray(array $data): static - { - return new static( - (string)$data['name'], - $data['description'] ? (string)$data['description'] : null - ); - } } diff --git a/src/Models/BotInfo.php b/src/Models/BotInfo.php index 97e3721..89d2e51 100644 --- a/src/Models/BotInfo.php +++ b/src/Models/BotInfo.php @@ -4,53 +4,37 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Models; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; + /** * Information about the current bot. */ final readonly class BotInfo extends AbstractModel { /** - * @param int $user_id ID user. - * @param string $first_name User display name. - * @param string|null $last_name User's display last name. + * @param int $userId ID user. + * @param string $firstName User display name. + * @param string|null $lastName User's display last name. * @param string|null $username Unique public name of the user, may be null if the user is not available or no name is set. - * @param bool $is_bot Is the user a bot. - * @param int $last_activity_time User last activity time in MAX (Unix time in milliseconds). May be irrelevant if the user has disabled the "online" status in the settings. + * @param bool $isBot Is the user a bot. + * @param int $lastActivityTime User last activity time in MAX (Unix time in milliseconds). May be irrelevant if the user has disabled the "online" status in the settings. * @param string|null $description User description, may be null if the user has not filled it in (up to 16000 characters). - * @param string|null $avatar_url Avatar URL. - * @param string|null $full_avatar_url Larger Avatar URL. + * @param string|null $avatarUrl Avatar URL. + * @param string|null $fullAvatarUrl Larger Avatar URL. * @param BotCommand[]|null $commands Commands supported by the bot (up to 32 elements). */ public function __construct( - public int $user_id, - public string $first_name, - public ?string $last_name, + public int $userId, + public string $firstName, + public ?string $lastName, public ?string $username, - public bool $is_bot, - public int $last_activity_time, + public bool $isBot, + public int $lastActivityTime, public ?string $description, - public ?string $avatar_url, - public ?string $full_avatar_url, + public ?string $avatarUrl, + public ?string $fullAvatarUrl, + #[ArrayOf(BotCommand::class)] public ?array $commands, ) { } - - /** - * @inheritdoc - */ - public static function fromArray(array $data): static - { - return new static( - (int)$data['user_id'], - (string)$data['first_name'], - $data['last_name'] ? (string)$data['last_name'] : null, - $data['username'] ? (string)$data['username'] : null, - (bool)$data['is_bot'], - (int)$data['last_activity_time'], - $data['description'] ? (string)$data['description'] : null, - $data['avatar_url'] ? (string)$data['avatar_url'] : null, - $data['full_avatar_url'] ? (string)$data['full_avatar_url'] : null, - isset($data['commands']) && is_array($data['commands']) ? $data['commands'] : null, - ); - } } diff --git a/src/Models/Result.php b/src/Models/Result.php index 468f24e..8c578a1 100644 --- a/src/Models/Result.php +++ b/src/Models/Result.php @@ -18,15 +18,4 @@ final readonly class Result extends AbstractModel public ?string $message, ) { } - - /** - * @inheritDoc - */ - public static function fromArray(array $data): static - { - return new static( - (bool)$data['success'], - $data['message'] ?? null, - ); - } } diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index ee69de9..4dce91e 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Models; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; use BushlanovDev\MaxMessengerBot\Enums\UpdateType; /** @@ -14,35 +15,15 @@ final readonly class Subscription extends AbstractModel /** * @param string $url URL webhook. * @param int $time Unix-time of creating a subscription. - * @param UpdateType[]|null $update_types List of update types. + * @param UpdateType[]|null $updateTypes List of update types. * @param string|null $version Version of the API. */ public function __construct( public string $url, public int $time, - public ?array $update_types, + #[ArrayOf(UpdateType::class)] + public ?array $updateTypes, public ?string $version, ) { } - - /** - * @inheritdoc - */ - public static function fromArray(array $data): static - { - $updateTypes = null; - if (isset($data['update_types']) && is_array($data['update_types'])) { - $updateTypes = array_map( - fn(string $typeValue): UpdateType => UpdateType::from($typeValue), - $data['update_types'], - ); - } - - return new static( - (string)$data['url'], - (int)$data['time'], - $updateTypes, - $data['version'] ?? null, - ); - } } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index ae05cfc..28b202e 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -124,7 +124,7 @@ final class ApiTest extends TestCase $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertInstanceOf(Subscription::class, $result[0]); - $this->assertSame(UpdateType::MessageCreated, $result[0]->update_types[0]); + $this->assertSame(UpdateType::MessageCreated, $result[0]->updateTypes[0]); } #[Test] diff --git a/tests/ClientTest.php b/tests/ClientTest.php index e6b8218..9f90ec7 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -7,8 +7,10 @@ namespace BushlanovDev\MaxMessengerBot\Tests; use BushlanovDev\MaxMessengerBot\Client; use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException; use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException; +use BushlanovDev\MaxMessengerBot\Exceptions\MethodNotAllowedException; use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException; use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException; +use BushlanovDev\MaxMessengerBot\Exceptions\RateLimitExceededException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException; use InvalidArgumentException; @@ -224,10 +226,17 @@ final class ClientTest extends TestCase public static function apiErrorProvider(): array { return [ + '400 Bad Request' => [400, ClientApiException::class, 'bad.request', 'Invalid parameters'], '401 Unauthorized' => [401, UnauthorizedException::class, 'verify.token', 'Invalid access_token'], '403 Forbidden' => [403, ForbiddenException::class, 'access.denied', 'You don\'t have permissions'], '404 Not Found' => [404, NotFoundException::class, 'not.found', 'Resource not found'], - '400 Bad Request' => [400, ClientApiException::class, 'bad.request', 'Invalid parameters'], + '405 Method Not Allowed' => [ + 405, + MethodNotAllowedException::class, + 'method.not.allowed', + 'Method not allowed', + ], + '429 Rate Limit' => [429, RateLimitExceededException::class, 'rate.limit', 'Rate limit exceeded'], '503 Service Unavailable' => [ 503, ClientApiException::class, diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index 94682ac..d4f3f78 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Tests; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; use BushlanovDev\MaxMessengerBot\Enums\UpdateType; use BushlanovDev\MaxMessengerBot\ModelFactory; use BushlanovDev\MaxMessengerBot\Models\BotCommand; @@ -20,6 +21,7 @@ use PHPUnit\Framework\TestCase; #[UsesClass(BotCommand::class)] #[UsesClass(Result::class)] #[UsesClass(Subscription::class)] +#[UsesClass(ArrayOf::class)] final class ModelFactoryTest extends TestCase { private ModelFactory $factory; @@ -79,7 +81,7 @@ final class ModelFactoryTest extends TestCase $botInfo = $this->factory->createBotInfo($rawData); $this->assertInstanceOf(BotInfo::class, $botInfo); - $this->assertSame(12345, $botInfo->user_id); + $this->assertSame(12345, $botInfo->userId); $this->assertIsArray($botInfo->commands); $this->assertCount(2, $botInfo->commands); @@ -130,6 +132,6 @@ final class ModelFactoryTest extends TestCase $this->assertIsArray($subscriptions); $this->assertCount(1, $subscriptions); $this->assertInstanceOf(Subscription::class, $subscriptions[0]); - $this->assertSame(UpdateType::MessageCreated, $subscriptions[0]->update_types[0]); + $this->assertSame(UpdateType::MessageCreated, $subscriptions[0]->updateTypes[0]); } } diff --git a/tests/Models/AbstractModelMappingTest.php b/tests/Models/AbstractModelMappingTest.php new file mode 100644 index 0000000..4684944 --- /dev/null +++ b/tests/Models/AbstractModelMappingTest.php @@ -0,0 +1,100 @@ + 'Test With Enums', + 'update_types' => ['message_created', 'bot_started'], + ]); + + $this->assertInstanceOf(DummyModelForMapping::class, $result); + $this->assertIsArray($result->updateTypes); + $this->assertCount(2, $result->updateTypes); + $this->assertInstanceOf(UpdateType::class, $result->updateTypes[0]); + $this->assertSame(UpdateType::MessageCreated, $result->updateTypes[0]); + $this->assertSame(UpdateType::BotStarted, $result->updateTypes[1]); + } + + #[Test] + public function itCorrectlyCastsArrayOfModels(): void + { + $result = DummyModelForMapping::fromArray([ + 'name' => 'Test With Models', + 'child_models' => [ + ['value' => 'child 1'], + ['value' => 'child 2'], + ], + ]); + + $this->assertIsArray($result->childModels); + $this->assertCount(2, $result->childModels); + $this->assertInstanceOf(DummyChildModel::class, $result->childModels[0]); + $this->assertSame('child 1', $result->childModels[0]->value); + } + + #[Test] + public function itReturnsRawArrayWhenAttributeIsMissing(): void + { + $result = DummyModelForMapping::fromArray(['untyped_array' => ['a', 'b', 'c']]); + + $this->assertIsArray($result->untypedArray); + $this->assertSame(['a', 'b', 'c'], $result->untypedArray); + } + + #[Test] + public function itHandlesEmptyArraysCorrectly(): void + { + $result = DummyModelForMapping::fromArray([ + 'name' => 'Test with Empty', + 'update_types' => [], + ]); + + $this->assertIsArray($result->updateTypes); + $this->assertEmpty($result->updateTypes); + } + + #[Test] + public function itHandlesNullForNullableArray(): void + { + $result = DummyModelForMapping::fromArray(['child_models' => null]); + + $this->assertNull($result->childModels); + } +} + +final readonly class DummyModelForMapping extends AbstractModel +{ + public function __construct( + public ?string $name, + #[ArrayOf(UpdateType::class)] + public ?array $updateTypes, + #[ArrayOf(DummyChildModel::class)] + public ?array $childModels, + public ?array $untypedArray, + ) + { + } +} + +final readonly class DummyChildModel extends AbstractModel +{ + public function __construct(public string $value) + { + } +} diff --git a/tests/Models/BotCommandTest.php b/tests/Models/BotCommandTest.php index a492395..ef9e46e 100644 --- a/tests/Models/BotCommandTest.php +++ b/tests/Models/BotCommandTest.php @@ -38,12 +38,10 @@ final class BotCommandTest extends TestCase #[Test] public function canBeCreatedFromArrayWithOptionalDataNull(): void { - $data = [ + $command = BotCommand::fromArray([ 'name' => 'help', 'description' => null, - ]; - - $command = BotCommand::fromArray($data); + ]); $this->assertInstanceOf(BotCommand::class, $command); $this->assertSame('help', $command->name); diff --git a/tests/Models/BotInfoTest.php b/tests/Models/BotInfoTest.php index aa97e6f..9e98fa7 100644 --- a/tests/Models/BotInfoTest.php +++ b/tests/Models/BotInfoTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Tests\Models; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; use BushlanovDev\MaxMessengerBot\Models\BotCommand; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use PHPUnit\Framework\Attributes\CoversClass; @@ -13,12 +14,13 @@ use PHPUnit\Framework\TestCase; #[CoversClass(BotInfo::class)] #[UsesClass(BotCommand::class)] +#[UsesClass(ArrayOf::class)] final class BotInfoTest extends TestCase { #[Test] public function canBeCreatedFromArray(): void { - $data = [ + $botInfo = BotInfo::fromArray([ 'user_id' => 12345, 'first_name' => 'Test', 'last_name' => 'Bot', @@ -29,17 +31,15 @@ final class BotInfoTest extends TestCase 'avatar_url' => 'http://example.com/avatar.jpg', 'full_avatar_url' => 'http://example.com/full_avatar.jpg', 'commands' => [ - new BotCommand('start', 'Start the bot'), - new BotCommand('help', 'Show help'), + ['name' => 'start', 'description' => 'Start the bot'], + ['name' => 'help', 'description' => 'Show help'], ], - ]; - - $botInfo = BotInfo::fromArray($data); + ]); $this->assertInstanceOf(BotInfo::class, $botInfo); - $this->assertSame(12345, $botInfo->user_id); - $this->assertSame('Test', $botInfo->first_name); - $this->assertTrue($botInfo->is_bot); + $this->assertSame(12345, $botInfo->userId); + $this->assertSame('Test', $botInfo->firstName); + $this->assertTrue($botInfo->isBot); $this->assertCount(2, $botInfo->commands); $this->assertInstanceOf(BotCommand::class, $botInfo->commands[0]); $this->assertSame('start', $botInfo->commands[0]->name); diff --git a/tests/Models/SubscriptionTest.php b/tests/Models/SubscriptionTest.php index bb82f4a..38f2e5e 100644 --- a/tests/Models/SubscriptionTest.php +++ b/tests/Models/SubscriptionTest.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Tests\Models; +use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; use BushlanovDev\MaxMessengerBot\Enums\UpdateType; use BushlanovDev\MaxMessengerBot\Models\Subscription; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(Subscription::class)] +#[UsesClass(ArrayOf::class)] final class SubscriptionTest extends TestCase { #[Test] @@ -28,7 +31,7 @@ final class SubscriptionTest extends TestCase $this->assertInstanceOf(Subscription::class, $subscription); $this->assertSame($data['url'], $subscription->url); $this->assertSame($data['time'], $subscription->time); - $this->assertSame([UpdateType::MessageCreated, UpdateType::BotStarted], $subscription->update_types); + $this->assertSame([UpdateType::MessageCreated, UpdateType::BotStarted], $subscription->updateTypes); $this->assertSame($data['version'], $subscription->version); $array = $subscription->toArray(); @@ -52,7 +55,7 @@ final class SubscriptionTest extends TestCase $this->assertInstanceOf(Subscription::class, $subscription); $this->assertSame($data['url'], $subscription->url); $this->assertSame($data['time'], $subscription->time); - $this->assertNull($subscription->update_types); + $this->assertNull($subscription->updateTypes); $this->assertNull($subscription->version); $array = $subscription->toArray();