Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.61% |
71 / 72 |
|
87.50% |
7 / 8 |
CRAP | |
0.00% |
0 / 1 |
| RequestFormatter | |
98.61% |
71 / 72 |
|
87.50% |
7 / 8 |
30 | |
0.00% |
0 / 1 |
| format | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
6 | |||
| resolveBodySource | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| bodyCarrier | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| buildEncapsulatedHeader | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| renderIcapHead | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
5.01 | |||
| renderHttpRequestHeaders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| renderHttpResponseHeaders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| chunkBody | |
100.00% |
1 / 1 |
|
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 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap; |
| 22 | |
| 23 | use Ndrstmr\Icap\DTO\HttpRequest; |
| 24 | use Ndrstmr\Icap\DTO\HttpResponse; |
| 25 | use 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 | */ |
| 42 | final 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 | } |