Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Guidance for Claude Code in this repo. This file covers what rarely changes; dee
## V2 architecture

- **Facade**: `src/SyncV2Sdk.php`.
- **Config**: `src/Config/SyncConfigV2.php` — `appId` (must be a UUID), `apiUrl`, `token`, optional `targetIndex`.
- **Config**: `src/Config/SyncConfigV2.php` — `appId` (must be a UUID), `apiUrl`, `token`, optional `targetIndex`, `timeout` (30s), `connectTimeout` (10s), `retryPolicy`.
- **Transport**: `src/Client/HttpClient.php` builds an `HttpRequest`, hands it to a `Client\Transport\Transport` (default `CurlTransport`), and retries idempotent calls per `Client\RetryPolicy` (transport errors, 5xx, 429; equal-jitter exponential backoff). POST is non-idempotent unless the facade passes `idempotent: true` — only bulk-operations, V1 sync/delete-products and normalize do. Never flag a configuration POST idempotent. `TransportException extends ApiException` with status 0 for "no response at all". Tests inject a fake `Transport` and `Sleeper` (see `tests/Client/Support/`).
- **Endpoints**: `/api/v2/applications/{appId}/...`.
- **Payloads**: strict immutable readonly ValueObjects in `src/V2/ValueObjects/` (BulkOperations, Index, Normalize, Product, Response, Search, SearchSettings, Synonym, Common), each with constructor validation, a builder, and a `jsonSerialize()` verified against a fixture. See `src/V2/ValueObjects/CLAUDE.md` for the conventions.
- **Adapters**: `PrestaShopAdapterV2`, `MagentoAdapterV2` (GraphQL-fed via `src/Magento/`), `ShopifyAdapter` — transform platform product data into V2 payloads.
Expand Down Expand Up @@ -87,7 +88,7 @@ vendor/bin/phpstan analyse # level 4, src/ only (phpstan.neon); expect "[O
vendor/bin/phpcs src tests # PSR-12 (phpcs.xml); expect empty output / exit 0
```

`laravel/pint` is in require-dev but NOT wired into CI — phpcs is the authority. No Makefile, no docker-compose, no `.env`; tests are fully offline (HTTP is mocked).
`laravel/pint` is in require-dev but NOT wired into CI — phpcs is the authority. No Makefile, no docker-compose, no `.env`; tests are fully offline (HTTP is mocked at the facade level, or scripted through `tests/Client/Support/FakeTransport.php` at the transport level).

### Install
```bash
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ use BradSearch\SyncSdk\Models\FieldConfigBuilder;
$config = new SyncConfig(
baseUrl: 'https://your-api-endpoint.com',
authToken: 'your-auth-token',
timeout: 30,
timeout: 30, // total request timeout, seconds
connectTimeout: 10, // connection-establishment timeout, seconds
verifySSL: true
);

Expand Down Expand Up @@ -206,6 +207,34 @@ try {
}
```

`TransportException` (a subclass of `ApiException`) means no HTTP response arrived at all: connection refused, connect or read timeout, DNS or TLS failure. Its `statusCode` is `0` and `responseBody` is `null`.

## Timeouts and retries

Every request carries two timeouts from the config: `connectTimeout` (default 10s) bounds the TCP/TLS handshake, `timeout` (default 30s) bounds the whole request. Raise `timeout` for large bulk payloads; keep `connectTimeout` short so a stalled network fails fast.

Idempotent requests are retried automatically on transport errors, HTTP 5xx and 429 with jittered exponential backoff (1s doubling to an 8s cap, 3 attempts by default). Idempotent means GET, PUT, DELETE, PATCH and the bulk-style POSTs (`bulk-operations`, V1 `sync/` and `delete-products`, `normalize`). Configuration POSTs (`configuration`, `configuration/refresh`, `synonyms`, `index`, `index/activate`, V1 `reindex`) are never retried by the SDK. After the last attempt the exception from the final response is thrown, with its status code and body intact.

Tune or disable the budget per config:

```php
use BradSearch\SyncSdk\Client\RetryPolicy;

