Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to OpenAPI #6

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Alibaba SDK
[![build](https://github.com/kyto-gmbh/alibaba-sdk-php/actions/workflows/build.yml/badge.svg)](https://github.com/kyto-gmbh/alibaba-sdk-php/actions/workflows/build.yml)

Alibaba SDK for PHP. This package provides a structured interface to communicate with [Alibaba Open Platform](https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=1&docId=118496).
Alibaba SDK for PHP. This package provides a structured interface to communicate with [Alibaba Open Platform](https://openapi.alibaba.com/doc/doc.htm?spm=a2o9m.11223882.0.0.1566722cTOuz7W#/?docId=19).

> Note, package is in development therefore public interface could be changed in future releases.

Expand All @@ -23,24 +23,25 @@ require __DIR__ . '/vendor/autoload.php';

use Kyto\Alibaba\Facade;

$alibaba = Facade::create('api-key', 'api-secret');
$alibaba = Facade::create('app-key', 'app-secret');
$alibaba->category->get('0'); // @return Kyto\Alibaba\Model\Category
```

> For the API usage workflow see [Alibaba API Workflow](docs/workflow.md).

## Endpoints
Currently implemented endpoints:

```text
facade
├─ getAuthorizationUrl - Get user authorization url
├─ token/ - Token endpoint
│ └─ new - Obtain new session token
│ ├─ new - Obtain new access token
│ └─ refresh - Refresh access token
├─ category/ - Category endpoint
│ ├─ get - Get product listing category
│ ├─ 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)
│ └─ get - Get product listing category
└─ product/ - Product endpoint
└─ getGroup - Get product group
└─ getSchema - Get rules and fields for new product release
```

## Credits
Expand Down
1 change: 0 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"require": {
"php": "^8.1",
"ext-json": "*",
"ext-mbstring": "*",
"symfony/http-client": "^5.4 || ^6"
},
"require-dev": {
Expand Down
24 changes: 24 additions & 0 deletions docs/workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Alibaba API Workflow

To be able to interact with Alibaba Open Platform, you need to follow the steps below:
1. [Create the application](https://openapi.alibaba.com/app/index.htm?spm=a2o9m.11193487.0.0.35b913a0AlGs39#/app/create?_k=lrqyds)
on the [Alibaba Open Platform](https://openapi.alibaba.com/) specifying all the required permissions (app category).
This may require you to have an Alibaba account and company verification process to be complete.
2. Obtain the `app-key` and `app-secret` from the created application.
This is available in the application settings after Alibaba approves the application.
3. Authorize the application to access the seller data.
Use the [`Facade::getAuthorizationUrl`](../src/Facade.php#L53) method to get the URL to authorize the application.
This will require to log in as a seller and grant the permissions to the application.
As a result, you will be redirected to callback URL with the authorization code.
4. Obtain the `access-token` from the authorization response.
Use the [`Facade::token->new`](../src/Endpoint/TokenEndpoint.php#L39) method to get the `access-token` (and `refresh-token`).
5. Use the `access-token` to interact with the Alibaba Open Platform.

> Every request to the Alibaba Open Platform requires the `access-token` and valid `signature`.
> Signature is a hash of the `app-key`, `app-secret`, `timestamp` and `request payload`.
> Signature is generated by the SDK automatically.

See the following schema for the Alibaba API workflow:
[![Alibab API workflow](./workflow.svg)](./workflow.svg)

For more details see the Alibaba Open Platform [quick start guide](https://openapi.alibaba.com/doc/doc.htm?spm=a2o9m.11223882.0.0.1566722cTOuz7W#/?docId=51).
46 changes: 46 additions & 0 deletions docs/workflow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 36 additions & 25 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
class Client
{
public function __construct(
private string $apiKey,
private string $key,
private string $secret,
private HttpClientInterface $httpClient,
private Clock $clock,
Expand All @@ -27,80 +27,91 @@ public function __construct(
* @param mixed[] $payload JSON serializable data
* @return mixed[] Decoded JSON as associative array
*/
public function request(array $payload): array
public function request(string $endpoint, array $payload): array
{
$endpoint = str_starts_with($endpoint, '/') ? $endpoint : '/' . $endpoint;
$headers = ['User-Agent' => 'Kyto Alibaba Client'];
$body = $this->getBody($payload);
$body = $this->getBody($endpoint, $payload);

$response = $this->httpClient->request('POST', 'https://api.taobao.com/router/rest', [
$response = $this->httpClient->request('POST', 'https://openapi-api.alibaba.com/rest' . $endpoint, [
'headers' => $headers,
'body' => $body,
]);

$data = $response->toArray();
$this->throwOnError($data);
$this->throwOnError($endpoint, $data);
return $data;
}

/**
* @param mixed[] $payload
* @return mixed[]
*/
private function getBody(array $payload): array
private function getBody(string $endpoint, array $payload): array
{
$payload = array_merge([
'app_key' => $this->apiKey,
'app_key' => $this->key,
'timestamp' => $this->getTimestamp(),
'format' => 'json',
'v' => '2.0',
], $payload);

return $this->getSignedBody($payload);
return $this->getSignedBody($endpoint, $payload);
}

/**
* All API calls must include a valid signature. Requests with invalid signatures will be rejected.
* @link https://openapi.alibaba.com/doc/doc.htm?docId=19#/?docId=60
* @link https://openapi.alibaba.com/doc/doc.htm?docId=19#/?docId=58
*
* @param mixed[] $body
* @return mixed[] Same body plus "sign_method" and "sign" values
*/
private function getSignedBody(array $body): array
private function getSignedBody(string $endpoint, array $body): array
{
unset($body['sign']);
$body['sign_method'] = 'md5';

$body['sign_method'] = 'sha256';
ksort($body);
$hashString = '';

$hashString = $endpoint;
foreach ($body as $key => $value) {
$hashString .= $key . $value;
}
$hashString = $this->secret . $hashString . $this->secret;

$body['sign'] = mb_strtoupper(md5($hashString));
$body['sign'] = strtoupper(hash_hmac('sha256', $hashString, $this->secret));
return $body;
}

/**
* Required by Alibaba API specs to be in GMT+8 timezone
* Required by Alibaba to be in microseconds and (seems like) in UTC timezone.
*/
private function getTimestamp(): string
{
return $this->clock->now('GMT+8')->format('Y-m-d H:i:s');
return $this->clock->now('UTC')->format('Uv');
}

/**
* @param mixed[] $data
*/
private function throwOnError(array $data): void
private function throwOnError(string $endpoint, array $data): void
{
$errorResponse = $data['error_response'] ?? null;
if (isset($data['type'], $data['code'])) {
throw new ResponseException(
$endpoint,
$data['type'],
$data['message'],
$data['code'],
$data['request_id'],
$data['_trace_id_'],
);
}

if ($errorResponse !== null) {
if (isset($data['result']['success']) && (bool) $data['result']['success'] !== true) {
throw new ResponseException(
$errorResponse['msg'],
(int) $errorResponse['code'],
$errorResponse['sub_msg'],
$errorResponse['sub_code'],
$endpoint,
'SYSTEM', // it's empty for this type of error, therefore we use "SYSTEM"
$data['result']['message_info'],
$data['result']['msg_code'],
$data['request_id'],
$data['_trace_id_'],
);
}
}
Expand Down
74 changes: 6 additions & 68 deletions src/Endpoint/CategoryEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@

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;
use Kyto\Alibaba\Model\Token;

class CategoryEndpoint
{
Expand All @@ -33,80 +31,20 @@ public function __construct(

/**
* Get product listing category
* @link https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=2&docId=50064
* @link https://openapi.alibaba.com/doc/api.htm?spm=a2o9m.11223882.0.0.1566722cTOuz7W#/api?cid=1&path=/icbu/product/category/get&methodType=GET/POST
*
* @param ?string $id Provide `null` to fetch root categories
* @throws ResponseException
*/
public function get(?string $id = null): Category
public function get(Token $token, ?string $id = null): Category
{
$id = $id ?? '0'; // '0' to fetch root categories

$data = $this->client->request([
'method' => 'alibaba.icbu.category.get.new',
'cat_id' => $id
$data = $this->client->request('/icbu/product/category/get', [
'access_token' => $token->token,
'cat_id' => $id,
]);

return $this->categoryFactory->createCategory($data);
}

/**
* Get system-defined attributes based on category ID
* @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
{
$data = $this->client->request([
'method' => 'alibaba.icbu.category.attribute.get',
'cat_id' => $categoryId,
]);

$result = [];

$attributes = $data['alibaba_icbu_category_attribute_get_response']['attributes']['attribute'];
foreach ($attributes as $attribute) {
$result[] = $this->categoryFactory->createAttribute($attribute);
}

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);
}
}
30 changes: 15 additions & 15 deletions src/Endpoint/ProductEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
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\Category;
use Kyto\Alibaba\Model\Token;

class ProductEndpoint
Expand All @@ -25,27 +24,28 @@ public static function create(Client $client): self
*/
public function __construct(
private Client $client,
private ProductFactory $productFactory,
private ProductFactory $productFactory, // @phpstan-ignore-line
) {
}

/**
* Get product group information.
* @link https://developer.alibaba.com/en/doc.htm?spm=a219a.7629140.0.0.188675fe5JPvEa#?docType=2&docId=25299
* Obtain the page rules and fill-in fields for new product release.
* @link https://openapi.alibaba.com/doc/api.htm?spm=a2o9m.11193531.0.0.2fabf453CIh7hC#/api?cid=1&path=/alibaba/icbu/product/schema/get&methodType=GET/POST
*
* @param ?string $id Provide `null` to fetch root groups
* @throws ResponseException
* @param string $language Allowed values are not documented in the API docs.
* Seems like uses the same locale codes as on Alibaba website:
* en_US, es_ES, fr_FR, it_IT, de_DE, pt_PT, ru_RU, ja_JP,
* ar_SA, ko_KR, tr_TR, vi_VN, th_TH, id_ID, he_IL, hi_IN, zh_CN
* @return array<mixed>
*/
public function getGroup(Token $token, ?string $id = null): ProductGroup
public function getSchema(Token $token, Category $category, string $language = 'en_US'): array
{
$id = $id ?? '-1'; // '-1' to fetch root groups

$data = $this->client->request([
'method' => 'alibaba.icbu.product.group.get',
'session' => $token->token,
'group_id' => $id
$data = $this->client->request('/alibaba/icbu/product/schema/get', [
'cat_id' => $category->id,
'access_token' => $token->token,
'language' => $language,
]);

return $this->productFactory->createGroup($data);
return $data; // TODO: Add normalized model for response
}
}
Loading