diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b90553..86439ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.0', '8.1', '8.2'] + php: ['8.1', '8.2', '8.3'] composer: ['--prefer-lowest', ''] steps: - name: Setup PHP diff --git a/README.md b/README.md index bffc6dd..575670f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ facade │ └─ new - Obtain new session token ├─ category/ - Category endpoint │ ├─ get - Get product listing category -│ └─ getAttributes - Get system-defined attributes based on category ID +│ ├─ getAttributes - Get system-defined attributes based on category ID +│ └─ getLevelAttribute - Get next-level attribute based on category, attribute and value ID (e.g. car_model values) └─ product/ - Product endpoint └─ getGroup - Get product group ``` diff --git a/composer.json b/composer.json index 587c72c..93f2303 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8", + "php": "^8.1", "ext-json": "*", "ext-mbstring": "*", "symfony/http-client": "^5.4 || ^6" diff --git a/src/Client.php b/src/Client.php index 3e897dc..c9048a7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,7 +4,7 @@ namespace Kyto\Alibaba; -use Kyto\Alibaba\Exception\AlibabaException; +use Kyto\Alibaba\Exception\ResponseException; use Kyto\Alibaba\Util\Clock; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -96,7 +96,7 @@ private function throwOnError(array $data): void $errorResponse = $data['error_response'] ?? null; if ($errorResponse !== null) { - throw new AlibabaException( + throw new ResponseException( $errorResponse['msg'], (int) $errorResponse['code'], $errorResponse['sub_msg'], diff --git a/src/Endpoint/CategoryEndpoint.php b/src/Endpoint/CategoryEndpoint.php index 37216ad..09b33d2 100644 --- a/src/Endpoint/CategoryEndpoint.php +++ b/src/Endpoint/CategoryEndpoint.php @@ -5,9 +5,12 @@ namespace Kyto\Alibaba\Endpoint; use Kyto\Alibaba\Client; +use Kyto\Alibaba\Exception\ResponseException; +use Kyto\Alibaba\Exception\UnexpectedResultException; use Kyto\Alibaba\Factory\CategoryFactory; use Kyto\Alibaba\Model\Category; use Kyto\Alibaba\Model\CategoryAttribute; +use Kyto\Alibaba\Model\CategoryLevelAttribute; class CategoryEndpoint { @@ -33,6 +36,7 @@ public function __construct( * @link https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=2&docId=50064 * * @param ?string $id Provide `null` to fetch root categories + * @throws ResponseException */ public function get(?string $id = null): Category { @@ -51,6 +55,7 @@ public function get(?string $id = null): Category * @link https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=2&docId=25348 * * @return CategoryAttribute[] + * @throws ResponseException */ public function getAttributes(string $categoryId): array { @@ -68,4 +73,40 @@ public function getAttributes(string $categoryId): array return $result; } + + /** + * Get next-level attribute based on category, attribute and optionally level attribute value ID. + * @link https://developer.alibaba.com/en/doc.htm?spm=a2728.12183079.k2mwm9fd.1.4b3630901WuQWY#?docType=2&docId=48659 + * + * @param ?string $valueId provide null to fetch root level + * @throws ResponseException|UnexpectedResultException + */ + public function getLevelAttribute( + string $categoryId, + string $attributeId, + ?string $valueId = null + ): CategoryLevelAttribute { + $attributeValueRequest = [ + 'cat_id' => $categoryId, + 'attr_id' => $attributeId, + 'value_id' => $valueId ?? '0' + ]; + + $data = $this->client->request([ + 'method' => 'alibaba.icbu.category.level.attr.get', + 'attribute_value_request' => json_encode($attributeValueRequest) + ]); + + $errorMessage = sprintf( + 'Result list for category id: "%s", attribute id: "%s", value id: "%s" is empty.', + $categoryId, + $attributeId, + $valueId + ); + + $attribute = $data['alibaba_icbu_category_level_attr_get_response']['result_list'] + ?? throw new UnexpectedResultException($errorMessage); + + return $this->categoryFactory->createLevelAttribute($attribute); + } } diff --git a/src/Endpoint/ProductEndpoint.php b/src/Endpoint/ProductEndpoint.php index 19b2684..e654c32 100644 --- a/src/Endpoint/ProductEndpoint.php +++ b/src/Endpoint/ProductEndpoint.php @@ -5,6 +5,7 @@ namespace Kyto\Alibaba\Endpoint; use Kyto\Alibaba\Client; +use Kyto\Alibaba\Exception\ResponseException; use Kyto\Alibaba\Factory\ProductFactory; use Kyto\Alibaba\Model\ProductGroup; use Kyto\Alibaba\Model\Token; @@ -33,6 +34,7 @@ public function __construct( * @link https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=2&docId=25299 * * @param ?string $id Provide `null` to fetch root groups + * @throws ResponseException */ public function getGroup(Token $token, ?string $id = null): ProductGroup { diff --git a/src/Endpoint/TokenEndpoint.php b/src/Endpoint/TokenEndpoint.php index ec713bd..04be7e1 100644 --- a/src/Endpoint/TokenEndpoint.php +++ b/src/Endpoint/TokenEndpoint.php @@ -5,6 +5,7 @@ namespace Kyto\Alibaba\Endpoint; use Kyto\Alibaba\Client; +use Kyto\Alibaba\Exception\ResponseException; use Kyto\Alibaba\Factory\TokenFactory; use Kyto\Alibaba\Model\Token; @@ -31,6 +32,8 @@ public function __construct( * To obtain authorization code see corresponding facade method. * @link https://open.taobao.com/api.htm?spm=a219a.7386653.0.0.41449b714zR8KI&docId=25388&docType=2&source=search * @see \Kyto\Alibaba\Facade::getAuthorizationUrl + * + * @throws ResponseException|\JsonException */ public function new(string $authorizationCode): Token { diff --git a/src/Enum/InputType.php b/src/Enum/InputType.php new file mode 100644 index 0000000..5c3ca3e --- /dev/null +++ b/src/Enum/InputType.php @@ -0,0 +1,12 @@ +subCode, $this->subMessage); - parent::__construct($message, $code, $previous); - } - - public function getSubMessage(): string - { - return $this->subMessage; - } - - public function getSubCode(): string - { - return $this->subCode; - } } diff --git a/src/Exception/ResponseException.php b/src/Exception/ResponseException.php new file mode 100644 index 0000000..b1351c7 --- /dev/null +++ b/src/Exception/ResponseException.php @@ -0,0 +1,32 @@ +subCode, $this->subMessage); + parent::__construct($message, $code, $previous); + } + + public function getSubMessage(): string + { + return $this->subMessage; + } + + public function getSubCode(): string + { + return $this->subCode; + } +} diff --git a/src/Exception/UnexpectedResultException.php b/src/Exception/UnexpectedResultException.php new file mode 100644 index 0000000..4cfb418 --- /dev/null +++ b/src/Exception/UnexpectedResultException.php @@ -0,0 +1,19 @@ + $data */ public function createCategory(array $data): Category { @@ -34,7 +39,7 @@ public function createCategory(array $data): Category } /** - * @param mixed[] $data + * @param array $data */ public function createAttribute(array $data): CategoryAttribute { @@ -44,9 +49,9 @@ public function createAttribute(array $data): CategoryAttribute $model->name = (string) $data['en_name']; $model->isRequired = (bool) $data['required']; - $model->inputType = (string) $data['input_type']; - $model->showType = (string) $data['show_type']; - $model->valueType = (string) $data['value_type']; + $model->inputType = InputType::from($data['input_type']); + $model->showType = ShowType::from($data['show_type']); + $model->valueType = ValueType::from($data['value_type']); $model->isSku = (bool) $data['sku_attribute']; $model->hasCustomizeImage = (bool) $data['customize_image']; @@ -64,7 +69,7 @@ public function createAttribute(array $data): CategoryAttribute } /** - * @param mixed[] $data + * @param array $data */ public function createAttributeValue(array $data): CategoryAttributeValue { @@ -77,4 +82,36 @@ public function createAttributeValue(array $data): CategoryAttributeValue return $model; } + + /** + * @param array $data + */ + public function createLevelAttribute(array $data): CategoryLevelAttribute + { + $model = new CategoryLevelAttribute(); + + $model->id = (string) $data['property_id']; + $model->name = (string) $data['property_en_name']; + + $model->values = []; + $decodedValues = json_decode($data['values'], true); + foreach ($decodedValues as $value) { + $model->values[] = $this->createLevelAttributeValue($value); + } + + return $model; + } + + /** + * @param array $data + */ + public function createLevelAttributeValue(array $data): CategoryLevelAttributeValue + { + $model = new CategoryLevelAttributeValue(); + $model->name = (string) $data['name']; + $model->id = (string) $data['id']; + $model->isLeaf = isset($data['leaf']); + + return $model; + } } diff --git a/src/Model/CategoryAttribute.php b/src/Model/CategoryAttribute.php index d0153ec..3b79432 100644 --- a/src/Model/CategoryAttribute.php +++ b/src/Model/CategoryAttribute.php @@ -4,16 +4,19 @@ namespace Kyto\Alibaba\Model; +use Kyto\Alibaba\Enum\InputType; +use Kyto\Alibaba\Enum\ShowType; +use Kyto\Alibaba\Enum\ValueType; + class CategoryAttribute { public string $id; public string $name; public bool $isRequired; - // TODO: change to enums once all values would be known - public string $inputType; // Known values: single_select, multi_select, input - public string $showType; // Known values: list_box (single_select), check_box (multi_select), input (input) - public string $valueType; // Known values: string, number + public InputType $inputType; + public ShowType $showType; + public ValueType $valueType; public bool $isSku; public bool $hasCustomizeImage; diff --git a/src/Model/CategoryLevelAttribute.php b/src/Model/CategoryLevelAttribute.php new file mode 100644 index 0000000..b497f16 --- /dev/null +++ b/src/Model/CategoryLevelAttribute.php @@ -0,0 +1,14 @@ +willReturn($response); if (!$isSuccess) { - $this->expectException(AlibabaException::class); + $this->expectException(ResponseException::class); } $actual = $this->client->request(['hello' => 'world', 'test' => 'data']); diff --git a/tests/Endpoint/CategoryEndpointTest.php b/tests/Endpoint/CategoryEndpointTest.php index eab7995..2cbf359 100644 --- a/tests/Endpoint/CategoryEndpointTest.php +++ b/tests/Endpoint/CategoryEndpointTest.php @@ -9,6 +9,7 @@ use Kyto\Alibaba\Factory\CategoryFactory; use Kyto\Alibaba\Model\Category; use Kyto\Alibaba\Model\CategoryAttribute; +use Kyto\Alibaba\Model\CategoryLevelAttribute; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -98,4 +99,30 @@ public function testGetAttributes(): void $actual = $this->categoryEndpoint->getAttributes($id); self::assertSame($result, $actual); } + + public function testGetLevelAttributes(): void + { + $attributeValueRequestBody = '{"cat_id":"1","attr_id":"1","value_id":"0"}'; + $levelAttribute = ['LevelAttribute']; + $data = ['alibaba_icbu_category_level_attr_get_response' => ['result_list' => $levelAttribute]]; + + $this->client + ->expects(self::once()) + ->method('request') + ->with([ + 'method' => 'alibaba.icbu.category.level.attr.get', + 'attribute_value_request' => $attributeValueRequestBody, + ]) + ->willReturn($data); + + $result = new CategoryLevelAttribute(); + + $this->categoryFactory + ->expects(self::once()) + ->method('createLevelAttribute') + ->willReturn($result); + + $actual = $this->categoryEndpoint->getLevelAttribute('1', '1', null); + self::assertSame($result, $actual); + } } diff --git a/tests/Exception/AlibabaExceptionTest.php b/tests/Exception/ResponseExceptionTest.php similarity index 79% rename from tests/Exception/AlibabaExceptionTest.php rename to tests/Exception/ResponseExceptionTest.php index 3af1f40..21f98ec 100644 --- a/tests/Exception/AlibabaExceptionTest.php +++ b/tests/Exception/ResponseExceptionTest.php @@ -4,10 +4,10 @@ namespace Kyto\Alibaba\Tests\Exception; -use Kyto\Alibaba\Exception\AlibabaException; +use Kyto\Alibaba\Exception\ResponseException; use PHPUnit\Framework\TestCase; -class AlibabaExceptionTest extends TestCase +class ResponseExceptionTest extends TestCase { public function testConstruct(): void { @@ -17,7 +17,7 @@ public function testConstruct(): void $subCode = 'sub.code'; $previous = new \RuntimeException('Previous'); - $exception = new AlibabaException($message, $code, $subMessage, $subCode, $previous); + $exception = new ResponseException($message, $code, $subMessage, $subCode, $previous); self::assertSame('Message. Sub-code: "sub.code". Sub-message: "Sub-message".', $exception->getMessage()); self::assertSame($code, $exception->getCode()); diff --git a/tests/Exception/UnexpectedResultExceptionTest.php b/tests/Exception/UnexpectedResultExceptionTest.php new file mode 100644 index 0000000..e3d39d8 --- /dev/null +++ b/tests/Exception/UnexpectedResultExceptionTest.php @@ -0,0 +1,24 @@ +getMessage()); + self::assertSame($code, $exception->getCode()); + self::assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Factory/CategoryFactoryTest.php b/tests/Factory/CategoryFactoryTest.php index fd63659..8e8d04f 100644 --- a/tests/Factory/CategoryFactoryTest.php +++ b/tests/Factory/CategoryFactoryTest.php @@ -4,10 +4,15 @@ namespace Kyto\Alibaba\Tests\Factory; +use Kyto\Alibaba\Enum\InputType; +use Kyto\Alibaba\Enum\ShowType; +use Kyto\Alibaba\Enum\ValueType; use Kyto\Alibaba\Factory\CategoryFactory; use Kyto\Alibaba\Model\Category; use Kyto\Alibaba\Model\CategoryAttribute; use Kyto\Alibaba\Model\CategoryAttributeValue; +use Kyto\Alibaba\Model\CategoryLevelAttribute; +use Kyto\Alibaba\Model\CategoryLevelAttributeValue; use PHPUnit\Framework\TestCase; class CategoryFactoryTest extends TestCase @@ -145,9 +150,9 @@ public function createAttributeDataProvider(): array $model->id = '1'; $model->name = 'Example'; $model->isRequired = true; - $model->inputType = 'single_select'; - $model->showType = 'list_box'; - $model->valueType = 'string'; + $model->inputType = InputType::SINGLE_SELECT; + $model->showType = ShowType::LIST_BOX; + $model->valueType = ValueType::STRING; $model->isSku = false; $model->hasCustomizeImage = false; $model->hasCustomizeValue = false; @@ -188,9 +193,9 @@ public function createAttributeDataProvider(): array $model->id = '1'; $model->name = 'Example'; $model->isRequired = true; - $model->inputType = 'input'; - $model->showType = 'input'; - $model->valueType = 'number'; + $model->inputType = InputType::INPUT; + $model->showType = ShowType::INPUT; + $model->valueType = ValueType::NUMBER; $model->isSku = false; $model->hasCustomizeImage = false; $model->hasCustomizeValue = false; @@ -253,4 +258,91 @@ public function createAttributeValueDataProvider(): array return $cases; } + + /** + * @dataProvider createLevelAttributeDataProvider + * @param mixed[] $data + */ + public function testCreateLevelAttribute(array $data, CategoryLevelAttribute $expected): void + { + $actual = $this->categoryFactory->createLevelAttribute($data); + self::assertEquals($expected, $actual); + } + + public function createLevelAttributeDataProvider(): \Generator + { + $data = [ + 'property_id' => '123', + 'property_en_name' => 'someName', + 'values' => '{}' + ]; + + $expected = new CategoryLevelAttribute(); + $expected->id = '123'; + $expected->name = 'someName'; + $expected->values = []; + + yield 'no values' => [$data, $expected]; + + $data = [ + 'property_id' => '123', + 'property_en_name' => 'someName', + 'values' => '[{"id":"1","name":"valueNoLeaf"},{"id":2,"name":"valueIsLeaf","leaf":true}]' + ]; + + $levelValueNoLeaf = new CategoryLevelAttributeValue(); + $levelValueNoLeaf->id = '1'; + $levelValueNoLeaf->name = 'valueNoLeaf'; + $levelValueNoLeaf->isLeaf = false; + + $levelValueIsLeaf = new CategoryLevelAttributeValue(); + $levelValueIsLeaf->id = '2'; + $levelValueIsLeaf->name = 'valueIsLeaf'; + $levelValueIsLeaf->isLeaf = true; + + $expected = new CategoryLevelAttribute(); + $expected->id = '123'; + $expected->name = 'someName'; + $expected->values = [$levelValueNoLeaf, $levelValueIsLeaf]; + + yield 'with values' => [$data, $expected]; + } + + /** + * @dataProvider createLevelAttributeValueDataProvider + * @param mixed[] $data + */ + public function testCreateLevelAttributeValue(array $data, CategoryLevelAttributeValue $expected): void + { + $actual = $this->categoryFactory->createLevelAttributeValue($data); + self::assertEquals($expected, $actual); + } + + public function createLevelAttributeValueDataProvider(): \Generator + { + $data = [ + "id" => "1", + "name" => "valueNoLeaf" + ]; + + $expected = new CategoryLevelAttributeValue(); + $expected->name = 'valueNoLeaf'; + $expected->id = '1'; + $expected->isLeaf = false; + + yield 'no leaf' => [$data, $expected]; + + $data = [ + "id" => "1", + "name" => "valueIsLeaf", + "leaf" => true + ]; + + $expected = new CategoryLevelAttributeValue(); + $expected->name = 'valueIsLeaf'; + $expected->id = '1'; + $expected->isLeaf = true; + + yield 'is leaf' => [$data, $expected]; + } }