diff --git a/readme.md b/readme.md index dffa874..7879b9e 100644 --- a/readme.md +++ b/readme.md @@ -95,6 +95,7 @@ try { * [x] [Emails](https://app.smartemailing.cz/docs/api/v3/index.html#api-Emails) * [x] [Newsletter](https://app.smartemailing.cz/docs/api/v3/index.html#api-Newsletter) * [ ] [Webhooks](https://app.smartemailing.cz/docs/api/v3/index.html#api-Webhooks) +* [x] [E shops](https://app.smartemailing.cz/docs/api/v3/index.html#api-E_shops) Notifies SmartEmailing about new order in e-shop. ## Advanced docs @@ -254,7 +255,6 @@ if ($customField = $api->customFields()->exists('name')) { throw new Exception('Not found!', 404); } ``` - ### Send / Transactional emails The implementation of API call ``send/transactional-emails-bulk``: https://app.smartemailing.cz/docs/api/v3/index.html#api-Custom_campaigns-Send_transactional_emails ## Full transactional email example @@ -363,9 +363,39 @@ $transactionEmail->addTask($task); $transactionEmail->send(); ``` + +## E_shops - Add Placed order + +The E_shops section have two endpoints to set single Order or import orders in bulk. + +Example add single order +```php +use \SmartEmailing\v3\Request\Eshops\Model\Order; +use \SmartEmailing\v3\Request\Eshops\Model\OrderItem; +use \SmartEmailing\v3\Request\Eshops\Model\Price; + +$order = new Order('my-eshop', 'ORDER0001', 'jan.novak@smartemailing.cz'); +$order->orderItems()->add( + new OrderItem( + 'ABC123', // item Id + 'My Product', // item name + 10, // quantity + new Price( + 100, // without vat + 121, // with vat + 'CZK' // currency code + ), + 'https://myeshop.cz/product/ABC123' // product url + ) +); +$api->eshopOrders()->addOrder($order)->send(); +``` + ### Send / Bulk custom sms The implementation of API call ``send/custom-sms-bulk``: https://app.smartemailing.cz/docs/api/v3/index.html#api-Custom_campaigns-Send_bulk_custom_SMS + ## Full send sms example + ```php $bulkCustomSms = new BulkCustomSms($api); @@ -402,4 +432,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute changes. All contri was written by [Martin Kluska](http://kluska.cz) and is released under the [MIT License](LICENSE.md). -Copyright (c) 2016 Martin Kluska +Copyright (c) 2016 - 2022 Martin Kluska and contributors diff --git a/src/Api.php b/src/Api.php index b173b5b..4729585 100644 --- a/src/Api.php +++ b/src/Api.php @@ -1,12 +1,14 @@ apiUrl = $apiUrl; $this->client = new Client([ @@ -50,7 +52,7 @@ public function __construct($username, $apiKey, $apiUrl = null) * Returns current API client with auth setup and base URL * @return Client */ - public function client() + public function client(): Client { return $this->client; } @@ -91,7 +93,7 @@ public function newsletter(int $emailId, array $contactLists): Newsletter /** * @return Ping */ - public function ping() + public function ping(): Ping { return new Ping($this); } @@ -99,16 +101,26 @@ public function ping() /** * @return Credentials */ - public function credentials() + public function credentials(): Credentials { return new Credentials($this); } - public function customFields() + public function customFields(): CustomFields { return new CustomFields($this); } + public function eshopOrders(): EshopOrders + { + return new EshopOrders($this); + } + + public function eshopOrdersBulk(): EshopOrdersBulk + { + return new EshopOrdersBulk($this); + } + public function customEmailsBulk(): BulkCustomEmails { return new BulkCustomEmails($this); @@ -123,5 +135,4 @@ public function transactionalEmails(): TransactionalEmails { return new TransactionalEmails($this); } - } diff --git a/src/Request/Eshops/AbstractEshopOrders.php b/src/Request/Eshops/AbstractEshopOrders.php new file mode 100644 index 0000000..a60e2d6 --- /dev/null +++ b/src/Request/Eshops/AbstractEshopOrders.php @@ -0,0 +1,90 @@ +addOrder($order); + return $order; + } + + /** + * @param Order $order + * + * @return AbstractEshopOrders + */ + public function addOrder(Order $order): AbstractEshopOrders + { + $this->orders[] = $order; + return $this; + } + + /** + * @return Order[] + */ + public function orders(): array + { + return $this->orders; + } + + /** + * @return array[] + */ + protected function options(): array + { + return [ + 'json' => $this->jsonSerialize() + ]; + } + + /** + * @return string + */ + protected function method(): string + { + return 'POST'; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return $this->orders; + } + +} diff --git a/src/Request/Eshops/EshopOrders.php b/src/Request/Eshops/EshopOrders.php new file mode 100644 index 0000000..0f5b02d --- /dev/null +++ b/src/Request/Eshops/EshopOrders.php @@ -0,0 +1,55 @@ +orders = []; + parent::addOrder($order); + return $this; + } + + /** + * @return Order|null + */ + public function order(): ?Order + { + if (count($this->orders)) { + return current($this->orders); + } + return null; + } + + /** + * @return string + */ + protected function endpoint(): string + { + return 'orders'; + } + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + if (is_null($this->order())) { + return []; + } + return $this->order()->toArray(); + } +} diff --git a/src/Request/Eshops/EshopOrdersBulk.php b/src/Request/Eshops/EshopOrdersBulk.php new file mode 100644 index 0000000..bb87f33 --- /dev/null +++ b/src/Request/Eshops/EshopOrdersBulk.php @@ -0,0 +1,68 @@ +chunkLimit >= count($this->orders)) { + return parent::send(); + } + + return $this->sendInChunkMode(); + } + + /** + * Sends contact list in chunk mode + * @return Response + */ + protected function sendInChunkMode(): Response + { + // Store the original contact list + $originalFullOrdersList = $this->orders; + $lastResponse = null; + + // Chunk the array of contacts send it in multiple requests + foreach (array_chunk($this->orders, $this->chunkLimit) as $orders) { + // Store the contacts that will be passed + $this->orders = $orders; + + $lastResponse = parent::send(); + } + + // Restore to original array + $this->orders = $originalFullOrdersList; + + return $lastResponse; + } + + /** + * @return string + */ + protected function endpoint(): string + { + return 'orders-bulk'; + } + +} diff --git a/src/Request/Eshops/Model/Attribute.php b/src/Request/Eshops/Model/Attribute.php new file mode 100644 index 0000000..3b124e0 --- /dev/null +++ b/src/Request/Eshops/Model/Attribute.php @@ -0,0 +1,77 @@ +name = $name; + $this->value = $value; + } + + /** + * @param string|null $name + * @return Attribute + */ + public function setName(string $name): Attribute + { + $this->name = $name; + return $this; + } + + /** + * @param string|null $value + * @return Attribute + */ + public function setValue(string $value): Attribute + { + $this->value = $value; + return $this; + } + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'value' => $this->value + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + // Don't remove null/empty values - not needed + return $this->toArray(); + } +} diff --git a/src/Request/Eshops/Model/FeedItem.php b/src/Request/Eshops/Model/FeedItem.php new file mode 100644 index 0000000..b39bbe2 --- /dev/null +++ b/src/Request/Eshops/Model/FeedItem.php @@ -0,0 +1,86 @@ +id = $id; + $this->feedName = $feedName; + $this->quantity = $quantity; + } + + /** + * @param string $id + * @return FeedItem + */ + public function setId(string $id): FeedItem + { + $this->id = $id; + return $this; + } + + /** + * @param string $feedName + * @return FeedItem + */ + public function setFeedName(string $feedName): FeedItem + { + $this->feedName = $feedName; + return $this; + } + + /** + * @param int $quantity + * @return FeedItem + */ + public function setQuantity(int $quantity): FeedItem + { + $this->quantity = $quantity; + return $this; + } + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return [ + 'item_id' => $this->id, + 'feed_name' => $this->feedName, + 'quantity' => $this->quantity, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + // Don't remove null/empty values - not needed + return $this->toArray(); + } +} diff --git a/src/Request/Eshops/Model/Holder/Attributes.php b/src/Request/Eshops/Model/Holder/Attributes.php new file mode 100644 index 0000000..d1f7aa6 --- /dev/null +++ b/src/Request/Eshops/Model/Holder/Attributes.php @@ -0,0 +1,61 @@ +insertEntry($list); + return $this; + } + + /** + * Creates Attribute entry and inserts it to the array + * + * @param $name + * @param $value + * + * @return Attribute + */ + public function create($name, $value): Attribute + { + $list = new Attribute($name, $value); + $this->add($list); + return $list; + } + /** + * Adds an entry model into items list. Only unique items are added (represented by the id property) + * + * @param $entry + * + * @return boolean if the entry was added (first time added) + */ + protected function insertEntry($entry): bool + { + // Allow only unique values + if (isset($this->idMap[$entry->name])) { + return false; + } + + $this->items[] = $entry; + $this->idMap[$entry->name] = $entry; + + return true; + } +} diff --git a/src/Request/Eshops/Model/Holder/FeedItems.php b/src/Request/Eshops/Model/Holder/FeedItems.php new file mode 100644 index 0000000..97c8c21 --- /dev/null +++ b/src/Request/Eshops/Model/Holder/FeedItems.php @@ -0,0 +1,43 @@ +insertEntry($list); + return $this; + } + + /** + * Creates FeedItem entry and inserts it to the array + * + * @param $idItem + * @param $feedName + * @param $quantity + * + * @return FeedItem + */ + public function create($idItem, $feedName, $quantity): FeedItem + { + $list = new FeedItem($idItem, $feedName, $quantity); + $this->add($list); + return $list; + } +} diff --git a/src/Request/Eshops/Model/Holder/OrderItems.php b/src/Request/Eshops/Model/Holder/OrderItems.php new file mode 100644 index 0000000..3e2c6eb --- /dev/null +++ b/src/Request/Eshops/Model/Holder/OrderItems.php @@ -0,0 +1,46 @@ +insertEntry($list); + return $this; + } + + /** + * Creates OrderItem entry and inserts it to the array + * + * @param int $id + * @param string $name + * @param int $quantity + * @param Price $price + * @param string $url + * + * @return OrderItem + */ + public function create($id, $name, $quantity, Price $price, $url): OrderItem + { + $list = new OrderItem($id, $name, $quantity, $price, $url); + $this->add($list); + return $list; + } +} diff --git a/src/Request/Eshops/Model/Order.php b/src/Request/Eshops/Model/Order.php new file mode 100644 index 0000000..9de3ad5 --- /dev/null +++ b/src/Request/Eshops/Model/Order.php @@ -0,0 +1,204 @@ +eshopName = $eshopName; + $this->eshopCode = $eshopCode; + $this->emailAddress = $emailAddress; + $this->orderItems = new OrderItems(); + $this->attributes = new Attributes(); + $this->feedItems = new FeedItems(); + } + + //region Setters + + + /** + * @param null|string $emailAddress + * + * @return Order + */ + public function setEmailAddress($emailAddress): Order + { + $this->emailAddress = $emailAddress; + return $this; + } + + /** + * @param mixed $eshopName + * @return Order + */ + public function setEshopName($eshopName): Order + { + $this->eshopName = $eshopName; + return $this; + } + + /** + * @param mixed $eshopCode + * @return Order + */ + public function setEshopCode($eshopCode): Order + { + $this->eshopCode = $eshopCode; + return $this; + } + + /** + * @param string $createdAt + * @param bool $convertToValidFormat converts the value to valid format + * @return Order + */ + public function setCreatedAt($createdAt, $convertToValidFormat = true): Order + { + $this->createdAt = convertDate($createdAt, $convertToValidFormat); + return $this; + } + + /** + * @param string $paidAt + * @param bool $convertToValidFormat converts the value to valid format + * @return Order + */ + public function setPaidAt($paidAt, $convertToValidFormat = true): Order + { + $this->paidAt = convertDate($paidAt, $convertToValidFormat); + return $this; + } + + /** + * @param string|null $status + * @return Order + */ + public function setStatus(string $status): Order + { + InvalidFormatException::checkInArray($status, [ + self::STATUS_PLACED, + self::STATUS_CANCELED, + self::STATUS_PROCESSING, + self::STATUS_SHIPPED, + self::STATUS_UNKNOWN + ]); + $this->status = $status; + return $this; + } + + /** + * @return OrderItems + */ + public function orderItems(): OrderItems + { + return $this->orderItems; + } + + /** + * @return Attributes + */ + public function attributes(): Attributes + { + return $this->attributes; + } + + /** + * @return FeedItems + */ + public function feedItems(): FeedItems + { + return $this->feedItems; + } + + //endregion + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return [ + 'emailaddress' => $this->emailAddress, + 'eshop_name' => $this->eshopName, + 'eshop_code' => $this->eshopCode, + 'status' => $this->status, + 'paid_at' => $this->paidAt, + 'created_at' => $this->createdAt, + 'attributes' => $this->attributes, + 'items' => $this->orderItems, + 'item_feeds' => $this->feedItems, + ]; + } +} diff --git a/src/Request/Eshops/Model/OrderItem.php b/src/Request/Eshops/Model/OrderItem.php new file mode 100644 index 0000000..f9b73f8 --- /dev/null +++ b/src/Request/Eshops/Model/OrderItem.php @@ -0,0 +1,154 @@ +setId($id); + $this->setName($name); + $this->setQuantity($quantity); + $this->setPrice($price); + $this->setUrl($url); + $this->attributes = new Attributes(); + } + + /** + * @param $id + * @return OrderItem + */ + public function setId(string $id): OrderItem + { + $this->id = $id; + return $this; + } + + /** + * @param string|null $name + * @return OrderItem + */ + public function setName(string $name): OrderItem + { + $this->name = $name; + return $this; + } + + /** + * @param string|null $description + * @return OrderItem + */ + public function setDescription(string $description): OrderItem + { + $this->description = $description; + return $this; + } + + /** + * @param Price|null $price + * @return OrderItem + */ + public function setPrice(Price $price): OrderItem + { + $this->price = $price; + return $this; + } + + /** + * @param int $quantity + * @return OrderItem + */ + public function setQuantity(int $quantity): OrderItem + { + $this->quantity = $quantity; + return $this; + } + + /** + * @param string $url + * @return OrderItem + */ + public function setUrl(string $url): OrderItem + { + $this->url = $url; + return $this; + } + + /** + * @param string|null $image_url + * @return OrderItem + */ + public function setImageUrl(?string $image_url): OrderItem + { + $this->image_url = $image_url; + return $this; + } + + /** + * @return Attributes + */ + public function attributes(): Attributes + { + return $this->attributes; + } + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'price' => $this->price, + 'quantity' => $this->quantity, + 'url' => $this->url, + 'image_url' => $this->image_url, + 'attributes' => $this->attributes, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + // Don't remove null/empty values - not needed + return $this->toArray(); + } +} diff --git a/src/Request/Eshops/Model/Price.php b/src/Request/Eshops/Model/Price.php new file mode 100644 index 0000000..09bb3da --- /dev/null +++ b/src/Request/Eshops/Model/Price.php @@ -0,0 +1,93 @@ +withoutVat = $withoutVat; + $this->withVat = $withVat; + $this->currency = $currency; + } + + /** + * @param number $withoutVat + * @return Price + */ + public function setWithoutVat($withoutVat): Price + { + $this->withoutVat = $withoutVat; + return $this; + } + + /** + * @param number $withVat + * @return Price + */ + public function setWithVat($withVat): Price + { + $this->withVat = $withVat; + return $this; + } + + + + /** + * item price currency code (ISO-4217 three-letter ("Alpha-3")) i.e.: CZK, EUR + * + * @param string $currency + * @return Price + */ + public function setCurrency(string $currency): Price + { + $this->currency = substr(strtoupper($currency), 0, 3); + return $this; + } + + + /** + * Converts data to array + * @return array + */ + public function toArray(): array + { + return [ + 'without_vat' => $this->withoutVat, + 'with_vat' => $this->withVat, + 'currency' => $this->currency, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + // Don't remove null/empty values - not needed + return $this->toArray(); + } +} diff --git a/tests/Request/Eshops/EshopOrdersBulkTest.php b/tests/Request/Eshops/EshopOrdersBulkTest.php new file mode 100644 index 0000000..6fc17bf --- /dev/null +++ b/tests/Request/Eshops/EshopOrdersBulkTest.php @@ -0,0 +1,163 @@ +orders = new EshopOrdersBulk($this->apiStub); + } + + /** + * Tests if the endpoint/options is passed to request + */ + public function testEndpoint() + { + $this->createEndpointTest($this->orders, 'orders-bulk', 'POST', $this->arrayHasKey('json')); + } + + public function testAddOrder() + { + $this->assertCount(1, $this->orders->addOrder( + new Order( + 'my-eshop', + 'ORDER0001', + 'jan.novak@smartemailing.cz' + ))->orders()); + $this->assertCount(2, $this->orders->addOrder( + new Order( + 'my-eshop2', + 'ORDER00012', + 'jan.novak2@smartemailing.cz' + ))->orders()); + $this->assertCount(3, $this->orders->addOrder( + new Order( + 'my-eshop2', + 'ORDER00013', + 'jan.novak3@smartemailing.cz' + ))->orders()); + } + + public function testNewOrder() + { + $this->orders->newOrder( + 'my-eshop', + 'ORDER0001', + 'jan.novak@smartemailing.cz' + ); + $this->assertCount(1, $this->orders->orders()); + } + + public function testChunkMode() + { + // Build a contact list 2,5 larger then chunk limit + for ($i = 1; $i <= 1250; $i++) { + $this->orders->addOrder( + new Order( + 'my-eshop', + "ORDER000{$i}", + "jan.novak+{$i}@test.cz" + ) + ); + } + + // Build the client that will mock the client->request method + $client = $this->createMock(Client::class); + $response = $this->createMock(ResponseInterface::class); + + // The array will be chunked in 3 groups + $willBeCalled = $this->exactly(3); + + // Make a response that is valid and ok - prevent exception + $response->expects($this->atLeastOnce())->method('getBody')->willReturn($this->defaultReturnResponse); + $called = 0; + $client->expects($willBeCalled)->method('request')->with( + $this->valueConstraint('POST'), + $this->valueConstraint('orders-bulk'), + $this->callback(function ($value) use (&$called) { + $this->assertTrue(is_array($value), 'Options should be array'); + $this->assertArrayHasKey('json', $value, 'Options should contain json'); + $called++; + + switch ($called) { + case 1: + $this->assertCount(500, $value['json']); + $this->assertEquals('jan.novak+1@test.cz', $value['json'][0]->emailAddress); + break; + case 2: + $this->assertCount(500, $value['json']); + $this->assertEquals('jan.novak+501@test.cz', $value['json'][0]->emailAddress); + break; + case 3: // Last pack of contacts is smaller + $this->assertCount(250, $value['json']); + $this->assertEquals('jan.novak+1001@test.cz', $value['json'][0]->emailAddress); + break; + } + + return true; + }) + )->willReturn($response); + + $this->apiStub->method('client')->willReturn($client); + $this->orders->send(); + } + + public function testChunkModeError() + { + // Build a contact list 2,5 larger then chunk limit + for ($i = 1; $i <= 1250; $i++) { + $this->orders->addOrder( + new Order( + 'my-eshop', + "ORDER000{$i}", + "jan.novak+{$i}@test.cz" + ) + ); + } + + // Build the client that will mock the client->request method + $client = $this->createMock(Client::class); + $response = $this->createMock(ResponseInterface::class); + + // Make a response that is valid and ok - prevent exception + $response->expects($this->atLeastOnce()) + ->method('getBody') + ->willReturn('{ + "status": "error", + "meta": [], + "message": "Emailaddress invalid@email@gmail.com is not valid email address." + }'); + $response->expects($this->once())->method('getStatusCode')->willReturn(422); + + $client->expects($this->once())->method('request')->with( + $this->valueConstraint('POST'), + $this->valueConstraint('orders-bulk'), + $this->callback(function ($value) { + $this->assertTrue(is_array($value), 'Options should be array'); + $this->assertArrayHasKey('json', $value, 'JSON must have data array'); + $this->assertCount(500, $value['json']); + $this->assertEquals('jan.novak+1@test.cz', $value['json'][0]->emailAddress); + + return true; + }) + )->willReturn($response); + + $this->apiStub->method('client')->willReturn($client); + $this->expectException(RequestException::class); + $this->orders->send(); + } +} diff --git a/tests/Request/Eshops/EshopOrdersLiveTest.php b/tests/Request/Eshops/EshopOrdersLiveTest.php new file mode 100644 index 0000000..eeaa029 --- /dev/null +++ b/tests/Request/Eshops/EshopOrdersLiveTest.php @@ -0,0 +1,53 @@ +orders = $this->createApi()->eshopOrders(); + } + + /** + * Tests if the endpoint/options is passed to request + */ + public function testBasic() { + $this->assertInstanceOf(EshopOrders::class, $this->orders); + } + + /** + * Live test of sync + */ + public function testContactImport() + { + // Uncomment if you want to try + return; + + $order = new Order( + 'my-eshop', + 'ORDER0001', + 'jan.novak@smartemailing.cz' + ); + + $this->orders->addOrder($order); + + $response = $this->orders->send(); + + $this->assertEquals(Response::SUCCESS, $response->status()); + $this->assertEquals(200, $response->statusCode()); + } + +} diff --git a/tests/Request/Eshops/EshopOrdersTest.php b/tests/Request/Eshops/EshopOrdersTest.php new file mode 100644 index 0000000..a0c3a7d --- /dev/null +++ b/tests/Request/Eshops/EshopOrdersTest.php @@ -0,0 +1,130 @@ +orders = new EshopOrders($this->apiStub); + } + + /** + * Tests if the endpoint/options is passed to request + */ + public function testEndpoint() + { + $this->createEndpointTest($this->orders, 'orders', 'POST', $this->arrayHasKey('json')); + } + + public function testAddOrder() + { + $this->assertCount(1, $this->orders->addOrder( + new Order( + 'my-eshop', + 'ORDER0001', + 'jan.novak@smartemailing.cz' + ))->orders()); + $orders = $this->orders->addOrder( + new Order( + 'eshop_name2', + 'eshop_code2', + 'jan.novak2@smartemailing.cz' + )); + $this->assertCount(1, $orders->orders()); + + $order = $orders->order(); + $this->assertSame('eshop_name2', $order->eshopName); + $this->assertSame('eshop_code2', $order->eshopCode); + $this->assertSame('jan.novak2@smartemailing.cz', $order->emailAddress); + } + + public function testNewOrder() + { + $this->orders->newOrder( + 'my-eshop', + 'ORDER0001', + 'jan.novak@smartemailing.cz' + ); + $this->assertCount(1, $this->orders->orders()); + } + /** + * Mocks the request and checks if request is returned via send method + */ + public function testSend() + { + $this->createSendResponse($this->orders, '{ + "status": "ok", + "meta": [], + "data": { + "id": "11eb15523deea6c49042ac1f6bc402ad", + "created_at": "2020-01-01 00:00:00", + "contact_id": 2320051, + "status": "processing", + "eshop_name": "my-eshop", + "eshop_code": "ORDER0001", + "paid_at": null, + "attributes": [ + { + "name": "discount", + "value": "Black friday" + } + ], + "items": [ + { + "id": "ABC123", + "name": "My product", + "description": "My product description", + "price": { + "without_vat": 123.97, + "with_vat": 150, + "currency": "CZK" + }, + "quantity": 1, + "url": "https://www.example.com/my-product", + "image_url": "https://www.example.com/images/my-product.jpg", + "attributes": [ + { + "name": "manufacturer", + "value": "Factory ltd." + }, + { + "name": "my other custom attribute", + "value": "some value" + } + ] + }, + { + "id": "XYZ789", + "name": "My another product", + "description": "My another product description", + "price": { + "without_vat": 165.7, + "with_vat": 200.5, + "currency": "CZK" + }, + "quantity": 2, + "url": "https://www.example.com/my-another-product", + "image_url": "https://www.example.com/images/my-another-product.jpg", + "attributes": [ + { + "name": "my other custom attribute2", + "value": "some value2" + } + ] + } + ] + } +}', null); + } +}