Skip to content

Commit c5560a2

Browse files
Add action responses and default SDK request headers
Adds ActionResponse for account and account replica creation so callers can distinguish 201 Created from 202 Accepted and inspect Retry-After metadata. Also applies MetaApi authentication headers at the SDK request layer, including when a custom Guzzle client is injected, while avoiding forced JSON content type for file uploads.
2 parents e51769d + 4c625b5 commit c5560a2

8 files changed

Lines changed: 267 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ try {
4848

4949
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.
5050

51+
The SDK applies MetaApi authentication headers automatically, including when you inject a custom Guzzle client.
52+
5153
## Regions
5254

5355
Account Management uses MetaApi's global provisioning URL and does not require a region.
@@ -83,7 +85,7 @@ Create an account:
8385

8486
```php
8587
try {
86-
$account = $accounts->create([
88+
$response = $accounts->create([
8789
'login' => '123456',
8890
'password' => 'password',
8991
'name' => 'Main MT5 account',
@@ -92,11 +94,33 @@ try {
9294
'magic' => 123456,
9395
'type' => 'cloud-g2',
9496
]);
97+
98+
if ($response->isCreated()) {
99+
echo "Account created: " . $response->id();
100+
}
101+
102+
if ($response->isAccepted()) {
103+
echo "Account creation accepted. Retry after: " . ($response->retryAfter() ?? 'not specified');
104+
}
95105
} catch (MetaApiException $exception) {
96106
echo $exception->getMessage();
97107
}
98108
```
99109

110+
`accounts()->create()` returns an `ActionResponse` because MetaApi can respond with `201 Created` or `202 Accepted`.
111+
112+
```php
113+
$response->body();
114+
$response->statusCode();
115+
$response->headers();
116+
$response->retryAfter();
117+
$response->isCreated();
118+
$response->isAccepted();
119+
$response->shouldRetry();
120+
$response->id();
121+
$response->state();
122+
```
123+
100124
Read accounts:
101125

102126
```php
@@ -184,21 +208,25 @@ $replicas = $metaapi->accountReplicas();
184208
```
185209

186210
```php
187-
$replica = $replicas->createReplica(
211+
$response = $replicas->createReplica(
188212
'primary-account-id',
189213
[
190214
'magic' => 123456,
191215
'region' => 'london',
192216
],
193217
transactionId: bin2hex(random_bytes(16))
194218
);
219+
220+
if ($response->shouldRetry()) {
221+
echo "Replica creation accepted. Retry after: " . ($response->retryAfter() ?? 'not specified');
222+
}
195223
```
196224

197225
Available methods:
198226

199227
- `replicas(string $accountId)`
200228
- `replica(string $accountId, string $replicaId)`
201-
- `createReplica(string $accountId, array $data, ?string $transactionId = null)`
229+
- `createReplica(string $accountId, array $data, ?string $transactionId = null)` returns `ActionResponse`
202230
- `updateReplica(string $accountId, string $replicaId, array $data)`
203231
- `undeployReplica(string $accountId, string $replicaId)`
204232
- `deployReplica(string $accountId, string $replicaId)`

‎src/Http.php‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use GuzzleHttp\ClientInterface;
77
use Psr\Http\Message\ResponseInterface;
88
use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException;
9+
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;
910

1011
class Http
1112
{
@@ -16,11 +17,6 @@ public function __construct(protected string $token, protected string $baseUrl,
1617
$this->client = $client ?? new Client([
1718
'base_uri' => $this->baseUrl,
1819
'http_errors' => false,
19-
'headers' => [
20-
'auth-token' => $this->token,
21-
'Content-Type' => 'application/json',
22-
'Accept' => 'application/json',
23-
],
2420
]);
2521
}
2622

@@ -34,6 +30,11 @@ public function post(string $uri, array $payload = [], array $headers = [], arra
3430
return $this->request('POST', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]);
3531
}
3632

33+
public function postAction(string $uri, array $payload = [], array $headers = [], array $query = []): ActionResponse
34+
{
35+
return $this->requestAction('POST', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]);
36+
}
37+
3738
public function put(string $uri, array $payload = [], array $headers = [], array $query = []): array|string|null
3839
{
3940
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
9495
return $decoded;
9596
}
9697

