Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
IcapRequest
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
4 / 4
8
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 assertHeaderValueTypes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isPartialPreview
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 withHeader
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap\DTO;
22
23use Ndrstmr\Icap\HeaderFields;
24
25/**
26 * Immutable data object representing an ICAP request.
27 *
28 * An ICAP request is an ICAP envelope (method/URI/ICAP-headers) that
29 * optionally carries a single encapsulated HTTP request (REQMOD) or a
30 * single encapsulated HTTP response (RESPMOD), per RFC 3507 §4.4.
31 *
32 * Rendering into wire bytes is the {@see \Ndrstmr\Icap\RequestFormatter}'s
33 * job; this class is a pure value object.
34 */
35final readonly class IcapRequest
36{
37    /** @var array<string, string[]> */
38    public array $headers;
39
40    /**
41     * @param string                  $method               ICAP method (OPTIONS, REQMOD, RESPMOD)
42     * @param string                  $uri                  Fully-qualified ICAP URI, e.g. icap://host:1344/service
43     * @param array<string, string|string[]> $headers       ICAP headers; a string value is promoted to a one-element
44     *                                                       list, any type other than a string or an array is
45     *                                                       refused. Host (from $uri) and Encapsulated are always
46     *                                                       computed by the formatter: caller values for them are
47     *                                                       ignored in any spelling
48     * @param HttpRequest|null        $encapsulatedRequest  Encapsulated HTTP request (REQMOD)
49     * @param HttpResponse|null       $encapsulatedResponse Encapsulated HTTP response (RESPMOD)
50     * @param bool                    $previewIsComplete    When true, the body attached to the encapsulated HTTP
51     *                                                      message represents the complete payload in a preview
52     *                                                      scenario, so the terminator must be `0; ieof\r\n\r\n`
53     *                                                      rather than `0\r\n\r\n` (RFC 3507 §4.5).
54     *
55     * @throws \InvalidArgumentException When a header value is neither a
56     *         string nor an array
57     */
58    public function __construct(
59        public string $method,
60        public string $uri = '/',
61        array $headers = [],
62        public ?HttpRequest $encapsulatedRequest = null,
63        public ?HttpResponse $encapsulatedResponse = null,
64        public bool $previewIsComplete = false,
65    ) {
66        self::assertHeaderValueTypes($headers);
67        $this->headers = array_map(fn ($v) => (array) $v, $headers);
68    }
69
70    /**
71     * Refuse header values the cast in the constructor would change: null
72     * would become no field at all, an object its properties. Callers
73     * without static analysis can pass any type, so the values are checked
74     * as such.
75     *
76     * @param array<mixed> $headers
77     *
78     * @throws \InvalidArgumentException When a value is neither a string nor
79     *         an array
80     */
81    private static function assertHeaderValueTypes(array $headers): void
82    {
83        foreach ($headers as $value) {
84            if (!is_string($value) && !is_array($value)) {
85                throw new \InvalidArgumentException(sprintf(
86                    'ICAP header values must be strings or lists of strings, got %s.',
87                    get_debug_type($value),
88                ));
89            }
90        }
91    }
92
93    /**
94     * Whether the body is only the start of the message: the request announces a `Preview` in any spelling and does not
95     * end in `ieof` (RFC 3507 §4.5). The rest follows after `100 Continue`.
96     */
97    public function isPartialPreview(): bool
98    {
99        return !$this->previewIsComplete
100            && HeaderFields::first($this->headers, ['Preview']) !== null;
101    }
102
103    /**
104     * @param string|string[] $value
105     */
106    public function withHeader(string $name, string|array $value): self
107    {
108        $headers = $this->headers;
109        $headers[$name] = (array) $value;
110
111        return new self(
112            $this->method,
113            $this->uri,
114            $headers,
115            $this->encapsulatedRequest,
116            $this->encapsulatedResponse,
117            $this->previewIsComplete,
118        );
119    }
120}