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]; + } +}