Skip to content
Merged
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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -83,7 +85,7 @@ Create an account:

```php
try {
$account = $accounts->create([
$response = $accounts->create([
'login' => '123456',
'password' => 'password',
'name' => 'Main MT5 account',
Expand All @@ -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
Expand Down Expand Up @@ -184,21 +208,25 @@ $replicas = $metaapi->accountReplicas();
```

```php
$replica = $replicas->createReplica(
$response = $replicas->createReplica(
'primary-account-id',
[
'magic' => 123456,
'region' => 'london',
],
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)`
Expand Down
63 changes: 58 additions & 5 deletions src/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use GuzzleHttp\ClientInterface;
use Psr\Http\Message\ResponseInterface;
use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException;
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;

class Http
{
Expand All @@ -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',
],
]);
}

Expand All @@ -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]);
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);
Expand All @@ -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://')) {
Expand All @@ -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;
}
}
5 changes: 3 additions & 2 deletions src/Resources/AccountManagement/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement;

use Victorycodedev\MetaapiCloudPhpSdk\Http;
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;

class Account
{
Expand All @@ -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
Expand Down
9 changes: 4 additions & 5 deletions src/Resources/AccountManagement/AccountReplica.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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] : []
Expand Down
71 changes: 71 additions & 0 deletions src/Responses/ActionResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Victorycodedev\MetaapiCloudPhpSdk\Responses;

class ActionResponse
{
public function __construct(
private readonly array|string|null $body,
private readonly int $statusCode,
private readonly array $headers = []
) {}

public function body(): array|string|null
{
return $this->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;
}
}
43 changes: 41 additions & 2 deletions tests/AccountManagementTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use GuzzleHttp\Psr7\Response;
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;

it('reads accounts with filters and api version header', function (): void {
$history = [];
Expand All @@ -25,11 +26,32 @@

$response = $metaapi->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);
Expand All @@ -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');
});
Loading
Loading