diff --git a/README.md b/README.md index a139ec7..368660b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ try { All failed MetaApi responses throw `MetaApiException`. The exception includes the HTTP status code, parsed MetaApi error response, response headers, error id, error name, details and retry-after header when available. +The SDK applies MetaApi authentication headers automatically, including when you inject a custom Guzzle client. + ## Regions Account Management uses MetaApi's global provisioning URL and does not require a region. @@ -83,7 +85,7 @@ Create an account: ```php try { - $account = $accounts->create([ + $response = $accounts->create([ 'login' => '123456', 'password' => 'password', 'name' => 'Main MT5 account', @@ -92,11 +94,33 @@ try { 'magic' => 123456, 'type' => 'cloud-g2', ]); + + if ($response->isCreated()) { + echo "Account created: " . $response->id(); + } + + if ($response->isAccepted()) { + echo "Account creation accepted. Retry after: " . ($response->retryAfter() ?? 'not specified'); + } } catch (MetaApiException $exception) { echo $exception->getMessage(); } ``` +`accounts()->create()` returns an `ActionResponse` because MetaApi can respond with `201 Created` or `202 Accepted`. + +```php +$response->body(); +$response->statusCode(); +$response->headers(); +$response->retryAfter(); +$response->isCreated(); +$response->isAccepted(); +$response->shouldRetry(); +$response->id(); +$response->state(); +``` + Read accounts: ```php @@ -184,7 +208,7 @@ $replicas = $metaapi->accountReplicas(); ``` ```php -$replica = $replicas->createReplica( +$response = $replicas->createReplica( 'primary-account-id', [ 'magic' => 123456, @@ -192,13 +216,17 @@ $replica = $replicas->createReplica( ], transactionId: bin2hex(random_bytes(16)) ); + +if ($response->shouldRetry()) { + echo "Replica creation accepted. Retry after: " . ($response->retryAfter() ?? 'not specified'); +} ``` Available methods: - `replicas(string $accountId)` - `replica(string $accountId, string $replicaId)` -- `createReplica(string $accountId, array $data, ?string $transactionId = null)` +- `createReplica(string $accountId, array $data, ?string $transactionId = null)` returns `ActionResponse` - `updateReplica(string $accountId, string $replicaId, array $data)` - `undeployReplica(string $accountId, string $replicaId)` - `deployReplica(string $accountId, string $replicaId)` diff --git a/src/Http.php b/src/Http.php index baca95f..582bf4a 100644 --- a/src/Http.php +++ b/src/Http.php @@ -6,6 +6,7 @@ use GuzzleHttp\ClientInterface; use Psr\Http\Message\ResponseInterface; use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException; +use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse; class Http { @@ -16,11 +17,6 @@ public function __construct(protected string $token, protected string $baseUrl, $this->client = $client ?? new Client([ 'base_uri' => $this->baseUrl, 'http_errors' => false, - 'headers' => [ - 'auth-token' => $this->token, - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ], ]); } @@ -34,6 +30,11 @@ public function post(string $uri, array $payload = [], array $headers = [], arra return $this->request('POST', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); } + public function postAction(string $uri, array $payload = [], array $headers = [], array $query = []): ActionResponse + { + return $this->requestAction('POST', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); + } + public function put(string $uri, array $payload = [], array $headers = [], array $query = []): array|string|null { return $this->request('PUT', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); @@ -94,6 +95,22 @@ public function request(string $verb, string $uri, array $options = []): array|s return $decoded; } + public function requestAction(string $verb, string $uri, array $options = []): ActionResponse + { + $options = $this->normalizeOptions($options); + $response = $this->client->request($verb, $this->resolveUri($uri), $options); + + if (!$this->isSuccessful($response)) { + return $this->handleError($response); + } + + return new ActionResponse( + $this->decodeBody($response), + $response->getStatusCode(), + $response->getHeaders() + ); + } + public function isSuccessful(?ResponseInterface $response): bool { if (!$response) { @@ -118,6 +135,11 @@ public function handleError(ResponseInterface $response): void private function normalizeOptions(array $options): array { + $options['headers'] = array_merge( + $this->defaultHeaders($options), + $options['headers'] ?? [] + ); + foreach (['headers', 'query', 'json'] as $key) { if (isset($options[$key]) && $options[$key] === []) { unset($options[$key]); @@ -131,6 +153,20 @@ private function normalizeOptions(array $options): array return $options; } + private function defaultHeaders(array $options): array + { + $headers = [ + 'auth-token' => $this->token, + 'Accept' => 'application/json', + ]; + + if (array_key_exists('json', $options)) { + $headers['Content-Type'] = 'application/json'; + } + + return $headers; + } + private function resolveUri(string $uri): string { if (str_starts_with($uri, 'http://') || str_starts_with($uri, 'https://')) { @@ -153,4 +189,21 @@ private function normalizeQuery(array $query): array static fn(mixed $value): bool => $value !== null ); } + + private function decodeBody(ResponseInterface $response): array|string|null + { + $body = (string) $response->getBody(); + + if ($body === '') { + return null; + } + + $decoded = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return $body; + } + + return $decoded; + } } diff --git a/src/Resources/AccountManagement/Account.php b/src/Resources/AccountManagement/Account.php index feec126..10f1545 100644 --- a/src/Resources/AccountManagement/Account.php +++ b/src/Resources/AccountManagement/Account.php @@ -3,6 +3,7 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement; use Victorycodedev\MetaapiCloudPhpSdk\Http; +use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse; class Account { @@ -18,9 +19,9 @@ public function accounts(array $filters = [], ?int $apiVersion = null): array|st return $this->http->get('/users/current/accounts', $filters, $this->apiVersionHeader($apiVersion)); } - public function create(array $data, ?string $transactionId = null): array|string|null + public function create(array $data, ?string $transactionId = null): ActionResponse { - return $this->http->post('/users/current/accounts', $data, $this->transactionHeader($transactionId)); + return $this->http->postAction('/users/current/accounts', $data, $this->transactionHeader($transactionId)); } public function update(string $accountId, array $data): array|string|null diff --git a/src/Resources/AccountManagement/AccountReplica.php b/src/Resources/AccountManagement/AccountReplica.php index d3503a8..5af8bd0 100644 --- a/src/Resources/AccountManagement/AccountReplica.php +++ b/src/Resources/AccountManagement/AccountReplica.php @@ -3,12 +3,11 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement; use Victorycodedev\MetaapiCloudPhpSdk\Http; +use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse; class AccountReplica { - public function __construct(private readonly Http $http) - { - } + public function __construct(private readonly Http $http) {} public function replicas(string $accountId): array|string|null { @@ -20,9 +19,9 @@ public function replica(string $accountId, string $replicaId): array|string|null return $this->http->get("/users/current/accounts/{$accountId}/replicas/{$replicaId}"); } - public function createReplica(string $accountId, array $data, ?string $transactionId = null): array|string|null + public function createReplica(string $accountId, array $data, ?string $transactionId = null): ActionResponse { - return $this->http->post( + return $this->http->postAction( "/users/current/accounts/{$accountId}/replicas", $data, $transactionId ? ['transaction-id' => $transactionId] : [] diff --git a/src/Responses/ActionResponse.php b/src/Responses/ActionResponse.php new file mode 100644 index 0000000..b8183de --- /dev/null +++ b/src/Responses/ActionResponse.php @@ -0,0 +1,71 @@ +body; + } + + public function statusCode(): int + { + return $this->statusCode; + } + + public function headers(): array + { + return $this->headers; + } + + public function retryAfter(): ?string + { + return $this->header('Retry-After') ?? $this->header('retry-after'); + } + + public function isCreated(): bool + { + return $this->statusCode === 201; + } + + public function isAccepted(): bool + { + return $this->statusCode === 202; + } + + public function shouldRetry(): bool + { + return $this->isAccepted() || $this->retryAfter() !== null; + } + + public function id(): ?string + { + return $this->field('id'); + } + + public function state(): ?string + { + return $this->field('state'); + } + + private function header(string $name): ?string + { + $values = $this->headers[$name] ?? null; + + return is_array($values) && $values !== [] ? (string) $values[0] : null; + } + + private function field(string $name): ?string + { + return is_array($this->body) && isset($this->body[$name]) + ? (string) $this->body[$name] + : null; + } +} diff --git a/tests/AccountManagementTest.php b/tests/AccountManagementTest.php index 9f8c9a8..2d9874b 100644 --- a/tests/AccountManagementTest.php +++ b/tests/AccountManagementTest.php @@ -1,6 +1,7 @@ accounts()->create(['name' => 'Demo', 'server' => 'ICMarketsSC-Demo'], '12345678901234567890123456789012'); - expect($response)->toBe(['id' => 'account-id', 'state' => 'DEPLOYED']); + expect($response)->toBeInstanceOf(ActionResponse::class); + expect($response->body())->toBe(['id' => 'account-id', 'state' => 'DEPLOYED']); + expect($response->statusCode())->toBe(201); + expect($response->isCreated())->toBeTrue(); + expect($response->isAccepted())->toBeFalse(); + expect($response->shouldRetry())->toBeFalse(); + expect($response->id())->toBe('account-id'); + expect($response->state())->toBe('DEPLOYED'); expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts'); expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('12345678901234567890123456789012'); }); +it('exposes accepted account creation retry metadata', function (): void { + $metaapi = metaApiClientWithHistory([ + new Response(202, ['Retry-After' => '10'], '{"id":"account-id","state":"DRAFT"}'), + ]); + + $response = $metaapi->accounts()->create(['name' => 'Demo']); + + expect($response->isAccepted())->toBeTrue(); + expect($response->shouldRetry())->toBeTrue(); + expect($response->retryAfter())->toBe('10'); + expect($response->id())->toBe('account-id'); + expect($response->state())->toBe('DRAFT'); +}); + it('deletes accounts using the account endpoint', function (): void { $history = []; $metaapi = metaApiClientWithHistory([new Response(204)], $history); @@ -55,8 +77,25 @@ $history = []; $metaapi = metaApiClientWithHistory([new Response(201, [], '{"id":"replica-id","state":"DEPLOYED"}')], $history); - $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456, 'region' => 'london'], 'transaction-id'); + $response = $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456, 'region' => 'london'], 'transaction-id'); + expect($response)->toBeInstanceOf(ActionResponse::class); + expect($response->isCreated())->toBeTrue(); + expect($response->id())->toBe('replica-id'); expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/replicas'); expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('transaction-id'); }); + +it('exposes accepted account replica creation retry metadata', function (): void { + $metaapi = metaApiClientWithHistory([ + new Response(202, ['Retry-After' => '15'], '{"id":"replica-id","state":"DRAFT"}'), + ]); + + $response = $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456]); + + expect($response->isAccepted())->toBeTrue(); + expect($response->shouldRetry())->toBeTrue(); + expect($response->retryAfter())->toBe('15'); + expect($response->id())->toBe('replica-id'); + expect($response->state())->toBe('DRAFT'); +}); diff --git a/tests/HttpTest.php b/tests/HttpTest.php index d7a9090..6d14174 100644 --- a/tests/HttpTest.php +++ b/tests/HttpTest.php @@ -3,6 +3,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException; +use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse; it('decodes json responses', function (): void { $http = mockHttp([ @@ -40,6 +41,61 @@ expect($query)->toContain('deploymentStatus%5B0%5D=deployed'); }); +it('applies default SDK headers when using a custom client', function (): void { + $history = []; + $http = mockHttp([ + new Response(200, [], '{"id":"account-id"}'), + ], $history); + + $http->post('/users/current/accounts', ['name' => 'Demo'], ['transaction-id' => 'transaction-id']); + + $request = $history[0]['request']; + + expect($request->getHeaderLine('auth-token'))->toBe('test-token'); + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + expect($request->getHeaderLine('Content-Type'))->toBe('application/json'); + expect($request->getHeaderLine('transaction-id'))->toBe('transaction-id'); +}); + +it('does not force json content type onto uploads', function (): void { + $history = []; + $http = mockHttp([ + new Response(204), + ], $history); + $file = tempnam(sys_get_temp_dir(), 'metaapi-sdk-test'); + file_put_contents($file, 'server-data'); + + try { + $http->upload('/users/current/provisioning-profiles/profile-id/servers.dat', $file); + } finally { + unlink($file); + } + + $request = $history[0]['request']; + + expect($request->getHeaderLine('auth-token'))->toBe('test-token'); + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + expect($request->getHeaderLine('Content-Type'))->not->toBe('application/json'); +}); + +it('can return action responses with status metadata', function (): void { + $http = mockHttp([ + new Response(202, ['Retry-After' => '5'], '{"id":"account-id","state":"DRAFT"}'), + ]); + + $response = $http->postAction('/users/current/accounts', ['name' => 'Demo']); + + expect($response)->toBeInstanceOf(ActionResponse::class); + expect($response->statusCode())->toBe(202); + expect($response->isAccepted())->toBeTrue(); + expect($response->isCreated())->toBeFalse(); + expect($response->shouldRetry())->toBeTrue(); + expect($response->retryAfter())->toBe('5'); + expect($response->id())->toBe('account-id'); + expect($response->state())->toBe('DRAFT'); + expect($response->body())->toBe(['id' => 'account-id', 'state' => 'DRAFT']); +}); + it('throws structured MetaApi exceptions', function (): void { $http = mockHttp([ new Response(400, [], '{"id":1,"error":"ValidationError","message":"Invalid account","details":{"code":"E_AUTH"}}'), diff --git a/tests/MetaApiClientTest.php b/tests/MetaApiClientTest.php index 37a6ed1..4aa8dd6 100644 --- a/tests/MetaApiClientTest.php +++ b/tests/MetaApiClientTest.php @@ -115,6 +115,8 @@ class CapturingClient implements ClientInterface { public string $lastUri = ''; + public array $lastOptions = []; + public function send(RequestInterface $request, array $options = []): ResponseInterface { $this->lastUri = (string) $request->getUri(); @@ -131,6 +133,7 @@ public function request($method, $uri = '', array $options = []): ResponseInterf { $query = isset($options['query']) ? '?' . http_build_query($options['query']) : ''; $this->lastUri = (string) $uri . $query; + $this->lastOptions = $options; return new Response(200, [], '[]'); }