Lines
84.09%
111 / 132
Methods
61.53%
8 / 13
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| defaultUserAgent | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 2 | ||
| __construct | 92.85% | 13 / 14 | 0.00% | 0 / 1 | 3.00 | ||
| sendRequest | 100.00% | 26 / 26 | 100.00% | 1 / 1 | 8 | ||
| get | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 3 | ||
| post | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| put | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| patch | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| delete | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| head | 60.00% | 3 / 5 | 0.00% | 0 / 1 | 3.58 | ||
| sendWithBody | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 4 | ||
| validateConfig | 81.25% | 26 / 32 | 0.00% | 0 / 1 | 30.46 | ||
| validateOptions | 56.00% | 14 / 25 | 0.00% | 0 / 1 | 68.06 | ||
| assertSafeHeaders | 80.00% | 4 / 5 | 0.00% | 0 / 1 | 6.29 | ||
| 48 | final class Client implements ClientInterface | |
| 49 | { | |
| 50 | /** @var string Option key for the sink (file path, resource, or stream) */ | |
| 51 | public const OPTION_SINK = 'sink'; | |
| 52 | ||
| 53 | /** @var string Option key for per-request timeout */ | |
| 54 | public const OPTION_TIMEOUT = 'timeout'; | |
| 55 | ||
| 56 | /** @var string Option key for per-request SSL verification */ | |
| 57 | public const OPTION_VERIFY_SSL = 'verify_ssl'; | |
| 58 | ||
| 59 | /** @var string Option key for per-request headers */ | |
| 60 | public const OPTION_HEADERS = 'headers'; | |
| 61 | ||
| 62 | /** @var string Option key for per-request query params */ | |
| 63 | public const OPTION_QUERY = 'query'; | |
| 64 | ||
| 65 | /** @var string Option key for per-request curl options */ | |
| 66 | public const OPTION_CURL = 'curl'; | |
| 67 | ||
| 68 | /** @var string Option key for per-request user agent */ | |
| 69 | public const OPTION_USER_AGENT = 'user_agent'; | |
| 70 | ||
| 71 | /** @var string Option key for per-request basic auth */ | |
| 72 | public const OPTION_BASIC_AUTH = 'basic_auth'; | |
| 73 | ||
| 74 | /** @var string Option key for a per-request progress callback */ | |
| 75 | public const OPTION_PROGRESS = 'progress'; | |
| 76 | ||
| 77 | /** @var string Option key for streaming the response body */ | |
| 78 | public const OPTION_STREAM = 'stream'; | |
| 79 | ||
| 80 | /** | |
| 81 | * Generate the default User-Agent string. | |
| 82 | * | |
| 83 | * Used as the client's config default and by handlers as a fallback when | |
| 84 | * the merged options omit `user_agent` (e.g. when a handler is used | |
| 85 | * directly). Centralized here so every transport sends the same value. | |
| 86 | * | |
| 87 | * @return string The default User-Agent string of a Client. | |
| 88 | */ | |
| 89 | public static function defaultUserAgent(): string | |
| 90 | { | |
| 91 | return 'Lucent-HttpClient/' . (defined('VERSION') ? VERSION : 'unknown'); | |
| 92 | } | |
| 93 | ||
| 94 | /** @var UriInterface|null Normalized base URI */ | |
| 95 | private readonly ?UriInterface $baseUri; | |
| 96 | ||
| 97 | /** @var int Default timeout in seconds */ | |
| 98 | private readonly int $timeout; | |
| 99 | ||
| 100 | /** @var bool Whether to verify SSL certificates */ | |
| 101 | private readonly bool $verifySsl; | |
| 102 | ||
| 103 | /** @var array{0: string, 1: string}|null Basic auth credentials */ | |
| 104 | private readonly ?array $basicAuth; | |
| 105 | ||
| 106 | /** @var string User agent */ | |
| 107 | private readonly string $userAgent; | |
| 108 | ||
| 109 | /** @var array<string, string> Default headers */ | |
| 110 | private readonly array $headers; | |
| 111 | ||
| 112 | /** @var array<int, mixed> Additional cURL options */ | |
| 113 | private readonly array $curlOptions; | |
| 114 | ||
| 115 | /** @var HttpFactory PSR-17 factory used to build requests/streams */ | |
| 116 | private readonly HttpFactory $factory; | |
| 117 | ||
| 118 | /** @var HandlerInterface The default (cURL-backed) transport */ | |
| 119 | private readonly HandlerInterface $defaultHandler; | |
| 120 | ||
| 121 | /** @var HandlerInterface The streaming transport */ | |
| 122 | private readonly HandlerInterface $streamHandler; | |
| 123 | ||
| 124 | /** | |
| 125 | * @param array<string, mixed> $config Client configuration | |
| 126 | * @param HandlerInterface|null $defaultHandler The default (cURL) transport | |
| 127 | * @param HandlerInterface|null $streamHandler The streaming transport | |
| 128 | * @throws \InvalidArgumentException On unknown keys, wrong types, or invalid values | |
| 129 | */ | |
| 130 | public function __construct( | |
| 131 | array $config = [], | |
| 132 | ?HandlerInterface $defaultHandler = null, | |
| 133 | ?HandlerInterface $streamHandler = null | |
| 134 | ) { | |
| 135 | $this->validateConfig($config); | |
| 136 | ||
| 137 | $baseUri = $config['base_uri'] ?? null; | |
| 138 | $this->baseUri = $baseUri instanceof UriInterface | |
| 139 | ? $baseUri | |
| 140 | : ($baseUri !== null ? Uri::fromString($baseUri) : null); | |
| 141 | ||
| 142 | $this->timeout = $config['timeout'] ?? 30; | |
| 143 | $this->verifySsl = $config['verify_ssl'] ?? true; | |
| 144 | $this->basicAuth = $config['basic_auth'] ?? null; | |
| 145 | $this->userAgent = $config['user_agent'] ?? self::defaultUserAgent(); | |
| 146 | $this->headers = $config['headers'] ?? []; | |
| 147 | $this->curlOptions = $config['curl_options'] ?? []; | |
| 148 | $this->factory = new HttpFactory(); | |
| 149 | ||
| 150 | $this->defaultHandler = $defaultHandler ?? new CurlHandler(); | |
| 151 | $this->streamHandler = $streamHandler ?? new StreamHandler(); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * Send a PSR-7 request and return a PSR-7 response. | |
| 156 | * | |
| 157 | * Per-request options may be passed as the second argument: | |
| 158 | * | |
| 159 | * ```php | |
| 160 | * $response = $client->sendRequest($request, [ | |
| 161 | * 'sink' => '/tmp/file.bin', | |
| 162 | * 'timeout' => 5, | |
| 163 | * 'verify_ssl'=> false, | |
| 164 | * 'stream' => true, | |
| 165 | * ]); | |
| 166 | * ``` | |
| 167 | * | |
| 168 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, query, curl, user_agent, basic_auth, stream, progress). | |
| 169 | * NOTE: this second parameter is a Lucent extension — the PSR-18 | |
| 170 | * interface defines only the $request parameter. Callers relying on | |
| 171 | * strict PSR-18 interop should not pass $options. | |
| 172 | * @throws \Psr\Http\Client\RequestExceptionInterface On request-level failures (invalid request, JSON-encode failure, cURL init failure) | |
| 173 | * @throws \Psr\Http\Client\NetworkExceptionInterface On transport-level failures (DNS, connection, timeout) | |
| 174 | * @throws \Psr\Http\Client\ClientExceptionInterface Parent interface of both of the above | |
| 175 | */ | |
| 176 | public function sendRequest(RequestInterface $request, array $options = []): ResponseInterface | |
| 177 | { | |
| 178 | $this->validateOptions($options); | |
| 179 | ||
| 180 | // Resolve the full URL against the configured base URI. Only relative | |
| 181 | // URIs (no scheme) are resolved; absolute URIs override the base. | |
| 182 | $uri = $request->getUri(); | |
| 183 | if ($this->baseUri !== null && $uri->getScheme() === '' && $uri->getHost() === '') { | |
| 184 | $uri = UriResolver::resolve($this->baseUri, $uri); | |
| 185 | $request = $request->withUri($uri); | |
| 186 | } | |
| 187 | ||
| 188 | // SSRF guard: only http/https are permitted. Rejecting other schemes | |
| 189 | // (file://, gopher://, dict://, ftp://) at the client boundary stops | |
| 190 | // local-file disclosure and protocol abuse when a caller passes a | |
| 191 | // user-supplied URL into the client. | |
| 192 | $scheme = strtolower($uri->getScheme()); | |
| 193 | if ($scheme !== '' && !in_array($scheme, ['http', 'https'], true)) { | |
| 194 | throw new RequestException( | |
| 195 | "Unsupported URL scheme '{$scheme}' — only http and https are allowed.", | |
| 196 | $request | |
| 197 | ); | |
| 198 | } | |
| 199 | ||
| 200 | $streaming = ($options['stream'] ?? false) === true; | |
| 201 | ||
| 202 | // Merge config defaults into the options (per-request wins). The | |
| 203 | // associative `headers` and `curl` options are deep-merged so | |
| 204 | // per-request keys add to (not replace) config defaults. Config curl | |
| 205 | // defaults are skipped on the stream path so a streaming request is | |
| 206 | // not rejected for curl options it never asked for. | |
| 207 | $options = array_merge([ | |
| 208 | 'timeout' => $this->timeout, | |
| 209 | 'verify_ssl' => $this->verifySsl, | |
| 210 | 'user_agent' => $this->userAgent, | |
| 211 | 'basic_auth' => $this->basicAuth, | |
| 212 | 'headers' => [], | |
| 213 | 'curl' => [], | |
| 214 | ], $options); | |
| 215 | ||
| 216 | $options['headers'] = array_merge($this->headers, $options['headers'] ?? []); | |
| 217 | if (!$streaming) { | |
| 218 | // array_replace (not array_merge) — CURLOPT_* keys are integers | |
| 219 | // and array_merge would renumber them, breaking the conflict | |
| 220 | // check and curl_setopt_array. | |
| 221 | $options['curl'] = array_replace($this->curlOptions, $options['curl'] ?? []); | |
| 222 | } | |
| 223 | ||
| 224 | // Dispatch per-request: stream => true routes to the streaming | |
| 225 | // handler; everything else uses the default (cURL) handler. | |
| 226 | $handler = $streaming ? $this->streamHandler : $this->defaultHandler; | |
| 227 | ||
| 228 | // Let the handler validate its own options (conflicting curl options, | |
| 229 | // unsupported options) against the merged options. | |
| 230 | $handler->validateOptions($options); | |
| 231 | ||
| 232 | return $handler->send($request, $options); | |
| 233 | } | |
| 234 | ||
| 235 | // ─── Verb Convenience Methods ─────────────────────────────────────── | |
| 236 | ||
| 237 | /** | |
| 238 | * Send a GET request. | |
| 239 | * | |
| 240 | * @param string $uri Request URI (absolute, or relative to `base_uri`) | |
| 241 | * @param array<string, mixed> $params Query parameters to append | |
| 242 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 243 | */ | |
| 244 | public function get(string $uri, array $params = [], array $options = []): ResponseInterface | |
| 245 | { | |
| 246 | if (!empty($params)) { | |
| 247 | $separator = str_contains($uri, '?') ? '&' : '?'; | |
| 248 | $uri .= $separator . http_build_query($params); | |
| 249 | } | |
| 250 | ||
| 251 | $request = $this->factory->createRequest('GET', $uri); | |
| 252 | ||
| 253 | return $this->sendRequest($request, $options); | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Send a POST request. | |
| 258 | * | |
| 259 | * Arrays are JSON-encoded and sent with `Content-Type: application/json`. | |
| 260 | * | |
| 261 | * @param string $uri Request URI | |
| 262 | * @param array<mixed>|string|StreamInterface $body Request body | |
| 263 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 264 | */ | |
| 265 | public function post(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface | |
| 266 | { | |
| 267 | return $this->sendWithBody('POST', $uri, $body, $options); | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Send a PUT request. | |
| 272 | * | |
| 273 | * Arrays are JSON-encoded and sent with `Content-Type: application/json`. | |
| 274 | * | |
| 275 | * @param string $uri Request URI | |
| 276 | * @param array<mixed>|string|StreamInterface $body Request body | |
| 277 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 278 | */ | |
| 279 | public function put(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface | |
| 280 | { | |
| 281 | return $this->sendWithBody('PUT', $uri, $body, $options); | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * Send a PATCH request. | |
| 286 | * | |
| 287 | * Arrays are JSON-encoded and sent with `Content-Type: application/json`. | |
| 288 | * | |
| 289 | * @param string $uri Request URI | |
| 290 | * @param array<mixed>|string|StreamInterface $body Request body | |
| 291 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 292 | */ | |
| 293 | public function patch(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface | |
| 294 | { | |
| 295 | return $this->sendWithBody('PATCH', $uri, $body, $options); | |
| 296 | } | |
| 297 | ||
| 298 | /** | |
| 299 | * Send a DELETE request. | |
| 300 | * | |
| 301 | * Arrays are JSON-encoded and sent with `Content-Type: application/json`. | |
| 302 | * | |
| 303 | * @param string $uri Request URI | |
| 304 | * @param array<mixed>|string|StreamInterface $body Request body | |
| 305 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 306 | */ | |
| 307 | public function delete(string $uri, array|string|StreamInterface $body = [], array $options = []): ResponseInterface | |
| 308 | { | |
| 309 | return $this->sendWithBody('DELETE', $uri, $body, $options); | |
| 310 | } | |
| 311 | ||
| 312 | /** | |
| 313 | * Send a HEAD request. | |
| 314 | * | |
| 315 | * @param string $uri Request URI | |
| 316 | * @param array<string, mixed> $params Query parameters to append | |
| 317 | * @param array<string, mixed> $options Per-request options (sink, timeout, verify_ssl, headers, curl, ...) | |
| 318 | */ | |
| 319 | public function head(string $uri, array $params = [], array $options = []): ResponseInterface | |
| 320 | { | |
| 321 | if (!empty($params)) { | |
| 322 | $separator = str_contains($uri, '?') ? '&' : '?'; | |
| 323 | $uri .= $separator . http_build_query($params); | |
| 324 | } | |
| 325 | ||
| 326 | $request = $this->factory->createRequest('HEAD', $uri); | |
| 327 | ||
| 328 | return $this->sendRequest($request, $options); | |
| 329 | } | |
| 330 | ||
| 331 | // ─── Internals ────────────────────────────────────────────────────── | |
| 332 | ||
| 333 | /** | |
| 334 | * @param array<mixed>|string|StreamInterface $body | |
| 335 | * @param array<string, mixed> $options | |
| 336 | */ | |
| 337 | private function sendWithBody(string $method, string $uri, array|string|StreamInterface $body, array $options): ResponseInterface | |
| 338 | { | |
| 339 | $request = $this->factory->createRequest($method, $uri); | |
| 340 | ||
| 341 | if (is_array($body)) { | |
| 342 | $encoded = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); | |
| 343 | if ($encoded === false) { | |
| 344 | throw new RequestException( | |
| 345 | 'Unable to JSON-encode request body: ' . json_last_error_msg(), | |
| 346 | $request | |
| 347 | ); | |
| 348 | } | |
| 349 | ||
| 350 | $request = $request | |
| 351 | ->withHeader('Content-Type', 'application/json') | |
| 352 | ->withBody(Stream::fromString($encoded)); | |
| 353 | } elseif ($body instanceof StreamInterface) { | |
| 354 | $request = $request->withBody($body); | |
| 355 | } else { | |
| 356 | $request = $request->withBody(Stream::fromString($body)); | |
| 357 | } | |
| 358 | ||
| 359 | return $this->sendRequest($request, $options); | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * Validate the config array, throwing on unknown keys or invalid values. | |
| 364 | * | |
| 365 | * @param array<string, mixed> $config | |
| 366 | * @throws \InvalidArgumentException | |
| 367 | */ | |
| 368 | private function validateConfig(array $config): void | |
| 369 | { | |
| 370 | $allowed = ['base_uri', 'timeout', 'verify_ssl', 'basic_auth', 'user_agent', 'headers', 'curl_options']; | |
| 371 | ||
| 372 | foreach ($config as $key => $value) { | |
| 373 | if (!in_array($key, $allowed, true)) { | |
| 374 | throw new \InvalidArgumentException("Unknown Client config key: {$key}"); | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | if (array_key_exists('base_uri', $config) && !is_string($config['base_uri']) && !$config['base_uri'] instanceof UriInterface) { | |
| 379 | throw new \InvalidArgumentException('base_uri must be a string or UriInterface'); | |
| 380 | } | |
| 381 | ||
| 382 | if (array_key_exists('timeout', $config) && (!is_int($config['timeout']) || $config['timeout'] <= 0)) { | |
| 383 | throw new \InvalidArgumentException('timeout must be a positive integer'); | |
| 384 | } | |
| 385 | ||
| 386 | if (array_key_exists('verify_ssl', $config) && !is_bool($config['verify_ssl'])) { | |
| 387 | throw new \InvalidArgumentException('verify_ssl must be a boolean'); | |
| 388 | } | |
| 389 | ||
| 390 | if (array_key_exists('basic_auth', $config)) { | |
| 391 | $basicAuth = $config['basic_auth']; | |
| 392 | if ($basicAuth !== null | |
| 393 | && (!is_array($basicAuth) | |
| 394 | || count($basicAuth) !== 2 | |
| 395 | || !is_string($basicAuth[0] ?? null) | |
| 396 | || !is_string($basicAuth[1] ?? null))) { | |
| 397 | throw new \InvalidArgumentException('basic_auth must be an array of [username, password] strings'); | |
| 398 | } | |
| 399 | } | |
| 400 | ||
| 401 | if (array_key_exists('user_agent', $config) && !is_string($config['user_agent'])) { | |
| 402 | throw new \InvalidArgumentException('user_agent must be a string'); | |
| 403 | } | |
| 404 | ||
| 405 | if (array_key_exists('headers', $config)) { | |
| 406 | if (!is_array($config['headers'])) { | |
| 407 | throw new \InvalidArgumentException('headers must be an array'); | |
| 408 | } | |
| 409 | $this->assertSafeHeaders($config['headers']); | |
| 410 | } | |
| 411 | ||
| 412 | if (array_key_exists('curl_options', $config) && !is_array($config['curl_options'])) { | |
| 413 | throw new \InvalidArgumentException('curl_options must be an array'); | |
| 414 | } | |
| 415 | ||
| 416 | // Fail fast on config-level curl options that conflict with the | |
| 417 | // handler's own transport handling (single source of truth lives in | |
| 418 | // CurlHandler so per-request `curl` options are checked identically). | |
| 419 | if (array_key_exists('curl_options', $config)) { | |
| 420 | foreach ($config['curl_options'] as $option => $_) { | |
| 421 | if (isset(CurlHandler::CONFLICTING_CURL_OPTIONS[$option])) { | |
| 422 | throw new \InvalidArgumentException( | |
| 423 | 'curl_options must not override ' . CurlHandler::CONFLICTING_CURL_OPTIONS[$option] | |
| 424 | ); | |
| 425 | } | |
| 426 | } | |
| 427 | } | |
| 428 | } | |
| 429 | ||
| 430 | /** | |
| 431 | * Validate per-request options, throwing on unknown keys or invalid values. | |
| 432 | * | |
| 433 | * @param array<string, mixed> $options | |
| 434 | * @throws \InvalidArgumentException | |
| 435 | */ | |
| 436 | private function validateOptions(array $options): void | |
| 437 | { | |
| 438 | $allowed = ['sink', 'timeout', 'verify_ssl', 'headers', 'curl', 'user_agent', 'basic_auth', 'query', 'progress', 'stream', 'max_response_size']; | |
| 439 | ||
| 440 | foreach ($options as $key => $value) { | |
| 441 | if (!in_array($key, $allowed, true)) { | |
| 442 | throw new \InvalidArgumentException("Unknown Client request option: {$key}"); | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | if (array_key_exists('stream', $options) && !is_bool($options['stream'])) { | |
| 447 | throw new \InvalidArgumentException('stream must be a boolean'); | |
| 448 | } | |
| 449 | ||
| 450 | if (array_key_exists('timeout', $options) && (!is_int($options['timeout']) || $options['timeout'] <= 0)) { | |
| 451 | throw new \InvalidArgumentException('timeout must be a positive integer'); | |
| 452 | } | |
| 453 | ||
| 454 | if (array_key_exists('verify_ssl', $options) && !is_bool($options['verify_ssl'])) { | |
| 455 | throw new \InvalidArgumentException('verify_ssl must be a boolean'); | |
| 456 | } | |
| 457 | ||
| 458 | if (array_key_exists('headers', $options)) { | |
| 459 | if (!is_array($options['headers'])) { | |
| 460 | throw new \InvalidArgumentException('headers must be an array'); | |
| 461 | } | |
| 462 | $this->assertSafeHeaders($options['headers']); | |
| 463 | } | |
| 464 | ||
| 465 | if (array_key_exists('curl', $options) && !is_array($options['curl'])) { | |
| 466 | throw new \InvalidArgumentException('curl must be an array'); | |
| 467 | } | |
| 468 | ||
| 469 | if (array_key_exists('max_response_size', $options) && (!is_int($options['max_response_size']) || $options['max_response_size'] <= 0)) { | |
| 470 | throw new \InvalidArgumentException('max_response_size must be a positive integer'); | |
| 471 | } | |
| 472 | ||
| 473 | if (array_key_exists('basic_auth', $options) && $options['basic_auth'] !== null) { | |
| 474 | $basicAuth = $options['basic_auth']; | |
| 475 | if (!is_array($basicAuth) | |
| 476 | || count($basicAuth) !== 2 | |
| 477 | || !is_string($basicAuth[0] ?? null) | |
| 478 | || !is_string($basicAuth[1] ?? null)) { | |
| 479 | throw new \InvalidArgumentException('basic_auth must be an array of [username, password] strings'); | |
| 480 | } | |
| 481 | } | |
| 482 | } | |
| 483 | ||
| 484 | /** | |
| 485 | * Reject header names/values containing CR/LF, which would otherwise be | |
| 486 | * concatenated verbatim into header lines and enable header injection. | |
| 487 | * | |
| 488 | * The PSR-7 request-header path is already sanitized by AbstractMessage; | |
| 489 | * this closes the same hole for the `headers` config/option array, which | |
| 490 | * bypasses that path. | |
| 491 | * | |
| 492 | * @param array<string, mixed> $headers | |
| 493 | * @throws \InvalidArgumentException | |
| 494 | */ | |
| 495 | private function assertSafeHeaders(array $headers): void | |
| 496 | { | |
| 497 | foreach ($headers as $name => $value) { | |
| 498 | if (is_string($name) && preg_match('/[\r\n]/', $name)) { | |
| 499 | throw new \InvalidArgumentException('Header name must not contain CR/LF'); | |
| 500 | } | |
| 501 | if (is_string($value) && preg_match('/[\r\n]/', $value)) { | |
| 502 | throw new \InvalidArgumentException("Header '{$name}' value must not contain CR/LF"); | |
| 503 | } | |
| 504 | } | |
| 505 | } | |
| 506 | } |