Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
JsonStream
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
4 / 4
5
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getPayload
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withPayload
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonEncode
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/http-message.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/http-message
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Http\Message;
20
21use InvalidArgumentException;
22use JsonException;
23use Nyholm\Psr7\Stream;
24
25/**
26 * Class JsonStream.
27 *
28 * Provides a JSON-specific stream implementation, extending Nyholm's PSR-7 Stream.
29 * This class SHALL encapsulate a JSON-encoded payload within an in-memory PHP stream,
30 * while retaining the original decoded payload for convenient retrieval.
31 *
32 * Implementations of this class MUST properly handle JSON encoding errors and SHALL explicitly
33 * prohibit the inclusion of resource types within the JSON payload.
34 */
35final class JsonStream extends Stream implements PayloadStreamInterface
36{
37    /**
38     * JSON encoding flags to be applied by default.
39     *
40     * The options JSON_THROW_ON_ERROR, JSON_UNESCAPED_SLASHES, and JSON_UNESCAPED_UNICODE
41     * SHALL be applied to enforce strict error handling and produce readable JSON output.
42     *
43     * @var int
44     */
45    public const ENCODING_OPTIONS = \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
46
47    /**
48     * Constructs a new JsonStream instance with the provided payload.
49     *
50     * The payload SHALL be JSON-encoded and written to an in-memory stream. The original payload is retained
51     * in its decoded form for later access via getPayload().
52     *
53     * @param mixed $payload The data to encode as JSON. MUST be JSON-encodable. Resources are explicitly prohibited.
54     * @param int $encodingOptions Optional JSON encoding flags. If omitted, ENCODING_OPTIONS will be applied.
55     */
56    public function __construct(
57        private readonly mixed $payload = [],
58        private readonly int $encodingOptions = self::ENCODING_OPTIONS
59    ) {
60        parent::__construct(fopen('php://temp', 'wb+'));
61
62        $this->write($this->jsonEncode($this->payload, $this->encodingOptions));
63        $this->rewind();
64    }
65
66    /**
67     * Retrieves the decoded payload associated with the stream.
68     *
69     * This method SHALL return the original JSON-encodable payload provided during construction or via withPayload().
70     *
71     * @return mixed the decoded payload
72     */
73    public function getPayload(): mixed
74    {
75        return $this->payload;
76    }
77
78    /**
79     * Returns a new instance of the stream with the specified payload.
80     *
81     * This method MUST return a new JsonStream instance with the body replaced by a stream
82     * containing the JSON-encoded form of the new payload. The current instance SHALL remain unchanged.
83     *
84     * @param mixed $payload the new JSON-encodable payload
85     *
86     * @return self a new JsonStream instance containing the updated payload
87     */
88    public function withPayload(mixed $payload): self
89    {
90        return new self($payload, $this->encodingOptions);
91    }
92
93    /**
94     * Encodes the given data as JSON, enforcing proper error handling.
95     *
96     * If the provided data is a resource, this method SHALL throw an \InvalidArgumentException,
97     * as resource types are not supported by JSON.
98     *
99     * @param mixed $data the data to encode as JSON
100     * @param int $encodingOptions JSON encoding options to apply. JSON_THROW_ON_ERROR will always be enforced.
101     *
102     * @return string the JSON-encoded string representation of the data
103     *
104     * @throws InvalidArgumentException if the data contains a resource
105     * @throws JsonException if JSON encoding fails
106     */
107    private function jsonEncode(mixed $data, int $encodingOptions): string
108    {
109        if (\is_resource($data)) {
110            throw new InvalidArgumentException('Cannot JSON encode resources.');
111        }
112
113        // Reset potential previous errors
114        json_encode(null);
115
116        return json_encode($data, $encodingOptions | \JSON_THROW_ON_ERROR);
117    }
118}