Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.52% covered (success)
96.52%
111 / 115
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiResponseTrait
97.37% covered (success)
97.37%
111 / 114
83.33% covered (warning)
83.33%
10 / 12
55
0.00% covered (danger)
0.00%
0 / 1
 jsonSuccess
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 jsonError
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
12
 jsonValidation
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 buildJsonResponse
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 parseJsonBody
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 resolveClientIp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 jsonCached
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 jsonResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 jsonStatusResponse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 handleApiException
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveCurrentUserId
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
10.20
 resolveBaseUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
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\Shared\Infrastructure\Http;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Presentation\Exception\ApiExceptionResponseHandler;
12use Nyholm\Psr7\Factory\Psr17Factory;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Psr\Http\Message\StreamFactoryInterface;
17use Throwable;
18use Yiisoft\DataResponse\DataResponseFactory;
19use Yiisoft\DataResponse\Formatter\JsonDataResponseFormatter;
20
21/**
22 * Universal PSR-7 API Response and Request Parsing Trait (RFC 9457 / RFC 7807).
23 *
24 * Provides standardized JSON response formatting using Yii3 DataResponse pipeline,
25 * Problem Details specification, and HTTP request payload extraction across API controllers.
26 *
27 * @package App\Shared\Infrastructure\Http
28 */
29trait ApiResponseTrait
30{
31    /** @var string JSON Content-Type header value. */
32    protected const string JSON_CONTENT_TYPE = 'application/json';
33
34    /**
35     * Builds a standardized successful JSON response.
36     *
37     * @param ResponseFactoryInterface $factory    PSR-7 Response factory.
38     * @param mixed                    $data       Response payload.
39     * @param int                      $statusCode HTTP status code (default 200).
40     * @return ResponseInterface Formatted JSON response.
41     */
42    protected function jsonSuccess(
43        ResponseFactoryInterface $factory,
44        mixed $data = null,
45        int $statusCode = 200
46    ): ResponseInterface {
47        return $this->buildJsonResponse($factory, [
48            'success' => true,
49            'status'  => true,
50            'data'    => $data,
51        ], $statusCode);
52    }
53
54    /**
55     * Builds a standardized error JSON response adhering to RFC 9457 Problem Details.
56     *
57     * @param ResponseFactoryInterface $factory    PSR-7 Response factory.
58     * @param string                   $message    Error message string.
59     * @param int                      $statusCode HTTP status code (default 400).
60     * @param mixed                    $data       Optional extra data.
61     * @return ResponseInterface Formatted JSON response.
62     */
63    protected function jsonError(
64        ResponseFactoryInterface $factory,
65        string $message,
66        int $statusCode = 400,
67        mixed $data = null
68    ): ResponseInterface {
69        $slug = match ($statusCode) {
70            400     => 'bad-request',
71            401     => 'unauthorized',
72            403     => 'forbidden',
73            404     => 'not-found',
74            405     => 'method-not-allowed',
75            409     => 'conflict',
76            413     => 'payload-too-large',
77            415     => 'unsupported-media-type',
78            422     => 'unprocessable-entity',
79            429     => 'rate-limit-exceeded',
80            default => 'internal-server-error',
81        };
82
83        return $this->buildJsonResponse($factory, [
84            'type'    => 'https://app-admin.ammonly.com/api/errors/' . $slug,
85            'title'   => $message,
86            'status'  => false,
87            'success' => false,
88            'error'   => $message,
89            'message' => $message,
90            'code'    => $statusCode,
91            'data'    => $data,
92        ], $statusCode);
93    }
94
95    /**
96     * Builds a standardized 422 validation failure response (RFC 9457 Problem Details).
97     *
98     * @param ResponseFactoryInterface $factory PSR-7 Response factory.
99     * @param array<string, mixed>     $errors  Validation error dictionary.
100     * @return ResponseInterface Formatted JSON response.
101     */
102    protected function jsonValidation(
103        ResponseFactoryInterface $factory,
104        array $errors
105    ): ResponseInterface {
106        $msg = 'Validation failed';
107        return $this->buildJsonResponse($factory, [
108            'type'           => 'https://app-admin.ammonly.com/api/errors/validation-error',
109            'title'          => $msg,
110            'status'         => false,
111            'success'        => false,
112            'error'          => $msg,
113            'message'        => $msg,
114            'code'           => 422,
115            'invalid_params' => $errors,
116            'errors'         => $errors,
117        ], 422);
118    }
119
120    /**
121     * Encodes payload array into PSR-7 JSON response.
122     *
123     * @param ResponseFactoryInterface $factory    PSR-7 Response factory.
124     * @param array<string, mixed>     $payload    Data map.
125     * @param int                      $statusCode HTTP status code.
126     * @return ResponseInterface PSR-7 response.
127     */
128    protected function buildJsonResponse(
129        ResponseFactoryInterface $factory,
130        array $payload,
131        int $statusCode = 200
132    ): ResponseInterface {
133        $streamFactory = $factory instanceof StreamFactoryInterface ? $factory : new Psr17Factory();
134
135        if (class_exists(DataResponseFactory::class)) {
136            $dataResponseFactory = new DataResponseFactory($factory, $streamFactory);
137            $formatter = new JsonDataResponseFormatter();
138
139            $dataResponse = $dataResponseFactory
140                ->createResponse($payload, $statusCode)
141                ->withResponseFormatter($formatter);
142
143            $bodyStream = $dataResponse->getBody();
144        } else {
145            $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
146            $bodyStream = $streamFactory->createStream($encoded ?: '{}');
147        }
148
149        return $factory->createResponse($statusCode)
150            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE)
151            ->withBody($bodyStream);
152    }
153
154    /**
155     * Extracts associative payload array from PSR-7 request body or JSON input stream.
156     *
157     * @param ServerRequestInterface $request PSR-7 Server request.
158     * @return array<string, mixed> Key-value payload array.
159     */
160    protected function parseJsonBody(ServerRequestInterface $request): array
161    {
162        $parsed = $request->getParsedBody();
163        if (is_array($parsed) && $parsed !== []) {
164            return $parsed;
165        }
166        if (is_object($parsed)) {
167            return (array) $parsed;
168        }
169
170        $stream = $request->getBody();
171        if ($stream->isSeekable()) {
172            $stream->rewind();
173        }
174        $raw = (string) $stream;
175        if ($raw === '') {
176            $raw = (string) @file_get_contents('php://input');
177        }
178        $decoded = $raw !== '' ? json_decode($raw, true) : null;
179
180        return is_array($decoded) ? $decoded : [];
181    }
182
183    /**
184     * Resolves client IP address from request or server parameters with proxy support.
185     *
186     * @param ServerRequestInterface|array<string, mixed> $requestOrServer Request or server params.
187     * @return string Valid IP address.
188     */
189    protected function resolveClientIp(ServerRequestInterface|array $requestOrServer): string
190    {
191        $params = $requestOrServer instanceof ServerRequestInterface
192            ? $requestOrServer->getServerParams()
193            : $requestOrServer;
194
195        $ip = '127.0.0.1';
196        if (!empty($params['HTTP_X_FORWARDED_FOR'])) {
197            $list = explode(',', (string) $params['HTTP_X_FORWARDED_FOR']);
198            $ip = trim($list[0]);
199        } elseif (!empty($params['HTTP_X_REAL_IP'])) {
200            $ip = (string) $params['HTTP_X_REAL_IP'];
201        } elseif (!empty($params['REMOTE_ADDR'])) {
202            $ip = (string) $params['REMOTE_ADDR'];
203        }
204
205        return filter_var($ip, FILTER_VALIDATE_IP) ?: '127.0.0.1';
206    }
207
208    /**
209     * Builds an ETag/Cache-Control compliant JSON response.
210     *
211     * @param ResponseFactoryInterface    $factory      PSR-7 Response factory.
212     * @param string                      $json         Serialized JSON payload.
213     * @param ServerRequestInterface|null $request      Incoming HTTP request.
214     * @param string                      $cacheControl Cache-Control header value.
215     * @return ResponseInterface Formatted cached JSON response or 304 Not Modified.
216     */
217    protected function jsonCached(
218        ResponseFactoryInterface $factory,
219        string $json,
220        ?ServerRequestInterface $request = null,
221        string $cacheControl = 'public, max-age=300, stale-while-revalidate=60'
222    ): ResponseInterface {
223        $etag = '"' . hash('sha256', $json) . '"';
224        if ($request !== null && $request->getHeaderLine('If-None-Match') === $etag) {
225            return $factory->createResponse(304)
226                ->withHeader('ETag', $etag)
227                ->withHeader('Cache-Control', $cacheControl);
228        }
229
230        $response = $factory->createResponse(200);
231        $response->getBody()->write($json);
232
233        return $response
234            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE . '; charset=utf-8')
235            ->withHeader('ETag', $etag)
236            ->withHeader('Cache-Control', $cacheControl);
237    }
238
239    /**
240     * Builds a direct JSON response with arbitrary associative or list payload.
241     *
242     * @param ResponseFactoryInterface $factory PSR-7 response factory.
243     * @param mixed                    $data    Data payload to serialize.
244     * @param int                      $status  HTTP status code (default 200).
245     * @return ResponseInterface Formatted JSON response.
246     */
247    protected function jsonResponse(
248        ResponseFactoryInterface $factory,
249        mixed $data,
250        int $status = 200
251    ): ResponseInterface {
252        $encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
253        $response = $factory->createResponse($status)
254            ->withHeader('Content-Type', self::JSON_CONTENT_TYPE);
255        $response->getBody()->write($encoded ?: '{}');
256
257        return $response;
258    }
259
260    /**
261     * Builds a status message payload JSON response with status, message, and data fields.
262     *
263     * @param ResponseFactoryInterface $factory    PSR-7 response factory.
264     * @param bool                     $success    Operation status boolean.
265     * @param string                   $message    Status message string.
266     * @param mixed                    $data       Optional payload.
267     * @param int                      $statusCode HTTP status code (default 200).
268     * @return ResponseInterface Formatted JSON response.
269     */
270    protected function jsonStatusResponse(
271        ResponseFactoryInterface $factory,
272        bool $success,
273        string $message,
274        mixed $data = null,
275        int $statusCode = 200
276    ): ResponseInterface {
277        return $this->buildJsonResponse($factory, [
278            'status'  => $success,
279            'message' => $message,
280            'data'    => $data,
281        ], $statusCode);
282    }
283
284    /**
285     * Maps domain and engine exceptions to standardized PSR-7 JSON responses.
286     *
287     * @param ResponseFactoryInterface $factory PSR-7 response factory.
288     * @param Throwable               $e       Exception instance.
289     * @return ResponseInterface Formatted error JSON response.
290     */
291    protected function handleApiException(
292        ResponseFactoryInterface $factory,
293        Throwable $e
294    ): ResponseInterface {
295        return ApiExceptionResponseHandler::handle($factory, $e);
296    }
297
298    /**
299     * Resolves authenticated user ID from session, identity, or request attribute.
300     *
301     * @param ServerRequestInterface $request     PSR-7 server request.
302     * @param object|null            $session     Optional session service.
303     * @param object|null            $currentUser Optional current user identity service.
304     * @return int Resolved user ID or 0 if unauthenticated.
305     */
306    protected function resolveCurrentUserId(
307        ServerRequestInterface $request,
308        ?object $session = null,
309        ?object $currentUser = null
310    ): int {
311        $userId = 0;
312        if ($session !== null && method_exists($session, 'get')) {
313            $userId = (int) ($session->get('user_id') ?? 0);
314        }
315
316        if ($userId <= 0 && session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['user_id'])) {
317            $userId = (int) $_SESSION['user_id'];
318        }
319
320        if ($userId <= 0 && $currentUser !== null && method_exists($currentUser, 'getId')) {
321            $userId = (int) ($currentUser->getId() ?? 0);
322        }
323
324        return $userId > 0 ? $userId : (int) ($request->getAttribute('user_id') ?? 0);
325    }
326
327    /**
328     * Resolves base scheme and host for generated pairing links and callbacks.
329     *
330     * @param ServerRequestInterface $request     PSR-7 server request.
331     * @param string                 $defaultHost Fallback host name.
332     * @return string Normalized base URL (e.g. 'https://app-client.ammonly.com').
333     */
334    protected function resolveBaseUrl(
335        ServerRequestInterface $request,
336        string $defaultHost = 'app-client.ammonly.com'
337    ): string {
338        $uri = $request->getUri();
339        $scheme = $uri->getScheme() !== '' ? $uri->getScheme() : 'https';
340        $host = $uri->getHost() !== '' ? $uri->getHost() : $defaultHost;
341        $port = $uri->getPort();
342
343        if ($port !== null && $port !== 80 && $port !== 443) {
344            return sprintf('%s://%s:%d', $scheme, $host, $port);
345        }
346
347        return sprintf('%s://%s', $scheme, $host);
348    }
349}