98+
public function requestAction(string $verb, string $uri, array $options = []): ActionResponse
99+
{
100+
$options = $this->normalizeOptions($options);
101+
$response = $this->client->request($verb, $this->resolveUri($uri), $options);
102+
103+
if (!$this->isSuccessful($response)) {
104+
return $this->handleError($response);
105+
}
106+
107+
return new ActionResponse(
108+
$this->decodeBody($response),
109+
$response->getStatusCode(),
110+
$response->getHeaders()
111+
);
112+
}
113+
97114
public function isSuccessful(?ResponseInterface $response): bool
98115
{
99116
if (!$response) {
@@ -118,6 +135,11 @@ public function handleError(ResponseInterface $response): void
118135

119136
private function normalizeOptions(array $options): array
120137
{
138+
$options['headers'] = array_merge(
139+
$this->defaultHeaders($options),
140+
$options['headers'] ?? []
141+
);
142+
121143
foreach (['headers', 'query', 'json'] as $key) {
122144
if (isset($options[$key]) && $options[$key] === []) {
123145
unset($options[$key]);
@@ -131,6 +153,20 @@ private function normalizeOptions(array $options): array
131153
return $options;
132154
}
133155

156+
private function defaultHeaders(array $options): array
157+
{
158+
$headers = [
159+
'auth-token' => $this->token,
160+
'Accept' => 'application/json',
161+
];
162+
163+
if (array_key_exists('json', $options)) {
164+
$headers['Content-Type'] = 'application/json';
165+
}
166+
167+
return $headers;
168+
}
169+
134170
private function resolveUri(string $uri): string
135171
{
136172
if (str_starts_with($uri, 'http://') || str_starts_with($uri, 'https://')) {
@@ -153,4 +189,21 @@ private function normalizeQuery(array $query): array
153189
static fn(mixed $value): bool => $value !== null
154190
);
155191
}
192+
193+
private function decodeBody(ResponseInterface $response): array|string|null
194+
{
195+
$body = (string) $response->getBody();
196+
197+
if ($body === '') {
198+
return null;
199+
}
200+
201+
$decoded = json_decode($body, true);
202+
203+
if (json_last_error() !== JSON_ERROR_NONE) {
204+
return $body;
205+
}
206+
207+
return $decoded;
208+
}
156209
}

‎src/Resources/AccountManagement/Account.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement;
44

55
use Victorycodedev\MetaapiCloudPhpSdk\Http;
6+
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;
67

78
class Account
89
{
@@ -18,9 +19,9 @@ public function accounts(array $filters = [], ?int $apiVersion = null): array|st
1819
return $this->http->get('/users/current/accounts', $filters, $this->apiVersionHeader($apiVersion));
1920
}
2021

21-
public function create(array $data, ?string $transactionId = null): array|string|null
22+
public function create(array $data, ?string $transactionId = null): ActionResponse
2223
{
23-
return $this->http->post('/users/current/accounts', $data, $this->transactionHeader($transactionId));
24+
return $this->http->postAction('/users/current/accounts', $data, $this->transactionHeader($transactionId));
2425
}
2526

2627
public function update(string $accountId, array $data): array|string|null

‎src/Resources/AccountManagement/AccountReplica.php‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement;
44

55
use Victorycodedev\MetaapiCloudPhpSdk\Http;
6+
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;
67

78
class AccountReplica
89
{
9-
public function __construct(private readonly Http $http)
10-
{
11-
}
10+
public function __construct(private readonly Http $http) {}
1211

1312
public function replicas(string $accountId): array|string|null
1413
{
@@ -20,9 +19,9 @@ public function replica(string $accountId, string $replicaId): array|string|null
2019
return $this->http->get("/users/current/accounts/{$accountId}/replicas/{$replicaId}");
2120
}
2221

23-
public function createReplica(string $accountId, array $data, ?string $transactionId = null): array|string|null
22+
public function createReplica(string $accountId, array $data, ?string $transactionId = null): ActionResponse
2423
{
25-
return $this->http->post(
24+
return $this->http->postAction(
2625
"/users/current/accounts/{$accountId}/replicas",
2726
$data,
2827
$transactionId ? ['transaction-id' => $transactionId] : []

‎src/Responses/ActionResponse.php‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Victorycodedev\MetaapiCloudPhpSdk\Responses;
4+
5+
class ActionResponse
6+
{
7+
public function __construct(
8+
private readonly array|string|null $body,
9+
private readonly int $statusCode,
10+
private readonly array $headers = []
11+
) {}
12+
13+
public function body(): array|string|null
14+
{
15+
return $this->body;
16+
}
17+
18+
public function statusCode(): int
19+
{
20+
return $this->statusCode;
21+
}
22+
23+
public function headers(): array
24+
{
25+
return $this->headers;
26+
}
27+
28+
public function retryAfter(): ?string
29+
{
30+
return $this->header('Retry-After') ?? $this->header('retry-after');
31+
}
32+
33+
public function isCreated(): bool
34+
{
35+
return $this->statusCode === 201;
36+
}
37+
38+
public function isAccepted(): bool
39+
{
40+
return $this->statusCode === 202;
41+
}
42+
43+
public function shouldRetry(): bool
44+
{
45+
return $this->isAccepted() || $this->retryAfter() !== null;
46+
}
47+
48+
public function id(): ?string
49+
{
50+
return $this->field('id');
51+
}
52+
53+
public function state(): ?string
54+
{
55+
return $this->field('state');
56+
}
57+
58+
private function header(string $name): ?string
59+
{
60+
$values = $this->headers[$name] ?? null;
61+
62+
return is_array($values) && $values !== [] ? (string) $values[0] : null;
63+
}
64+
65+
private function field(string $name): ?string
66+
{
67+
return is_array($this->body) && isset($this->body[$name])
68+
? (string) $this->body[$name]
69+
: null;
70+
}
71+
}

‎tests/AccountManagementTest.php‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use GuzzleHttp\Psr7\Response;
4+
use Victorycodedev\MetaapiCloudPhpSdk\Responses\ActionResponse;
45

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

2627
$response = $metaapi->accounts()->create(['name' => 'Demo', 'server' => 'ICMarketsSC-Demo'], '12345678901234567890123456789012');
2728

28-
expect($response)->toBe(['id' => 'account-id', 'state' => 'DEPLOYED']);
29+
expect($response)->toBeInstanceOf(ActionResponse::class);
30+
expect($response->body())->toBe(['id' => 'account-id', 'state' => 'DEPLOYED']);
31+
expect($response->statusCode())->toBe(201);
32+
expect($response->isCreated())->toBeTrue();
33+
expect($response->isAccepted())->toBeFalse();
34+
expect($response->shouldRetry())->toBeFalse();
35+
expect($response->id())->toBe('account-id');
36+
expect($response->state())->toBe('DEPLOYED');
2937
expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts');
3038
expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('12345678901234567890123456789012');
3139
});
3240

41+
it('exposes accepted account creation retry metadata', function (): void {
42+
$metaapi = metaApiClientWithHistory([
43+
new Response(202, ['Retry-After' => '10'], '{"id":"account-id","state":"DRAFT"}'),
44+
]);
45+
46+
$response = $metaapi->accounts()->create(['name' => 'Demo']);
47+
48+
expect($response->isAccepted())->toBeTrue();
49+
expect($response->shouldRetry())->toBeTrue();
50+
expect($response->retryAfter())->toBe('10');
51+
expect($response->id())->toBe('account-id');
52+
expect($response->state())->toBe('DRAFT');
53+
});
54+
3355
it('deletes accounts using the account endpoint', function (): void {
3456
$history = [];
3557
$metaapi = metaApiClientWithHistory([new Response(204)], $history);
@@ -55,8 +77,25 @@
5577
$history = [];
5678
$metaapi = metaApiClientWithHistory([new Response(201, [], '{"id":"replica-id","state":"DEPLOYED"}')], $history);
5779

58-
$metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456, 'region' => 'london'], 'transaction-id');
80+
$response = $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456, 'region' => 'london'], 'transaction-id');
5981

82+
expect($response)->toBeInstanceOf(ActionResponse::class);
83+
expect($response->isCreated())->toBeTrue();
84+
expect($response->id())->toBe('replica-id');
6085
expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/replicas');
6186
expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('transaction-id');
6287
});
88+
89+
it('exposes accepted account replica creation retry metadata', function (): void {
90+
$metaapi = metaApiClientWithHistory([
91+
new Response(202, ['Retry-After' => '15'], '{"id":"replica-id","state":"DRAFT"}'),
92+
]);
93+
94+
$response = $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456]);
95+
96+
expect($response->isAccepted())->toBeTrue();
97+
expect($response->shouldRetry())->toBeTrue();
98+
expect($response->retryAfter())->toBe('15');
99+
expect($response->id())->toBe('replica-id');
100+
expect($response->state())->toBe('DRAFT');
101+
});

0 commit comments

Comments
 (0)