Passed
Pull Request — master (#42)
by Rustam
02:36
created

BasicNetworkResolver::withAddedProtocolHeader()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 40
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 8.0052

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 8
eloc 22
c 2
b 0
f 1
nc 8
nop 2
dl 0
loc 40
ccs 22
cts 23
cp 0.9565
crap 8.0052
rs 8.4444
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Middleware;
6
7
use Psr\Http\Message\ResponseInterface;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Http\Server\MiddlewareInterface;
10
use Psr\Http\Server\RequestHandlerInterface;
11
use RuntimeException;
12
13
use function array_map;
14
use function in_array;
15
use function is_callable;
16
use function is_string;
17
use function strtolower;
18
19
/**
20
 * Basic network resolver updates an instance of server request with protocol from special headers.
21
 *
22
 * It can be used in if your server is behind a trusted load balancer or a proxy that is setting a special header.
23
 */
24
final class BasicNetworkResolver implements MiddlewareInterface
25
{
26
    private const DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES = [
27
        'http' => ['http'],
28
        'https' => ['https', 'on'],
29
    ];
30
31
    /**
32
     * @psalm-var array<string, non-empty-array<non-empty-string, non-empty-array<array-key, string>>|callable>
33
     */
34
    private array $protocolHeaders = [];
35
36
    /**
37
     * Returns a new instance with added the specified protocol header to check
38
     * whether the connection is made via HTTP or HTTPS (or any protocol).
39
     *
40
     * The match of header names and values is case-insensitive.
41
     * It's not advisable to put insecure/untrusted headers here.
42
     *
43
     * Accepted types of values:
44
     * - NULL (default): {@see DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES}
45
     * - callable: custom function for getting the protocol
46
     * ```php
47
     * ->withProtocolHeader(
48
     *     'x-forwarded-proto',
49
     *     function (array $values, string $header, ServerRequestInterface $request): ?string {
50
     *         return $values[0] === 'https' ? 'https' : 'http';
51
     *         return null;     // If it doesn't make sense.
52
     *     },
53
     * );
54
     * ```
55
     * - array: The array keys are protocol string and the array value is a list of header values that
56
     * indicate the protocol.
57
     *
58
     * ```php
59
     * ->withProtocolHeader('x-forwarded-proto', [
60
     *     'http' => ['http'],
61
     *     'https' => ['https'],
62
     * ]);
63
     * ```
64
     *
65
     * @param string $header The protocol header name.
66
     * @param array|callable|null $values The protocol header values.
67
     *
68
     * @psalm-param array<array-key, string|string[]>|callable|null $values
69
     */
70 24
    public function withAddedProtocolHeader(string $header, array|callable|null $values = null): self
71
    {
72 24
        $new = clone $this;
73 24
        $header = strtolower($header);
74
75 24
        if ($values === null) {
76 4
            $new->protocolHeaders[$header] = self::DEFAULT_PROTOCOL_AND_ACCEPTABLE_VALUES;
77 4
            return $new;
78
        }
79
80 20
        if (is_callable($values)) {
81 11
            $new->protocolHeaders[$header] = $values;
82 11
            return $new;
83
        }
84
85 9
        if (empty($values)) {
86 1
            throw new RuntimeException('Accepted values cannot be an empty array.');
87
        }
88
89 8
        $protocolHeader = [];
90 8
        foreach ($values as $protocol => $acceptedValues) {
91 8
            if (!is_string($protocol)) {
92 1
                throw new RuntimeException('The protocol must be type of string.');
93
            }
94
95 7
            if ($protocol === '') {
96 1
                throw new RuntimeException('The protocol cannot be an empty string.');
97
            }
98
99 6
            $acceptedValues = (array) $acceptedValues;
100 6
            if (empty($acceptedValues)) {
101
                throw new RuntimeException('Protocol accepted values cannot be an empty array.');
102
            }
103
104 6
            $protocolHeader[$protocol] = array_map('\strtolower', $acceptedValues);
105
        }
106
107 6
        $new->protocolHeaders[$header] = $protocolHeader;
108
109 6
        return $new;
110
    }
111
112
    /**
113
     * Returns a new instance without the specified protocol header.
114
     *
115
     * @param string $header The protocol header name.
116
     *
117
     * @see withAddedProtocolHeader()
118
     */
119 2
    public function withoutProtocolHeader(string $header): self
120
    {
121 2
        $new = clone $this;
122 2
        unset($new->protocolHeaders[strtolower($header)]);
123 2
        return $new;
124
    }
125
126
    /**
127
     * Returns a new instance without the specified protocol headers.
128
     *
129
     * @param string[] $headers The protocol header names. If `null` is specified all protocol headers will be removed.
130
     *
131
     * @see withoutProtocolHeader()
132
     */
133 2
    public function withoutProtocolHeaders(?array $headers = null): self
134
    {
135 2
        $new = clone $this;
136
137 2
        if ($headers === null) {
138 1
            $new->protocolHeaders = [];
139 1
            return $new;
140
        }
141
142 1
        foreach ($headers as $header) {
143 1
            $new = $new->withoutProtocolHeader($header);
144
        }
145
146 1
        return $new;
147
    }
148
149
    /**
150
     * {@inheritDoc}
151
     *
152
     * @throws RuntimeException If wrong URI scheme protocol.
153
     */
154 23
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
155
    {
156 23
        $newScheme = null;
157
158 23
        foreach ($this->protocolHeaders as $header => $data) {
159 19
            if (!$request->hasHeader($header)) {
160 1
                continue;
161
            }
162
163 18
            $headerValues = $request->getHeader($header);
164
165 18
            if (is_callable($data)) {
166
                /** @var mixed $newScheme */
167 11
                $newScheme = $data($headerValues, $header, $request);
168
169 11
                if ($newScheme === null) {
170 1
                    continue;
171
                }
172
173 10
                if (!is_string($newScheme)) {
174 8
                    throw new RuntimeException('The scheme is neither string nor null.');
175
                }
176
177 2
                if ($newScheme === '') {
178 1
                    throw new RuntimeException('The scheme cannot be an empty string.');
179
                }
180
181 1
                break;
182
            }
183
184 7
            $headerValue = strtolower($headerValues[0]);
185
186 7
            foreach ($data as $protocol => $acceptedValues) {
187 7
                if (!in_array($headerValue, $acceptedValues, true)) {
188 2
                    continue;
189
                }
190
191 6
                $newScheme = $protocol;
192 6
                break 2;
193
            }
194
        }
195
196 14
        $uri = $request->getUri();
197
198 14
        if ($newScheme !== null && $newScheme !== $uri->getScheme()) {
199 7
            $request = $request->withUri($uri->withScheme($newScheme));
200
        }
201
202 14
        return $handler->handle($request);
203
    }
204
}
205