Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.37% covered (success)
94.37%
67 / 71
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiClient
94.29% covered (success)
94.29%
66 / 70
92.31% covered (success)
92.31%
12 / 13
30.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDispatcher
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getToken
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 loadTokenFromConfig
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
9.16
 resetTokenCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearRequestCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 post
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 put
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 createRequest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 decodeResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Settings\SqlSettingsRepository;
12use Nyholm\Psr7\Factory\Psr17Factory;
13use Psr\Http\Message\ResponseInterface;
14use Psr\Http\Message\ServerRequestInterface;
15use Psr\Http\Server\RequestHandlerInterface;
16
17/**
18 * High-Performance Internal & External REST API Client.
19 *
20 * Encapsulates REST API requests to /api/v1/* endpoints with automatic Bearer token
21 * authorization, request-scoped in-memory caching, and in-process direct dispatching.
22 *
23 * @package App\Core\Api
24 */
25final class ApiClient implements ApiClientInterface
26{
27    /** @var string JSON Content-Type header value. */
28    private const string JSON_MIME_TYPE = 'application/json';
29
30    /** @var string|null Cached system API token. */
31    private static ?string $cachedToken = null;
32
33    /** @var array<string, array<string, mixed>> Request-scoped query cache. */
34    private array $requestCache = [];
35
36    /**
37     * ApiClient constructor.
38     *
39     * @param SqlSettingsRepository|null $settingsRepository Settings repository instance.
40     * @param RequestHandlerInterface|null $dispatcher In-memory request handler dispatcher.
41     * @param Psr17Factory|null $psr17Factory PSR-17 factory instance.
42     */
43    public function __construct(
44        private ?SqlSettingsRepository $settingsRepository = null,
45        private ?RequestHandlerInterface $dispatcher = null,
46        private ?Psr17Factory $psr17Factory = null,
47        private ?string $configPath = null
48    ) {
49        $this->psr17Factory = $psr17Factory ?? new Psr17Factory();
50    }
51
52    /**
53     * Sets internal request dispatcher for in-process direct API execution.
54     *
55     * @param RequestHandlerInterface $dispatcher Internal dispatcher instance.
56     */
57    public function setDispatcher(RequestHandlerInterface $dispatcher): void
58    {
59        $this->dispatcher = $dispatcher;
60    }
61
62    /**
63     * Retrieves Bearer API token with priority: config file -> settings repo.
64     *
65     * @return string System Bearer token.
66     */
67    public function getToken(): string
68    {
69        if (self::$cachedToken !== null) {
70            return self::$cachedToken;
71        }
72
73        $configToken = $this->loadTokenFromConfig();
74        if ($configToken !== null) {
75            self::$cachedToken = $configToken;
76            return self::$cachedToken;
77        }
78
79        self::$cachedToken = $this->settingsRepository !== null
80            ? $this->settingsRepository->get('system_api_token', '')
81            : '';
82
83        return self::$cachedToken;
84    }
85
86    /**
87     * Loads system API token from config/common/api_auth.php if present.
88     *
89     * @return string|null Token string if found, null otherwise.
90     */
91    private function loadTokenFromConfig(): ?string
92    {
93        if ($this->configPath !== null) {
94            if (!file_exists($this->configPath)) {
95                return null;
96            }
97            $config = (static fn(string $file): mixed => require_once $file)($this->configPath);
98            return (is_array($config) && !empty($config['system_api_token']))
99                ? (string) $config['system_api_token']
100                : null;
101        }
102
103        $token = \App\Shared\Infrastructure\Config\ApiAuthConfigLoader::getSystemApiToken();
104        return $token !== '' ? $token : null;
105    }
106
107    /**
108     * Resets in-memory cached token.
109     */
110    public static function resetTokenCache(): void
111    {
112        self::$cachedToken = null;
113    }
114
115    /**
116     * Clears request-scoped in-memory cache.
117     */
118    public function clearRequestCache(): void
119    {
120        $this->requestCache = [];
121    }
122
123    /**
124     * {@inheritdoc}
125     */
126    public function get(string $path, array $queryParams = [], bool $useCache = true): array
127    {
128        $cacheKey = $path . '?' . http_build_query($queryParams);
129        if ($useCache && isset($this->requestCache[$cacheKey])) {
130            return $this->requestCache[$cacheKey];
131        }
132
133        $request = $this->createRequest('GET', $path, $queryParams);
134        $response = $this->send($request);
135        $data = $this->decodeResponse($response);
136
137        if ($useCache && $response->getStatusCode() === 200) {
138            $this->requestCache[$cacheKey] = $data;
139        }
140
141        return $data;
142    }
143
144    /**
145     * {@inheritdoc}
146     */
147    public function post(string $path, array $body = []): array
148    {
149        $request = $this->createRequest('POST', $path, [], $body);
150        $response = $this->send($request);
151        return $this->decodeResponse($response);
152    }
153
154    /**
155     * {@inheritdoc}
156     */
157    public function put(string $path, array $body = []): array
158    {
159        $request = $this->createRequest('PUT', $path, [], $body);
160        $response = $this->send($request);
161        return $this->decodeResponse($response);
162    }
163
164    /**
165     * {@inheritdoc}
166     */
167    public function delete(string $path, array $body = []): array
168    {
169        $request = $this->createRequest('DELETE', $path, [], $body);
170        $response = $this->send($request);
171        return $this->decodeResponse($response);
172    }
173
174    /**
175     * {@inheritdoc}
176     */
177    public function send(ServerRequestInterface $request): ResponseInterface
178    {
179        if ($this->dispatcher !== null) {
180            return $this->dispatcher->handle($request);
181        }
182
183        $factory = $this->psr17Factory ?? new Psr17Factory();
184        $response = $factory->createResponse(500);
185        $response->getBody()->write((string)json_encode([
186            'status' => false,
187            'error'  => 'No API dispatcher configured.',
188        ]));
189        return $response->withHeader('Content-Type', self::JSON_MIME_TYPE);
190    }
191
192    /**
193     * Creates authenticated PSR-7 ServerRequest for API path.
194     *
195     * @param string $method HTTP method.
196     * @param string $path Endpoint path.
197     * @param array<string, mixed> $queryParams Query parameters.
198     * @param array<string, mixed>|null $body Payload body.
199     * @return ServerRequestInterface Built PSR-7 request.
200     */
201    private function createRequest(
202        string $method,
203        string $path,
204        array $queryParams = [],
205        ?array $body = null
206    ): ServerRequestInterface {
207        $factory = $this->psr17Factory ?? new Psr17Factory();
208        $uri = $path;
209        if (!empty($queryParams)) {
210            $uri .= '?' . http_build_query($queryParams);
211        }
212
213        $token = $this->getToken();
214        $request = $factory->createServerRequest($method, $uri)
215            ->withHeader('Authorization', 'Bearer ' . $token)
216            ->withHeader('Accept', self::JSON_MIME_TYPE)
217            ->withQueryParams($queryParams);
218
219        if ($body !== null) {
220            $json = (string)json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
221            $request->getBody()->write($json);
222            $request = $request->withHeader('Content-Type', self::JSON_MIME_TYPE)
223                ->withParsedBody($body);
224        }
225
226        return $request;
227    }
228
229    /**
230     * Decodes PSR-7 response body to array.
231     *
232     * @param ResponseInterface $response PSR-7 Response.
233     * @return array<string, mixed> Decoded array.
234     */
235    private function decodeResponse(ResponseInterface $response): array
236    {
237        $body = (string)$response->getBody();
238        if ($body === '') {
239            return [];
240        }
241
242        /** @var mixed $data */
243        $data = json_decode($body, true);
244        return is_array($data) ? $data : ['raw' => $body];
245    }
246}