Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.37% |
67 / 71 |
|
92.31% |
12 / 13 |
CRAP | |
0.00% |
0 / 1 |
| ApiClient | |
94.29% |
66 / 70 |
|
92.31% |
12 / 13 |
30.17 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setDispatcher | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getToken | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| loadTokenFromConfig | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
9.16 | |||
| resetTokenCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| clearRequestCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| post | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| put | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| delete | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| send | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| createRequest | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
| decodeResponse | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Api; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Settings\SqlSettingsRepository; |
| 12 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 13 | use Psr\Http\Message\ResponseInterface; |
| 14 | use Psr\Http\Message\ServerRequestInterface; |
| 15 | use 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 | */ |
| 25 | final 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 | } |