new SyncConfigV2(
appId: $appId,
apiUrl: $apiUrl,
token: $token,
timeout: 120,
connectTimeout: 10,
retryPolicy: new RetryPolicy(maxAttempts: 3, baseDelaySeconds: 1.0, maxDelaySeconds: 8.0),
);

// Caller owns every retry decision:
new SyncConfig($baseUrl, $token, retryPolicy: RetryPolicy::none());
```

For tests, pass an implementation of `BradSearch\SyncSdk\Client\Transport\Transport` as the second constructor argument of `SyncV2Sdk` or `AdminSdk` to script responses without touching the network.

## Advanced Usage

### Field Filtering
Expand Down
8 changes: 6 additions & 2 deletions src/AdminSdk.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace BradSearch\SyncSdk;

use BradSearch\SyncSdk\Client\AdminHttpClient;
use BradSearch\SyncSdk\Client\Transport\Transport;
use BradSearch\SyncSdk\Config\SyncConfig;
use BradSearch\SyncSdk\V2\ValueObjects\Response\AllIndicesResponse;

Expand All @@ -18,9 +19,12 @@ class AdminSdk
{
private readonly AdminHttpClient $httpClient;

public function __construct(SyncConfig $config)
/**
* @param Transport|null $transport Override the HTTP transport (tests, custom clients); null uses cURL
*/
public function __construct(SyncConfig $config, ?Transport $transport = null)
{
$this->httpClient = new AdminHttpClient($config);
$this->httpClient = new AdminHttpClient($config, $transport);
}

/**
Expand Down
89 changes: 12 additions & 77 deletions src/Client/AdminHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,99 +4,34 @@

namespace BradSearch\SyncSdk\Client;

use BradSearch\SyncSdk\Client\Transport\Sleeper;
use BradSearch\SyncSdk\Client\Transport\Transport;
use BradSearch\SyncSdk\Config\SyncConfig;
use BradSearch\SyncSdk\Exceptions\ApiException;

/**
* HTTP client for admin operations that includes the X-Admin-Action header.
*
* Thin wrapper over HttpClient so admin calls share its timeouts and retry policy.
*/
class AdminHttpClient
{
private readonly HttpClient $httpClient;

public function __construct(
private readonly SyncConfig $config
SyncConfig $config,
?Transport $transport = null,
?Sleeper $sleeper = null,
) {
$this->httpClient = new HttpClient($config, $transport, $sleeper, ['X-Admin-Action: true']);
}

public function get(string $endpoint): array
{
return $this->request('GET', $endpoint);
return $this->httpClient->get($endpoint);
}

public function delete(string $endpoint): array
{
return $this->request('DELETE', $endpoint);
}

private function request(string $method, string $endpoint, ?array $data = null): array
{
$curl = curl_init();

if ($curl === false) {
throw new ApiException('Failed to initialize cURL');
}

try {
$url = rtrim($this->config->baseUrl, '/') . '/' . ltrim($endpoint, '/');

$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->config->timeout,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->authToken,
'X-Admin-Action: true',
],
CURLOPT_SSL_VERIFYPEER => $this->config->verifySSL,
CURLOPT_SSL_VERIFYHOST => $this->config->verifySSL ? 2 : 0,
];

if ($data !== null) {
$json = json_encode($data, JSON_THROW_ON_ERROR);
$options[CURLOPT_POSTFIELDS] = $json;
}

curl_setopt_array($curl, $options);

$response = curl_exec($curl);

if ($response === false) {
$error = curl_error($curl);
throw new ApiException("cURL error: {$error}");
}

$statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);

if (!is_string($response)) {
throw new ApiException('Invalid response from server');
}

if ($statusCode < 200 || $statusCode >= 300) {
throw new ApiException(
"API request failed with status {$statusCode}",
$statusCode,
$response
);
}

if (empty($response)) {
return [];
}

try {
$decoded = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new ApiException("Failed to decode JSON response: {$e->getMessage()}", $statusCode, $response);
}

if (!is_array($decoded)) {
throw new ApiException('Expected JSON object in response', $statusCode, $response);
}

return $decoded;
} finally {
curl_close($curl);
}
return $this->httpClient->delete($endpoint);
}
}
Loading
Loading