diff --git a/README.md b/README.md
index da63432..432f331 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,9 @@
* [Delete](#webhook-delete)
* [Campaign language API](#campaign-language-read)
* [Timezone API](#timezone-read)
- * [Batch API](#batch-send)
+ * [Batch API](#batch-send)
+* [Optional PSR transport (no BC break)](#optional-psr-transport-no-bc-break)
+* [E-commerce (Products / Orders / Customers)](#e-commerce)
* [Testing](#testing)
* [License](#license)
@@ -766,6 +768,65 @@ $response = $mailerLite->timezones->get();
## Batch API
More information about request parameters on https://developers.mailerlite.com/docs/batching.html
+
+## E-commerce (Products / Orders / Customers)
+
+E-commerce endpoints follow:
+```php
+/api/ecommerce/shops/{shopId}/...
+```
+`shopId` is provided per call (there is no separate `/shops` API).
+
+**Products**
+```php
+$res = $mailerLite->ecommerceProducts->create('YOUR_SHOP_ID', [
+ 'title' => 'Red T-shirt',
+ 'price' => 19.99,
+ 'currency' => 'USD',
+]);
+
+$res = $mailerLite->ecommerceProducts->find('YOUR_SHOP_ID', 'PRODUCT_ID');
+
+$res = $mailerLite->ecommerceProducts->update('YOUR_SHOP_ID', 'PRODUCT_ID', [
+ 'title' => 'New title',
+]);
+```
+**Orders**
+```php
+$res = $mailerLite->ecommerceOrders->create('YOUR_SHOP_ID', [
+ 'order_id' => 'ORD-1001',
+]);
+
+$res = $mailerLite->ecommerceOrders->find('YOUR_SHOP_ID', 'ORD-1001');
+
+$res = $mailerLite->ecommerceOrders->update('YOUR_SHOP_ID', 'ORD-1001', [
+ 'status' => 'paid',
+]);
+```
+**Customers**
+```php
+$res = $mailerLite->ecommerceCustomers->get('YOUR_SHOP_ID', ['limit' => 50]);
+$res = $mailerLite->ecommerceCustomers->create('YOUR_SHOP_ID', ['email' => 'user@example.com']);
+$res = $mailerLite->ecommerceCustomers->find('YOUR_SHOP_ID', 'CUSTOMER_ID');
+```
+## Optional PSR transport (no BC break)
+```php
+use MailerLite\MailerLite;
+use MailerLite\Http\Adapters\Psr17FactoryAggregate;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use GuzzleHttp\Client as GuzzleHttpClient;
+$ml = new MailerLite(['api_key' => 'YOUR_API_KEY']);
+
+$psr17 = new Psr17Factory();
+$factories = new Psr17FactoryAggregate($psr17, $psr17);
+
+$httpClient = new GuzzleHttpClient();
+
+$ml->enablePsrTransport($httpClient, $factories, 'YOUR_API_KEY', 'https://connect.mailerlite.com');
+
+$ml->subscribers->create(['email' => 'subscriber@example.com']);
+```
+
### Send
diff --git a/composer.json b/composer.json
index 38fd64a..3fc85da 100644
--- a/composer.json
+++ b/composer.json
@@ -25,8 +25,15 @@
"php-http/discovery": "^1.9",
"php-http/httplug": "^2.1",
"psr/http-client-implementation": "^1.0",
- "psr/http-message": "^1.0 || ^2.0"
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.0"
},
+ "suggest": {
+ "guzzlehttp/guzzle": "^7.8 || ^8.0",
+ "nyholm/psr7": "^1.8",
+ "symfony/http-client": "^7.0"
+ },
"require-dev": {
"phpunit/phpunit": "^7.5.15 || ^8.4 || ^9.0",
"php-http/mock-client": "^1.0",
@@ -36,7 +43,8 @@
"http-interop/http-factory-guzzle": "^1.0",
"php-http/guzzle7-adapter": "^0.1",
"friendsofphp/php-cs-fixer": "^2.18",
- "phpstan/phpstan": "^1.10"
+ "phpstan/phpstan": "^1.10",
+ "nyholm/psr7": "^1.8"
},
"autoload": {
"psr-4": {
diff --git a/src/Common/HttpLayerPsr.php b/src/Common/HttpLayerPsr.php
new file mode 100644
index 0000000..53f62de
--- /dev/null
+++ b/src/Common/HttpLayerPsr.php
@@ -0,0 +1,124 @@
+ */
+ private $defaultHeaders;
+
+ /**
+ * @param array $defaultHeaders
+ */
+ public function __construct(
+ ClientInterface $client,
+ RequestFactoryInterface $requestFactory,
+ string $apiKey,
+ string $baseUrl = 'https://connect.mailerlite.com',
+ array $defaultHeaders = [
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json',
+ ]
+ ) {
+ $this->client = $client;
+ $this->requestFactory = $requestFactory;
+ $this->apiKey = $apiKey;
+ $this->baseUrl = $baseUrl;
+ $this->defaultHeaders = $defaultHeaders;
+ }
+
+ /**
+ * @param array|bool|float|int|string|null> $query
+ */
+ public function get(string $path, array $query = []): ResponseInterface
+ {
+ $uri = $this->buildUri($path, $query);
+ $req = $this->requestFactory->create('GET', $uri, $this->headers());
+ $res = $this->client->sendRequest($req);
+ HttpErrorMapper::throwIfError($res);
+ return $res;
+ }
+
+ /**
+ * @param array $payload
+ */
+ public function post(string $path, array $payload = []): ResponseInterface
+ {
+ $uri = $this->buildUri($path);
+ $body = $this->encodeJsonBody($payload);
+ $req = $this->requestFactory->create('POST', $uri, $this->headers(), $body);
+ $res = $this->client->sendRequest($req);
+ HttpErrorMapper::throwIfError($res);
+ return $res;
+ }
+
+ /**
+ * @param array $payload
+ */
+ public function put(string $path, array $payload = []): ResponseInterface
+ {
+ $uri = $this->buildUri($path);
+ $body = $this->encodeJsonBody($payload);
+ $req = $this->requestFactory->create('PUT', $uri, $this->headers(), $body);
+ $res = $this->client->sendRequest($req);
+ HttpErrorMapper::throwIfError($res);
+ return $res;
+ }
+
+ public function delete(string $path): ResponseInterface
+ {
+ $uri = $this->buildUri($path);
+ $req = $this->requestFactory->create('DELETE', $uri, $this->headers());
+ $res = $this->client->sendRequest($req);
+ HttpErrorMapper::throwIfError($res);
+ return $res;
+ }
+
+ /**
+ * @param array|bool|float|int|string|null> $query
+ */
+ private function buildUri(string $path, array $query = []): string
+ {
+ $uri = rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/');
+ if ($query !== []) {
+ $uri .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
+ }
+ return $uri;
+ }
+
+ /**
+ * @param array $payload
+ */
+ private function encodeJsonBody(array $payload): ?string
+ {
+ if ($payload === []) {
+ return null;
+ }
+ return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+ }
+
+ /**
+ * @return array
+ */
+ private function headers(): array
+ {
+ return ['Authorization' => 'Bearer ' . $this->apiKey] + $this->defaultHeaders;
+ }
+}
\ No newline at end of file
diff --git a/src/Common/HttpLayerPsrBridge.php b/src/Common/HttpLayerPsrBridge.php
new file mode 100644
index 0000000..267f720
--- /dev/null
+++ b/src/Common/HttpLayerPsrBridge.php
@@ -0,0 +1,154 @@
+psrLayer = $psrLayer;
+ }
+
+ /** @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface} */
+ public function get(string $uri, array $body = []): array
+ {
+ /** @var array|bool|float|int|string|null> $query */
+ $query = $this->sanitizeQuery($body);
+ return $this->executeAndConvert(fn() => $this->psrLayer->get($uri, $query));
+ }
+
+ /** @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface} */
+ public function post(string $uri, array $body = []): array
+ {
+ return $this->executeAndConvert(fn() => $this->psrLayer->post($uri, $body));
+ }
+
+ /** @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface} */
+ public function put(string $uri, array $body): array
+ {
+ return $this->executeAndConvert(fn() => $this->psrLayer->put($uri, $body));
+ }
+
+ /** @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface} */
+ public function delete(string $uri, array $body = []): array
+ {
+ // Note: $body parameter is ignored for DELETE requests as per HTTP semantics
+ return $this->executeAndConvert(fn() => $this->psrLayer->delete($uri));
+ }
+
+ /** @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface} */
+ public function request(string $method, string $uri, string $body = ''): array
+ {
+ $method = strtoupper($method);
+ $decodedBody = $this->decodeJsonBody($body);
+
+ switch ($method) {
+ case 'GET':
+ return $this->get($uri, []);
+ case 'POST':
+ return $this->post($uri, $decodedBody);
+ case 'PUT':
+ return $this->put($uri, $decodedBody);
+ case 'DELETE':
+ return $this->delete($uri, []);
+ default:
+ throw new MailerLiteException("Unsupported HTTP method: {$method}");
+ }
+ }
+
+ /**
+ * Execute PSR layer method and convert response to compatibility format
+ * @param callable(): ResponseInterface $psrMethod
+ * @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface}
+ */
+ private function executeAndConvert(callable $psrMethod): array
+ {
+ $response = $psrMethod();
+ return $this->compatResponse($response);
+ }
+
+ /**
+ * @return array
+ */
+ private function decodeJsonBody(string $body): array
+ {
+ if ($body === '') {
+ return [];
+ }
+
+ $decoded = json_decode($body, true);
+ return $decoded !== null ? (array) $decoded : [];
+ }
+
+ /**
+ * @param array $in
+ * @return array|bool|float|int|string|null>
+ */
+ private function sanitizeQuery(array $in): array
+ {
+ /** @var array|bool|float|int|string|null> $out */
+ $out = [];
+ foreach ($in as $k => $v) {
+ if (is_array($v)) {
+ $out[$k] = array_map(
+ /** @return array|bool|float|int|string|null */
+ static function ($x) {
+ if (is_bool($x) || is_int($x) || is_float($x) || is_string($x) || $x === null) {
+ return $x;
+ }
+ if (is_object($x) && method_exists($x, '__toString')) {
+ return (string) $x;
+ }
+ $json = json_encode($x);
+ return $json !== false ? $json : get_debug_type($x);
+ },
+ $v
+ );
+ } elseif (is_bool($v) || is_int($v) || is_float($v) || is_string($v) || $v === null) {
+ $out[$k] = $v;
+ } else {
+ if (is_object($v) && method_exists($v, '__toString')) {
+ $out[$k] = (string) $v;
+ } else {
+ $json = json_encode($v);
+ $out[$k] = $json !== false ? $json : get_debug_type($v);
+ }
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * @return array{status_code:int,headers:array,body:mixed,response:ResponseInterface}
+ */
+ private function compatResponse(ResponseInterface $response): array
+ {
+ $headers = $response->getHeaders();
+ $statusCode = $response->getStatusCode();
+
+ $contentTypes = $response->getHeader('Content-Type');
+ $contentType = $response->hasHeader('Content-Type') ? (string) reset($contentTypes) : null;
+
+ switch (true) {
+ case $contentType !== null && stripos($contentType, 'application/json') === 0:
+ $bodyStr = (string) $response->getBody();
+ $body = $bodyStr !== '' ? json_decode($bodyStr, true) : null;
+ break;
+ default:
+ $body = (string) $response->getBody();
+ }
+
+ return [
+ 'status_code' => $statusCode,
+ 'headers' => $headers,
+ 'body' => $body,
+ 'response' => $response,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Endpoints/Cart.php b/src/Endpoints/Cart.php
new file mode 100644
index 0000000..7e931a9
--- /dev/null
+++ b/src/Endpoints/Cart.php
@@ -0,0 +1,18 @@
+
+ */
+ public function find(string $shopId, $cartId): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/carts/{$cartId}");
+ return $this->httpLayer->get($uri);
+ }
+}
diff --git a/src/Endpoints/CartItem.php b/src/Endpoints/CartItem.php
new file mode 100644
index 0000000..e56f7ae
--- /dev/null
+++ b/src/Endpoints/CartItem.php
@@ -0,0 +1,20 @@
+ $data
+ * @return array
+ */
+ public function upsert($shopId, $cartId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/carts/{$cartId}/items");
+ return $this->httpLayer->post($uri, $data);
+ }
+}
diff --git a/src/Endpoints/Customer.php b/src/Endpoints/Customer.php
new file mode 100644
index 0000000..037141c
--- /dev/null
+++ b/src/Endpoints/Customer.php
@@ -0,0 +1,38 @@
+ $filter
+ * @return array
+ */
+ public function get(string $shopId, array $filter = []): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/customers", $filter);
+ return $this->httpLayer->get($uri);
+ }
+
+ /**
+ * @param array $data
+ * @return array
+ */
+ public function create(string $shopId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/customers");
+ return $this->httpLayer->post($uri, $data);
+ }
+
+ /**
+ * @param string|int $customerId
+ * @return array
+ */
+ public function find(string $shopId, $customerId): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/customers/{$customerId}");
+ return $this->httpLayer->get($uri);
+ }
+}
diff --git a/src/Endpoints/Import.php b/src/Endpoints/Import.php
new file mode 100644
index 0000000..ce964d4
--- /dev/null
+++ b/src/Endpoints/Import.php
@@ -0,0 +1,18 @@
+ $payload
+ * @return array
+ */
+ public function orders(string $shopId, array $payload): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/orders/import");
+ return $this->httpLayer->post($uri, $payload);
+ }
+}
diff --git a/src/Endpoints/Order.php b/src/Endpoints/Order.php
new file mode 100644
index 0000000..0be2d25
--- /dev/null
+++ b/src/Endpoints/Order.php
@@ -0,0 +1,39 @@
+ $data
+ * @return array
+ */
+ public function create(string $shopId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/orders");
+ return $this->httpLayer->post($uri, $data);
+ }
+
+ /**
+ * @param string|int $orderId
+ * @param array $data
+ * @return array
+ */
+ public function update(string $shopId, $orderId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/orders/{$orderId}");
+ return $this->httpLayer->put($uri, $data);
+ }
+
+ /**
+ * @param string|int $orderId
+ * @return array
+ */
+ public function find(string $shopId, $orderId): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/orders/{$orderId}");
+ return $this->httpLayer->get($uri);
+ }
+}
diff --git a/src/Endpoints/Product.php b/src/Endpoints/Product.php
new file mode 100644
index 0000000..d1905b2
--- /dev/null
+++ b/src/Endpoints/Product.php
@@ -0,0 +1,40 @@
+ $data
+ * @return array
+ */
+ public function create(string $shopId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/products");
+ return $this->httpLayer->post($uri, $data);
+ }
+
+ /**
+ * @param string|int $productId
+ * @param array $data
+ * @return array
+ */
+ public function update(string $shopId, $productId, array $data): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/products/{$productId}");
+ return $this->httpLayer->put($uri, $data);
+ }
+
+ /**
+ * @param string|int $productId
+ * @return array
+ */
+ public function find(string $shopId, $productId): array
+ {
+ $uri = $this->buildUri("ecommerce/shops/{$shopId}/products/{$productId}");
+ return $this->httpLayer->get($uri);
+ }
+}
diff --git a/src/Http/Adapters/GuzzleClientAdapter.php b/src/Http/Adapters/GuzzleClientAdapter.php
new file mode 100644
index 0000000..ea60833
--- /dev/null
+++ b/src/Http/Adapters/GuzzleClientAdapter.php
@@ -0,0 +1,24 @@
+client = $client;
+ }
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ return $this->client->sendRequest($request);
+ }
+}
diff --git a/src/Http/Adapters/Psr17FactoryAggregate.php b/src/Http/Adapters/Psr17FactoryAggregate.php
new file mode 100644
index 0000000..4d58dac
--- /dev/null
+++ b/src/Http/Adapters/Psr17FactoryAggregate.php
@@ -0,0 +1,55 @@
+requestFactory = $requestFactory;
+ $this->streamFactory = $streamFactory;
+ }
+
+ /**
+ * @param array $headers
+ */
+ public function create(
+ string $method,
+ string $uri,
+ array $headers = [],
+ ?string $body = null
+ ): RequestInterface {
+ $req = $this->requestFactory->createRequest($method, $uri);
+
+ foreach ($headers as $name => $value) {
+ $values = is_array($value) ? array_values(array_map('strval', $value)) : [ (string)$value ];
+ $req = $req->withHeader($name, $values);
+ }
+
+ if ($body !== null) {
+ $req = $req->withBody($this->createStream($body));
+ }
+
+ return $req;
+ }
+
+ public function createStream(string $content): StreamInterface
+ {
+ return $this->streamFactory->createStream($content);
+ }
+}
diff --git a/src/Http/ClientInterface.php b/src/Http/ClientInterface.php
new file mode 100644
index 0000000..ed2c171
--- /dev/null
+++ b/src/Http/ClientInterface.php
@@ -0,0 +1,11 @@
+ */
+ protected array $responseHeaders;
+
+ /**
+ * @param array $responseHeaders
+ */
+ public function __construct(
+ string $message,
+ int $statusCode = 0,
+ ?string $responseBody = null,
+ array $responseHeaders = [],
+ ?\Throwable $previous = null
+ ) {
+ parent::__construct($message, $statusCode, $previous);
+ $this->statusCode = $statusCode;
+ $this->responseBody = $responseBody;
+ $this->responseHeaders = $responseHeaders;
+ }
+
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+
+ public function getResponseBody(): ?string
+ {
+ return $this->responseBody;
+ }
+
+ /**
+ * @return array
+ */
+ public function getResponseHeaders(): array
+ {
+ return $this->responseHeaders;
+ }
+
+ public function getRequestId(?string $headerName = 'X-Request-Id'): ?string
+ {
+ foreach ($this->responseHeaders as $name => $values) {
+ if (strcasecmp($name, (string)$headerName) === 0) {
+ return $values[0] ?? null;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/Http/Exceptions/NotFound.php b/src/Http/Exceptions/NotFound.php
new file mode 100644
index 0000000..e64ec14
--- /dev/null
+++ b/src/Http/Exceptions/NotFound.php
@@ -0,0 +1,7 @@
+getStatusCode();
+ if ($code < 400) {
+ return;
+ }
+
+ $body = (string) $r->getBody();
+ $headers = $r->getHeaders();
+ $message = $body !== '' ? $body : ('HTTP ' . $code);
+
+ switch ($code) {
+ case 401:
+ throw new Unauthorized($message, 401, $body, $headers);
+ case 403:
+ throw new Forbidden($message, 403, $body, $headers);
+ case 404:
+ throw new NotFound($message, 404, $body, $headers);
+ case 429:
+ throw new TooManyRequests($message, 429, $body, $headers);
+ default:
+ if ($code >= 500) {
+ throw new ServerError($message, $code, $body, $headers);
+ }
+
+ throw new \RuntimeException($message, $code);
+ }
+ }
+}
diff --git a/src/Http/RequestFactoryInterface.php b/src/Http/RequestFactoryInterface.php
new file mode 100644
index 0000000..4530be3
--- /dev/null
+++ b/src/Http/RequestFactoryInterface.php
@@ -0,0 +1,18 @@
+ $headers
+ */
+ public function create(
+ string $method,
+ string $uri,
+ array $headers = [],
+ ?string $body = null
+ ): RequestInterface;
+}
diff --git a/src/Http/RetryingClient.php b/src/Http/RetryingClient.php
new file mode 100644
index 0000000..f2c2cf4
--- /dev/null
+++ b/src/Http/RetryingClient.php
@@ -0,0 +1,74 @@
+inner = $inner;
+ $this->maxAttempts = $maxAttempts;
+ $this->baseDelayMs = $baseDelayMs;
+ }
+
+ public function sendRequest(RequestInterface $request): ResponseInterface
+ {
+ $attempt = 0;
+
+ do {
+ $attempt++;
+ $response = $this->inner->sendRequest($request);
+
+ if ($this->shouldRetry($response, $attempt)) {
+ $this->waitBeforeRetry($response, $attempt);
+ continue;
+ }
+
+ return $response;
+ } while ($attempt < $this->maxAttempts);
+
+ return $response;
+ }
+
+ private function shouldRetry(ResponseInterface $response, int $attempt): bool
+ {
+ if ($attempt >= $this->maxAttempts) {
+ return false;
+ }
+
+ $statusCode = $response->getStatusCode();
+ return $statusCode === 429 || $statusCode >= 500;
+ }
+
+ private function waitBeforeRetry(ResponseInterface $response, int $attempt): void
+ {
+ $retryAfter = $response->getHeaderLine('Retry-After');
+
+ $delayMs = $this->calculateDelayMs($retryAfter, $attempt);
+ usleep($delayMs * 1000);
+ }
+
+ private function calculateDelayMs(string $retryAfter, int $attempt): int
+ {
+ if ($retryAfter !== '' && is_numeric($retryAfter)) {
+ return (int) $retryAfter * 1000;
+ }
+
+ return $this->baseDelayMs * (2 ** ($attempt - 1));
+ }
+}
\ No newline at end of file
diff --git a/src/Http/StreamFactoryInterface.php b/src/Http/StreamFactoryInterface.php
new file mode 100644
index 0000000..3c05bdc
--- /dev/null
+++ b/src/Http/StreamFactoryInterface.php
@@ -0,0 +1,10 @@
+
- */
+ /** @var array */
protected array $options;
- /**
- * @var array
- */
+ /** @var array */
protected static array $defaultOptions = [
'host' => 'connect.mailerlite.com',
'protocol' => 'https',
@@ -50,9 +51,15 @@ class MailerLite
public Timezone $timezones;
public CampaignLanguage $campaignLanguages;
public Batch $batches;
+ public Product $ecommerceProducts;
+ public Order $ecommerceOrders;
+ public Customer $ecommerceCustomers;
+ public Cart $ecommerceCarts;
+ public CartItem $ecommerceCartItems;
+ public Import $ecommerceImport;
/**
- * @param array $options
+ * @param array $options
*/
public function __construct(array $options = [], ?HttpLayer $httpLayer = null)
{
@@ -62,8 +69,8 @@ public function __construct(array $options = [], ?HttpLayer $httpLayer = null)
}
/**
- * @param array $options
- */
+ * @param array $options
+ */
protected function setOptions(array $options): void
{
$this->options = self::$defaultOptions;
@@ -84,6 +91,18 @@ protected function setHttpLayer(?HttpLayer $httpLayer = null): void
$this->httpLayer = $httpLayer ?: new HttpLayer($this->options);
}
+ public function enablePsrTransport(
+ \MailerLite\Http\ClientInterface $client,
+ \MailerLite\Http\RequestFactoryInterface $requestFactory,
+ string $apiKey,
+ string $baseUrl = 'https://connect.mailerlite.com'
+ ): self {
+ $psr = new HttpLayerPsr($client, $requestFactory, $apiKey, $baseUrl);
+ $this->httpLayer = new HttpLayerPsrBridge($psr);
+ $this->setEndpoints();
+ return $this;
+ }
+
protected function setEndpoints(): void
{
$this->subscribers = new Subscriber($this->httpLayer, $this->options);
@@ -97,5 +116,11 @@ protected function setEndpoints(): void
$this->timezones = new Timezone($this->httpLayer, $this->options);
$this->campaignLanguages = new CampaignLanguage($this->httpLayer, $this->options);
$this->batches = new Batch($this->httpLayer, $this->options);
+ $this->ecommerceProducts = new Product($this->httpLayer, $this->options);
+ $this->ecommerceOrders = new Order($this->httpLayer, $this->options);
+ $this->ecommerceCustomers = new Customer($this->httpLayer, $this->options);
+ $this->ecommerceCarts = new Cart($this->httpLayer, $this->options);
+ $this->ecommerceCartItems = new CartItem($this->httpLayer, $this->options);
+ $this->ecommerceImport = new Import($this->httpLayer, $this->options);
}
}
diff --git a/tests/Common/HttpLayerPsrTest.php b/tests/Common/HttpLayerPsrTest.php
new file mode 100644
index 0000000..0cec805
--- /dev/null
+++ b/tests/Common/HttpLayerPsrTest.php
@@ -0,0 +1,53 @@
+queue = [ new Response(200, [], '{"ok":true}') ];
+
+ $psr17 = new Psr17Factory();
+ $factories = new Psr17FactoryAggregate($psr17, $psr17);
+
+ $layer = new HttpLayerPsr(
+ $fake,
+ $factories,
+ 'TEST_KEY',
+ 'https://connect.mailerlite.com'
+ );
+
+ $resp = $layer->post('api/subscribers', ['email' => 'a@b.com']);
+
+ $this->assertSame(200, $resp->getStatusCode());
+ $this->assertSame('{"ok":true}', (string)$resp->getBody());
+ }
+
+ public function test_delete_404_maps_to_exception(): void
+ {
+ $this->expectException(\MailerLite\Http\Exceptions\NotFound::class);
+
+ $fake = new FakeClient();
+ $fake->queue = [ new Response(404, [], 'nope') ];
+
+ $psr17 = new Psr17Factory();
+ $factories = new Psr17FactoryAggregate($psr17, $psr17);
+
+ $layer = new HttpLayerPsr(
+ $fake,
+ $factories,
+ 'TEST_KEY'
+ );
+
+ $layer->delete('api/subscribers/123');
+ }
+}
diff --git a/tests/Endpoints/CartTest.php b/tests/Endpoints/CartTest.php
new file mode 100644
index 0000000..5cbfd09
--- /dev/null
+++ b/tests/Endpoints/CartTest.php
@@ -0,0 +1,41 @@
+makeBridgeAndMock();
+
+ $payload = [
+ 'id' => 'cart_123',
+ 'currency' => 'USD',
+ 'total' => 42.50,
+ 'items' => [
+ ['sku' => 'SKU-1', 'qty' => 1],
+ ],
+ ];
+ $psr18->addResponse(new Response(
+ 200,
+ ['Content-Type' => 'application/json'],
+ json_encode($payload, JSON_THROW_ON_ERROR)
+ ));
+
+ $endpoint = new Cart($bridge, $options);
+ $res = $endpoint->find('shop_1', 'cart_123');
+
+ $this->assertSame(200, $res['status_code']);
+ $this->assertSame('cart_123', $res['body']['id']);
+ $this->assertSame('USD', $res['body']['currency']);
+ }
+}
diff --git a/tests/Endpoints/CustomerTest.php b/tests/Endpoints/CustomerTest.php
new file mode 100644
index 0000000..c635c61
--- /dev/null
+++ b/tests/Endpoints/CustomerTest.php
@@ -0,0 +1,39 @@
+makeBridgeAndMock();
+
+ $payload = [
+ 'data' => [
+ ['id' => 'c1', 'email' => 'a@ex.com'],
+ ['id' => 'c2', 'email' => 'b@ex.com'],
+ ],
+ ];
+ $psr18->addResponse(new Response(
+ 200,
+ ['Content-Type' => 'application/json'],
+ json_encode($payload, JSON_THROW_ON_ERROR)
+ ));
+
+ $endpoint = new Customer($bridge, $options);
+
+ $res = $endpoint->get('shop_1', ['limit' => 2]);
+
+ $this->assertSame(200, $res['status_code']);
+ $this->assertIsArray($res['body']);
+ $this->assertCount(2, $res['body']['data']);
+ $this->assertSame('c1', $res['body']['data'][0]['id']);
+ }
+}
diff --git a/tests/Endpoints/ImportTest.php.php b/tests/Endpoints/ImportTest.php.php
new file mode 100644
index 0000000..b1bcd7e
--- /dev/null
+++ b/tests/Endpoints/ImportTest.php.php
@@ -0,0 +1,31 @@
+makeBridgeAndMock();
+
+ $payload = ['status' => 'accepted', 'import_id' => 'imp_1'];
+ $psr18->addResponse(new Response(
+ 202,
+ ['Content-Type' => 'application/json'],
+ json_encode($payload, JSON_THROW_ON_ERROR)
+ ));
+
+ $endpoint = new Import($bridge, $options);
+ $res = $endpoint->orders('shop_1', ['orders' => [/* ... */]]);
+
+ $this->assertSame(202, $res['status_code']);
+ $this->assertSame('imp_1', $res['body']['import_id']);
+ }
+}
diff --git a/tests/Endpoints/OrderTest.php b/tests/Endpoints/OrderTest.php
new file mode 100644
index 0000000..8df3227
--- /dev/null
+++ b/tests/Endpoints/OrderTest.php
@@ -0,0 +1,55 @@
+ 'https',
+ 'host' => 'connect.mailerlite.com',
+ 'api_path' => 'api',
+ 'api_key' => 'TEST_KEY',
+ ];
+
+ return [$factories, $options];
+ }
+
+ public function test_find_404_maps_to_NotFound(): void
+ {
+ [$factories, $options] = $this->makeBridge();
+
+ $psr18 = new Psr18MockClient();
+ $psr18->addResponse(new Response(
+ 404,
+ ['Content-Type' => 'application/json'],
+ json_encode(['error' => 'order not found'], JSON_THROW_ON_ERROR)
+ ));
+
+ $client = new GuzzleClientAdapter($psr18);
+ $psrLayer = new HttpLayerPsr($client, $factories, 'TEST_KEY', 'https://connect.mailerlite.com');
+ $bridge = new HttpLayerPsrBridge($psrLayer);
+
+ $endpoint = new Order($bridge, $options);
+
+ $this->expectException(NotFound::class);
+ $this->expectExceptionMessage('order not found');
+
+ $endpoint->find('shop_1', 'order_404');
+ }
+}
diff --git a/tests/Endpoints/ProductTest.php b/tests/Endpoints/ProductTest.php
new file mode 100644
index 0000000..29b7443
--- /dev/null
+++ b/tests/Endpoints/ProductTest.php
@@ -0,0 +1,67 @@
+ 'https',
+ 'host' => 'connect.mailerlite.com',
+ 'api_path' => 'api',
+ 'api_key' => 'TEST_KEY',
+ ];
+
+ return [$factories, $options];
+ }
+
+ public function test_create_ok(): void
+ {
+ [$factories, $options] = $this->makeBridge();
+
+ $payload = [
+ 'id' => 321,
+ 'title' => 'Red T-shirt',
+ 'price' => 19.99,
+ 'currency' => 'USD',
+ ];
+
+ $psr18 = new Psr18MockClient();
+ $psr18->addResponse(new Response(
+ 201,
+ ['Content-Type' => 'application/json'],
+ json_encode($payload, JSON_THROW_ON_ERROR)
+ ));
+ $client = new GuzzleClientAdapter($psr18);
+ $psrLayer = new HttpLayerPsr($client, $factories, 'TEST_KEY', 'https://connect.mailerlite.com');
+ $bridge = new HttpLayerPsrBridge($psrLayer);
+
+ $endpoint = new Product($bridge, $options);
+
+ $shopId = 'shop_1';
+ $data = ['title' => 'Red T-shirt', 'price' => 19.99, 'currency' => 'USD'];
+
+ $res = $endpoint->create($shopId, $data);
+
+ $this->assertSame(201, $res['status_code']);
+ $this->assertIsArray($res['headers']);
+ $this->assertIsArray($res['body']);
+ $this->assertSame(321, $res['body']['id']);
+ $this->assertSame('Red T-shirt', $res['body']['title']);
+ $this->assertSame(19.99, $res['body']['price']);
+ }
+}
diff --git a/tests/Fakes/FakeClient.php b/tests/Fakes/FakeClient.php
new file mode 100644
index 0000000..ff8f1d2
--- /dev/null
+++ b/tests/Fakes/FakeClient.php
@@ -0,0 +1,21 @@
+queue === []) {
+ throw new \RuntimeException('Response queue is empty');
+ }
+ return array_shift($this->queue);
+ }
+}
diff --git a/tests/Http/ExceptionsPayloadTest.php b/tests/Http/ExceptionsPayloadTest.php
new file mode 100644
index 0000000..c0ef510
--- /dev/null
+++ b/tests/Http/ExceptionsPayloadTest.php
@@ -0,0 +1,19 @@
+ ['id-1']]);
+
+ $this->assertSame(401, $ex->getStatusCode());
+ $this->assertSame('body', $ex->getResponseBody());
+ $this->assertSame(['X-Request-Id' => ['id-1']], $ex->getResponseHeaders());
+ $this->assertSame('id-1', $ex->getRequestId());
+ }
+}
diff --git a/tests/Http/GuzzleClientAdapter.php b/tests/Http/GuzzleClientAdapter.php
new file mode 100644
index 0000000..2d09d51
--- /dev/null
+++ b/tests/Http/GuzzleClientAdapter.php
@@ -0,0 +1,37 @@
+resp;
+ }
+ };
+
+ $adapter = new GuzzleClientAdapter($inner);
+
+ $req = $psr17->createRequest('GET', 'https://x');
+ $resp = $adapter->sendRequest($req);
+
+ $this->assertSame(200, $resp->getStatusCode());
+ $this->assertSame('ok', (string)$resp->getBody());
+ }
+}
diff --git a/tests/Http/HttpErrorMapperTest.php b/tests/Http/HttpErrorMapperTest.php
new file mode 100644
index 0000000..30ae066
--- /dev/null
+++ b/tests/Http/HttpErrorMapperTest.php
@@ -0,0 +1,49 @@
+ ['abc-123']], 'invalid');
+
+ try {
+ HttpErrorMapper::throwIfError($resp);
+ $this->fail('No exception thrown');
+ } catch (Ex\Unauthorized $e) {
+ $this->assertSame(401, $e->getStatusCode());
+ $this->assertSame('invalid', $e->getResponseBody());
+ $this->assertSame('abc-123', $e->getRequestId());
+ }
+ }
+
+ public function test_not_found(): void
+ {
+ $this->expectException(Ex\NotFound::class);
+ HttpErrorMapper::throwIfError(new Response(404, [], 'nope'));
+ }
+
+ public function test_too_many_requests(): void
+ {
+ $this->expectException(Ex\TooManyRequests::class);
+ HttpErrorMapper::throwIfError(new Response(429, ['Retry-After' => ['2']], 'rl'));
+ }
+
+ public function test_server_error(): void
+ {
+ $this->expectException(Ex\ServerError::class);
+ HttpErrorMapper::throwIfError(new Response(503, [], 'maintenance'));
+ }
+
+ public function test_ok_no_exception(): void
+ {
+ HttpErrorMapper::throwIfError(new Response(200, [], 'ok'));
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Http/Psr17FactoryAggregate.php b/tests/Http/Psr17FactoryAggregate.php
new file mode 100644
index 0000000..f366002
--- /dev/null
+++ b/tests/Http/Psr17FactoryAggregate.php
@@ -0,0 +1,28 @@
+create(
+ 'POST',
+ 'https://example.test/api',
+ ['X-Foo' => ['A', 'B'], 'Content-Type' => 'application/json'],
+ '{"a":1}'
+ );
+
+ $this->assertSame('POST', $req->getMethod());
+ $this->assertSame(['A','B'], $req->getHeader('X-Foo'));
+ $this->assertSame('application/json', $req->getHeaderLine('Content-Type'));
+ $this->assertSame('{"a":1}', (string)$req->getBody());
+ }
+}
diff --git a/tests/Http/RetryingClientTest.php b/tests/Http/RetryingClientTest.php
new file mode 100644
index 0000000..539f867
--- /dev/null
+++ b/tests/Http/RetryingClientTest.php
@@ -0,0 +1,64 @@
+queue = [
+ new Response(429, ['Retry-After' => '0'], 'rate'),
+ new Response(200, [], '{"ok":true}'),
+ ];
+
+ $client = new RetryingClient($fake, 3, 1);
+ $req = (new Psr17Factory())->createRequest('GET', 'https://x');
+
+ $resp = $client->sendRequest($req);
+
+ $this->assertSame(200, $resp->getStatusCode());
+ $this->assertSame('{"ok":true}', (string)$resp->getBody());
+ }
+
+ public function test_retries_on_500_then_succeeds(): void
+ {
+ $fake = new FakeClient();
+ $fake->queue = [
+ new Response(500, [], 'err'),
+ new Response(200, [], 'ok'),
+ ];
+
+ $client = new RetryingClient($fake, 3, 1);
+ $req = (new Psr17Factory())->createRequest('GET', 'https://x');
+
+ $resp = $client->sendRequest($req);
+
+ $this->assertSame(200, $resp->getStatusCode());
+ $this->assertSame('ok', (string)$resp->getBody());
+ }
+
+ public function test_stops_after_max_attempts(): void
+ {
+ $fake = new FakeClient();
+ $fake->queue = [
+ new Response(500, [], 'e1'),
+ new Response(500, [], 'e2'),
+ new Response(500, [], 'e3'),
+ ];
+
+ $client = new RetryingClient($fake, 3, 1);
+ $req = (new Psr17Factory())->createRequest('GET', 'https://x');
+
+ $resp = $client->sendRequest($req);
+
+ $this->assertSame(500, $resp->getStatusCode());
+ $this->assertSame('e3', (string)$resp->getBody());
+ }
+}
diff --git a/tests/Support/PsrTestHelper.php b/tests/Support/PsrTestHelper.php
new file mode 100644
index 0000000..0c332e3
--- /dev/null
+++ b/tests/Support/PsrTestHelper.php
@@ -0,0 +1,35 @@
+,2:Psr18MockClient} */
+ private function makeBridgeAndMock(): array
+ {
+ $psr17 = new Psr17Factory();
+ $factories = new Psr17FactoryAggregate($psr17, $psr17);
+
+ $options = [
+ 'protocol' => 'https',
+ 'host' => 'connect.mailerlite.com',
+ 'api_path' => 'api',
+ 'api_key' => 'TEST_KEY',
+ ];
+
+ $psr18 = new Psr18MockClient();
+ $client = new GuzzleClientAdapter($psr18);
+
+ $psrLayer = new HttpLayerPsr($client, $factories, 'TEST_KEY', 'https://connect.mailerlite.com');
+ $bridge = new HttpLayerPsrBridge($psrLayer);
+
+ return [$bridge, $options, $psr18];
+ }
+}