Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.61% covered (success)
98.61%
71 / 72
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RequestFormatter
98.61% covered (success)
98.61%
71 / 72
87.50% covered (warning)
87.50%
7 / 8
30
0.00% covered (danger)
0.00%
0 / 1
 format
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 resolveBodySource
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 bodyCarrier
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 buildEncapsulatedHeader
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 renderIcapHead
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 renderHttpRequestHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 renderHttpResponseHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 chunkBody
100.00% covered (success)
100.00%
1 / 1
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;
22
23use Ndrstmr\Icap\DTO\HttpRequest;
24use Ndrstmr\Icap\DTO\HttpResponse;
25use Ndrstmr\Icap\DTO\IcapRequest;
26
27/**
28 * RFC 3507–conformant formatter for ICAP requests.
29 *
30 * The formatter yields the request in three phases so large encapsulated
31 * HTTP bodies never have to be buffered in memory:
32 *
33 *   1. ICAP request line + ICAP headers + blank line (with computed
34 *      `Encapsulated` offsets).
35 *   2. The encapsulated HTTP header block(s).
36 *   3. The encapsulated HTTP body, transfer-encoded as HTTP/1.1 chunks,
37 *      terminated by either `0\r\n\r\n` or `0; ieof\r\n\r\n` for a
38 *      preview that already carries the complete payload (§4.5).
39 *
40 * @see https://www.rfc-editor.org/rfc/rfc3507#section-4
41 */
42final class RequestFormatter implements RequestFormatterInterface
43{
44    #[\Override]
45    public function format(IcapRequest $request): iterable
46    {
47        // Build encapsulated HTTP header block(s) eagerly so we know the
48        // `*-body` offset, which MUST appear in the Encapsulated header
49        // before the body is written on the wire (RFC 3507 §4.4.1).
50        $reqHeaderBlock = $request->encapsulatedRequest !== null
51            ? $this->renderHttpRequestHeaders($request->encapsulatedRequest)
52            : '';
53        $resHeaderBlock = $request->encapsulatedResponse !== null
54            ? $this->renderHttpResponseHeaders($request->encapsulatedResponse)
55            : '';
56
57        $bodySource = $this->resolveBodySource($request);
58        $hasBody = $bodySource !== null;
59
60        $encapsulated = $this->buildEncapsulatedHeader(
61            hasReqHeaders: $reqHeaderBlock !== '',
62            reqHeaderLength: strlen($reqHeaderBlock),
63            hasResHeaders: $resHeaderBlock !== '',
64            resHeaderLength: strlen($resHeaderBlock),
65            bodyCarrier: $this->bodyCarrier($request),
66            hasBody: $hasBody,
67        );
68
69        yield $this->renderIcapHead($request, $encapsulated);
70
71        if ($reqHeaderBlock !== '') {
72            yield $reqHeaderBlock;
73        }
74        if ($resHeaderBlock !== '') {
75            yield $resHeaderBlock;
76        }
77
78        if ($hasBody) {
79            yield from $this->chunkBody($bodySource, $request->previewIsComplete);
80        }
81    }
82
83    /**
84     * @return string|resource|null
85     */
86    private function resolveBodySource(IcapRequest $request): mixed
87    {
88        if ($request->encapsulatedResponse?->body !== null && $request->encapsulatedResponse->body !== '') {
89            return $request->encapsulatedResponse->body;
90        }
91        if ($request->encapsulatedRequest?->body !== null && $request->encapsulatedRequest->body !== '') {
92            return $request->encapsulatedRequest->body;
93        }
94        return null;
95    }
96
97    /**
98     * Which encapsulated section the body belongs to, per RFC 3507 §4.4.
99     * A RESPMOD with a response body uses `res-body`; a REQMOD with a
100     * request body uses `req-body`. An encapsulated response body wins
101     * over a request body when both are present (the response is what
102     * gets modified on the way back).
103     */
104    private function bodyCarrier(IcapRequest $request): string
105    {
106        if ($request->encapsulatedResponse?->body !== null && $request->encapsulatedResponse->body !== '') {
107            return 'res-body';
108        }
109        return 'req-body';
110    }
111
112    private function buildEncapsulatedHeader(
113        bool $hasReqHeaders,
114        int $reqHeaderLength,
115        bool $hasResHeaders,
116        int $resHeaderLength,
117        string $bodyCarrier,
118        bool $hasBody,
119    ): string {
120        $parts = [];
121        $offset = 0;
122
123        if ($hasReqHeaders) {
124            $parts[] = "req-hdr={$offset}";
125            $offset += $reqHeaderLength;
126        }
127        if ($hasResHeaders) {
128            $parts[] = "res-hdr={$offset}";
129            $offset += $resHeaderLength;
130        }
131
132        if ($hasBody) {
133            $parts[] = "{$bodyCarrier}={$offset}";
134        } else {
135            $parts[] = "null-body={$offset}";
136        }
137
138        return implode(', ', $parts);
139    }
140
141    private function renderIcapHead(IcapRequest $request, string $encapsulated): string
142    {
143        $parts = parse_url($request->uri);
144        $host = $parts['host'] ?? '';
145        if (isset($parts['port'])) {
146            $host .= ':' . $parts['port'];
147        }
148
149        $requestLine = sprintf('%s %s ICAP/1.0', $request->method, $request->uri);
150
151        $headers = $request->headers;
152        if (!isset($headers['Host'])) {
153            $headers['Host'] = [$host];
154        }
155        // Encapsulated is computed by the formatter — any user-supplied
156        // value would contradict the actual byte layout.
157        $headers['Encapsulated'] = [$encapsulated];
158
159        $head = $requestLine . "\r\n";
160        // Emit Host first for readability, then the remaining headers in
161        // the order the caller supplied them, then Encapsulated last
162        // (it's the header ICAP parsers rely on).
163        $orderedNames = array_unique(array_merge(['Host'], array_keys($headers), ['Encapsulated']));
164        foreach ($orderedNames as $name) {
165            foreach ($headers[$name] as $value) {
166                $head .= $name . ': ' . $value . "\r\n";
167            }
168        }
169        $head .= "\r\n";
170
171        return $head;
172    }
173
174    private function renderHttpRequestHeaders(HttpRequest $req): string
175    {
176        $block = sprintf('%s %s %s', $req->method, $req->requestTarget, $req->httpVersion) . "\r\n";
177        foreach ($req->headers as $name => $values) {
178            foreach ($values as $value) {
179                $block .= $name . ': ' . $value . "\r\n";
180            }
181        }
182        $block .= "\r\n";
183
184        return $block;
185    }
186
187    private function renderHttpResponseHeaders(HttpResponse $res): string
188    {
189        $block = sprintf('%s %d %s', $res->httpVersion, $res->statusCode, $res->reasonPhrase) . "\r\n";
190        foreach ($res->headers as $name => $values) {
191            foreach ($values as $value) {
192                $block .= $name . ': ' . $value . "\r\n";
193            }
194        }
195        $block .= "\r\n";
196
197        return $block;
198    }
199
200    /**
201     * Emit the body as HTTP/1.1 chunked transfer encoding. Delegates
202     * to {@see ChunkedBodyEncoder} so the strict RFC 3507 §4.5
203     * preview-continue path on the same socket can reuse the exact
204     * same encoding rules.
205     *
206     * @param string|resource $body
207     * @return iterable<string>
208     */
209    private function chunkBody(mixed $body, bool $previewIsComplete): iterable
210    {
211        return (new ChunkedBodyEncoder())->encode($body, $previewIsComplete);
212    }
213}