From 5b2290951792d884acac3fe9e4a5fcbc958c8eb9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 27 Sep 2026 12:55:35 +0100 Subject: [PATCH 1/3] Write the type signatures, route definition, path parameters and types pages --- docs/api/types.md | 20 +++++++++++++ docs/core/type-signatures.md | 43 ++++++++++++++++++++++++++- docs/index.md | 2 +- docs/routing/definition.md | 51 ++++++++++++++++++++++++++++++++- docs/routing/path-parameters.md | 23 ++++++++++++++- 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/docs/api/types.md b/docs/api/types.md index f54cd23..300cc46 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -1 +1,21 @@ # Types + +Everything lives under `Phunkie\Http4p`. Every value is immutable: the `with` methods return a new instance. + +| Type | What it is | +|---|---| +| `Request` | `method`, `uri`, `headers`, `body` (a `Stream`) and `pathParams`; `query($name)` and `queryParams()` read the query string; `withPathParams`, `withBody`, `withHeader` | +| `Response` | `status`, `headers` and `body` (a `Stream`); `withStatus`, `withHeaders`, `withHeader`, `withBody` | +| `Headers` | an immutable map of header names to values: `get`, `put`, `remove`, `toArray`. Built with `Headers([...])`; http4p itself uses lowercase names | +| `Status` | `code` and `reason`; built with `Status($code, $reason)` or `StatusOk()` and the other constructors | +| `Method` | enum: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` | +| `Route` | a method, a pattern and a handler; built with `GET(...)`, `POST(...)` and the rest | +| `Router` | an application over an `ImmList`: `callable(Request): IO` | +| `DecodeFailure` | the exception a body that does not fit raises; `errors()` gives one message per field; the router answers it with `400` | +| `EntityEncoder` | `encode(mixed)` returning a string or a `Stream`, and `contentType()`; `Encoder\JsonEncoder` is the default | +| `Decoder\EntityDecoder` | what `decode($request, Entity::class)` uses; `EntityDecoder::stored($class)` is the form `decodeLines` uses, generated fields included | +| `Server\PhpServer` | serves an application through the SAPI; takes an optional `Server\Output` | +| `Server\Output`, `Server\SapiOutput` | where a response is written: the status, the headers, then each chunk of the body, flushed as it is written | +| `Client\ResponseHead` | the status and headers of a response read by `send`, before any of its body | + +The functions that build and use them are listed in [Functions](functions.md). diff --git a/docs/core/type-signatures.md b/docs/core/type-signatures.md index 5700293..115f070 100644 --- a/docs/core/type-signatures.md +++ b/docs/core/type-signatures.md @@ -1 +1,42 @@ -# Type Yignatures +# Type Signatures + +Http4p describes an application with a handful of types. Reading them explains most of the library. + +## A handler is `Request => IO` + +```php +fn(Request $request): IO // IO +``` + +A handler sends nothing. It returns an `IO` from phunkie/effect: a description of the work, run by the server once the router has chosen the route. That is why a handler can be called in a test with a `Request` and inspected without a server, and why `map`, `flatMap` and `recover` on the `IO` compose the response the way they compose any other effect. + +`Ok`, `Created`, `NotFound` and the other constructors return `IO` too, so a handler built on a query is one chain: + +```php +GET('/users/:id', fn(int $id) => $users->get($id)->flatMap(Ok(...))); // IO +``` + +## An application is `callable(Request): IO` + +`Router` is that callable over an `ImmList`; `Through($app, ...$middleware)` returns another one; `PhpServer` takes one. Anything with the shape of a handler is an application, which is why the client has the same shape: `send(Request): IO`. + +## Middleware is `callable(callable): callable` + +A middleware takes an application and returns an application. `Recover(SomeException::class, $handler)` wraps every route once; see [Middleware](../middleware/basics.md). + +## A body is a `Stream` + +`Request::$body` and `Response::$body` are `Phunkie\Streams\Type\Stream`: a lazy sequence of chunks, read from `php://input` or a socket, or produced by a query, only when the stream is compiled. The server writes it chunk by chunk and a client reads it the same way. `Ok($value)` encodes the value into a one-chunk stream; `Ok($stream)` passes the stream through. See [Streaming](streaming.md). + +## `decode` is `Request => IO` + +`decode($request, Author::class)` yields the entity's fields as an array, `decode($request)` the raw JSON, and a body that does not fit fails the `IO` with a `DecodeFailure`, which the router answers with a `400`. `decodeLines($message, Author::class)` is the streaming form, a `Stream` of entities. See [Entity Decoding](entity-decoding.md). + +## Reading a signature + +| Signature | Meaning | +|---|---| +| `IO` | a response, once the effect runs | +| `IO` failing with `DecodeFailure` | a 400, answered by the router before the handler's work | +| `Stream` | chunks, produced as they are consumed | +| `callable(Request): IO` | a handler, an application, a client | diff --git a/docs/index.md b/docs/index.md index 5528990..0ebef48 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ ### Middleware - [Basics](middleware/basics.md) - How usage works -- [Built-in](middleware/built-in.md) - Logger, Cors +- [Built-in](middleware/built-in.md) - Logger, Cors, Recover - [Custom](middleware/custom.md) - Writing your own ### Examples diff --git a/docs/routing/definition.md b/docs/routing/definition.md index 9cf6ae5..9f18b33 100644 --- a/docs/routing/definition.md +++ b/docs/routing/definition.md @@ -1 +1,50 @@ -# Definition +# Route Definition + +A route is a method, a path pattern and a handler: + +```php +use function Phunkie\Http4p\Functions\HttpRoutes; +use function Phunkie\Http4p\Functions\routes\{DELETE, GET, PATCH, POST, PUT}; + +$routes = HttpRoutes( + GET('/books', fn() => $catalogue->all()->flatMap(Ok(...))), + GET('/books/:id', fn(int $id) => $catalogue->get($id)->flatMap(Ok(...))), + POST('/books', fn(Request $req) => decode($req, Book::class)->flatMap($catalogue->add(...))->flatMap(Created(...))), + PUT('/books/:id', fn(int $id, Request $req) => decode($req, Book::class)->flatMap(fn(array $data) => $catalogue->replace($id, $data))->flatMap(Ok(...))), + PATCH('/books/:id', fn(int $id, Request $req) => decode($req, Book::class)->flatMap(fn(array $data) => $catalogue->change($id, $data))->flatMap(Ok(...))), + DELETE('/books/:id', fn(int $id) => $catalogue->remove($id)->flatMap(fn() => NoContent())), +); +``` + +`HttpRoutes` returns an `ImmList`, and `new Router($routes)` turns it into an application: `callable(Request): IO`. + +## Matching + +The router tries the routes in the order given and takes the first whose method and path both match, so put `/books/export` before `/books/:id` when both exist. The query string is not part of the match. When nothing matches the router answers `404` with `{"error": "Not Found", "path": "..."}`. + +## The handler + +A handler receives the path parameters in the order they appear in the pattern, followed by the `Request` when its last parameter is typed `Request`. It returns `IO`. See [Path Parameters](path-parameters.md) for how the segments are typed and [Response Construction](response-construction.md) for the constructors. + +```php +GET('/authors/:authorId/books/:id', fn(int $authorId, int $id, Request $req) => ...); +``` + +## Errors inside a handler + +An `IO` that fails with a `DecodeFailure` is answered with `400` by the router. Any other exception propagates to the middleware, where `Recover` can answer it, or to `PhpServer`, which answers `500`. + +## A routes file + +The convention for an application is a `routes.php` that receives its dependencies and returns the application, wrapped in middleware with `Through`: + +```php +return fn(Connection $conn): callable => Through( + new Router(HttpRoutes( + GET('/books/:id', fn(int $id) => findOrFail(Book::class, $id)->run($conn)->flatMap(Ok(...))), + )), + Recover(RowNotFound::class, fn(RowNotFound $e) => NotFound(['error' => $e->getMessage()])), +); +``` + +`index.php` includes it and hands the result to `PhpServer`; a test calls the same callable with a `Request`. See [Building a REST API](../examples/rest-api.md). diff --git a/docs/routing/path-parameters.md b/docs/routing/path-parameters.md index c463f52..a4b006b 100644 --- a/docs/routing/path-parameters.md +++ b/docs/routing/path-parameters.md @@ -1 +1,22 @@ -# Path Aarameters +# Path Parameters + +A segment written `:name` in a pattern matches one path segment and is passed to the handler by position: + +```php +GET('/users/:id', fn(int $id) => ...); +GET('/users/:userId/posts/:slug', fn(int $userId, string $slug) => ...); +``` + +The parameters arrive in the order they appear in the pattern, followed by the `Request` when the handler's last parameter is typed `Request`. + +## Types + +A numeric segment is passed as an `int`, any other segment as a `string`. Type the handler's parameter to match: `int $id` for `/users/7`, `string $slug` for `/posts/hello-world`. A parameter typed `int` for a segment that turns out not to be numeric raises a `TypeError`, answered as a `500`, so declare `string` where a segment may be either. + +## On the request + +The extracted parameters are also on the request as `$request->pathParams`, keyed by name, and `decode($request, Book::class)` reads them: a path parameter fills the constructor parameter of the same name ahead of the body, so `POST /authors/:authorId/books` needs no `authorId` in the JSON. See [Entity Decoding](../core/entity-decoding.md). + +## Query strings + +The query string is not part of the pattern. Read it with `$request->query('name')` for one value or `$request->queryParams()` for all of them; see [Request Handling](request-handling.md). From 8792d7604dcdb3f920525dab12e7ff3e89036ba9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Thu, 15 Oct 2026 12:01:39 +0100 Subject: [PATCH 2/3] Send requests over a socket so a body streams out chunked and read 64 KB at a time from php://input --- docs/api/types.md | 2 + docs/core/client.md | 17 ++- src/Client/ResponseBody.php | 172 +++++++++++++++++++++ src/Client/SocketTransport.php | 181 +++++++++++++++++++++++ src/Functions/client.php | 24 ++- src/Server/PhpServer.php | 2 +- tests/Integration/Fixtures/rawserver.php | 62 ++++++++ tests/Integration/TransportTest.php | 178 ++++++++++++++++++++++ tests/Unit/ResponseBodyTest.php | 77 ++++++++++ 9 files changed, 698 insertions(+), 17 deletions(-) create mode 100644 src/Client/ResponseBody.php create mode 100644 src/Client/SocketTransport.php create mode 100644 tests/Integration/Fixtures/rawserver.php create mode 100644 tests/Integration/TransportTest.php create mode 100644 tests/Unit/ResponseBodyTest.php diff --git a/docs/api/types.md b/docs/api/types.md index 300cc46..882b209 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -17,5 +17,7 @@ Everything lives under `Phunkie\Http4p`. Every value is immutable: the `with` me | `Server\PhpServer` | serves an application through the SAPI; takes an optional `Server\Output` | | `Server\Output`, `Server\SapiOutput` | where a response is written: the status, the headers, then each chunk of the body, flushed as it is written | | `Client\ResponseHead` | the status and headers of a response read by `send`, before any of its body | +| `Client\SocketTransport` | what `send` runs: one HTTP/1.1 connection per request, plain or TLS, the request body framed by `Content-Length` or chunked as the stream allows | +| `Client\ResponseBody` | the response body as a `Resource`, decoded chunk by chunk as it is pulled, chunked, counted or until the server closes | The functions that build and use them are listed in [Functions](functions.md). diff --git a/docs/core/client.md b/docs/core/client.md index 236ec9d..45f9c3a 100644 --- a/docs/core/client.md +++ b/docs/core/client.md @@ -19,7 +19,22 @@ implode('', $response->body->compile()->toArray()); // the whole body, when send(Request(Method::POST, 'http://catalogue.internal/books', Headers(['content-type' => 'application/json']), json_encode($book))); ``` -The request goes out and the status and headers are read when the IO runs; the body is not read until the stream is compiled. A failed status is a response like any other: `send` does not throw on a 404 or a 500, only a `RuntimeException` when no connection could be made. The second argument is the timeout in seconds, for connecting and for each read. +The request goes out over its own HTTP/1.1 connection when the IO runs, and the status and headers are read then; the body is not read until the stream is compiled. A failed status is a response like any other: `send` does not throw on a 404 or a 500, only a `RuntimeException` when no connection could be made or the server closed it before answering. Redirects are not followed; a 3xx comes back as it is. The second argument is the timeout in seconds, for connecting and for each read. + +## The transport + +`send` opens a plain socket for `http://` and a TLS socket for `https://`, sends `Connection: close`, and frames the request body by what the stream knows: + +- A body built from a string, or any pure stream, goes out with its `Content-Length`. +- A body backed by a file, a socket or another response goes out with chunked transfer encoding, one chunk written as it is pulled, so an upload of any size streams through without being held: + +```php +use function Phunkie\Streams\Functions\io\readHandle; // streams 1.5 + +send(Request(Method::PUT, 'http://archive.internal/exports/today', null, readHandle(io(fn() => fopen('export.ndjson', 'rb'))))); +``` + +The response body is decoded as the server framed it, chunked, counted by `Content-Length`, or read until the server closes, and the socket is closed when the body ends. A `HEAD`, a `204` or a `304` has no body to pull. ## Reading a large body diff --git a/src/Client/ResponseBody.php b/src/Client/ResponseBody.php new file mode 100644 index 0000000..34d87c1 --- /dev/null +++ b/src/Client/ResponseBody.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Http4p\Client; + +use Phunkie\Streams\IO\Resource; + +/** + * The body of a response read from the socket as it is pulled: chunked transfer encoding decoded + * chunk by chunk, a Content-Length counted down, or everything until the server closes. The socket + * is closed when the body ends. + */ +final class ResponseBody implements Resource +{ + private const CHUNKED = 'chunked'; + private const COUNTED = 'counted'; + private const UNTIL_CLOSE = 'until close'; + + private bool $done = false; + + /** + * @param resource|null $socket + * @param int $remaining bytes left in the current chunk, or in the whole body when counted + */ + private function __construct(private $socket, private string $framing, private int $remaining = 0) + { + $this->done = null === $socket; + } + + public function __destruct() + { + $this->close(); + } + + /** + * @param resource $socket + */ + public static function chunked($socket): self + { + return new self($socket, self::CHUNKED); + } + + /** + * @param resource $socket + */ + public static function ofLength($socket, int $length): self + { + return 0 === $length ? self::none($socket) : new self($socket, self::COUNTED, $length); + } + + /** + * @param resource $socket + */ + public static function untilClose($socket): self + { + return new self($socket, self::UNTIL_CLOSE); + } + + /** + * No body at all, as after a HEAD or a 204; the socket is released. + * + * @param resource $socket + */ + public static function none($socket): self + { + fclose($socket); + + return new self(null, self::UNTIL_CLOSE); + } + + public function pull(int $bytes): mixed + { + if ($this->done) { + return Resource::EOF; + } + + $data = match ($this->framing) { + self::CHUNKED => $this->chunk($bytes), + self::COUNTED => $this->counted($bytes), + default => $this->rest($bytes), + }; + if (Resource::EOF === $data) { + $this->close(); + } + + return $data; + } + + public function close(): void + { + if (is_resource($this->socket)) { + fclose($this->socket); + } + $this->done = true; + } + + private function chunk(int $bytes): string + { + if (0 === $this->remaining && !$this->startChunk()) { + return Resource::EOF; + } + + $data = fread($this->socket, min($bytes, $this->remaining)); + if (false === $data || '' === $data) { + return Resource::EOF; + } + + $this->remaining -= strlen($data); + if (0 === $this->remaining) { + fgets($this->socket); + } + + return $data; + } + + /** + * Read the next chunk's size line; false at the terminating zero-size chunk, after its trailers. + */ + private function startChunk(): bool + { + $line = fgets($this->socket); + if (false === $line) { + return false; + } + + $size = hexdec(trim(explode(';', $line, 2)[0])); + if (0 === $size) { + while (false !== ($trailer = fgets($this->socket)) && '' !== trim($trailer)) { + } + + return false; + } + + $this->remaining = (int) $size; + + return true; + } + + private function counted(int $bytes): string + { + if (0 === $this->remaining) { + return Resource::EOF; + } + + $data = fread($this->socket, min($bytes, $this->remaining)); + if (false === $data || '' === $data) { + return Resource::EOF; + } + + $this->remaining -= strlen($data); + + return $data; + } + + private function rest(int $bytes): string + { + $data = fread($this->socket, $bytes); + if (false === $data || ('' === $data && feof($this->socket))) { + return Resource::EOF; + } + + return $data; + } +} diff --git a/src/Client/SocketTransport.php b/src/Client/SocketTransport.php new file mode 100644 index 0000000..29eeb53 --- /dev/null +++ b/src/Client/SocketTransport.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Http4p\Client; + +use InvalidArgumentException; +use Phunkie\Http4p\Method; +use Phunkie\Http4p\Request; +use Phunkie\Http4p\Response; +use RuntimeException; + +use function Phunkie\Effect\Functions\io\io; + +/** + * Sends a request over its own HTTP/1.1 connection, plain or TLS, and reads the response from it. + * + * The request body streams out: a pure body (built from a string) is sent with its Content-Length, + * a resource-backed body is sent with chunked transfer encoding, one chunk written as it is pulled. + * The response head is read before the body, which is left on the socket to be pulled as a Stream. + * Redirects are not followed; a 3xx comes back like any other status. + */ +final class SocketTransport +{ + public function __construct(private float $timeout = 30.0) + { + } + + public function send(Request $request): Response + { + $url = $this->parsed($request->uri); + $socket = $this->connect($url); + $chunks = \Pure === $request->body->getEffect() ? $request->body->compile()->toArray() : null; + + $this->writeAll($socket, $this->head($request, $url, $chunks)); + $this->writeBody($socket, $request, $chunks); + + $head = ResponseHead::fromWrapperData($this->headLines($socket)); + + return new Response($head->status, $head->headers, \Stream($this->body($socket, $request->method, $head))); + } + + /** + * @return array{scheme: string, host: string, port: int, target: string} + */ + private function parsed(string $uri): array + { + $parts = parse_url($uri); + $scheme = strtolower($parts['scheme'] ?? ''); + if (!in_array($scheme, ['http', 'https'], true) || !isset($parts['host'])) { + throw new InvalidArgumentException(sprintf('send() needs an absolute http or https URL, got "%s".', $uri)); + } + + $target = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''); + + return ['scheme' => $scheme, 'host' => $parts['host'], 'port' => $parts['port'] ?? ('https' === $scheme ? 443 : 80), 'target' => '' === $target ? '/' : $target]; + } + + /** + * @param array{scheme: string, host: string, port: int, target: string} $url + * @return resource + */ + private function connect(array $url) + { + $address = sprintf('%s://%s:%d', 'https' === $url['scheme'] ? 'ssl' : 'tcp', $url['host'], $url['port']); + $socket = @stream_socket_client($address, $errno, $error, $this->timeout); + if (false === $socket) { + throw new RuntimeException(sprintf('Could not connect to %s: %s.', $address, $error ?: 'error ' . $errno)); + } + stream_set_timeout($socket, (int) $this->timeout, (int) (($this->timeout - (int) $this->timeout) * 1000000)); + + return $socket; + } + + /** + * @param array{scheme: string, host: string, port: int, target: string} $url + * @param list|null $chunks the body when its length is known up front + */ + private function head(Request $request, array $url, ?array $chunks): string + { + $defaultPort = 'https' === $url['scheme'] ? 443 : 80; + $lines = [ + sprintf('%s %s HTTP/1.1', $request->method->value, $url['target']), + 'Host: ' . $url['host'] . ($url['port'] === $defaultPort ? '' : ':' . $url['port']), + 'Connection: close', + ]; + foreach ($request->headers->toArray() as $name => $value) { + $lines[] = sprintf('%s: %s', $name, $value); + } + + $length = null === $chunks ? null : array_sum(array_map('strlen', $chunks)); + if (null === $length) { + $lines[] = 'Transfer-Encoding: chunked'; + } elseif ($length > 0) { + $lines[] = 'Content-Length: ' . $length; + } + + return implode("\r\n", $lines) . "\r\n\r\n"; + } + + /** + * @param resource $socket + * @param list|null $chunks + */ + private function writeBody($socket, Request $request, ?array $chunks): void + { + if (null !== $chunks) { + $this->writeAll($socket, implode('', $chunks)); + + return; + } + + $request->body + ->evalTap(fn (string $chunk) => io(fn () => '' === $chunk ? null : $this->writeAll($socket, dechex(strlen($chunk)) . "\r\n" . $chunk . "\r\n"))) + ->compile() + ->drain() + ->unsafeRun(); + $this->writeAll($socket, "0\r\n\r\n"); + } + + /** + * @param resource $socket + */ + private function writeAll($socket, string $data): void + { + while ('' !== $data) { + $written = fwrite($socket, $data); + if (false === $written || 0 === $written) { + throw new RuntimeException('The connection was closed while sending the request.'); + } + $data = substr($data, $written); + } + } + + /** + * The status line and header lines, up to the blank line that ends the head. + * + * @param resource $socket + * @return list + */ + private function headLines($socket): array + { + $lines = []; + while (false !== ($line = fgets($socket))) { + $line = rtrim($line, "\r\n"); + if ('' === $line && [] !== $lines) { + return $lines; + } + if ('' !== $line) { + $lines[] = $line; + } + } + + throw new RuntimeException('The connection was closed before a response arrived.'); + } + + /** + * @param resource $socket + */ + private function body($socket, Method $method, ResponseHead $head): ResponseBody + { + if (Method::HEAD === $method || in_array($head->status->code, [204, 304], true)) { + return ResponseBody::none($socket); + } + + if (str_contains(strtolower($head->headers->get('transfer-encoding') ?? ''), 'chunked')) { + return ResponseBody::chunked($socket); + } + + $length = $head->headers->get('content-length'); + + return null === $length ? ResponseBody::untilClose($socket) : ResponseBody::ofLength($socket, (int) $length); + } +} diff --git a/src/Functions/client.php b/src/Functions/client.php index b1b7e55..ede15f1 100644 --- a/src/Functions/client.php +++ b/src/Functions/client.php @@ -12,33 +12,27 @@ namespace Phunkie\Http4p\Functions\client { use Phunkie\Effect\IO\IO; - use Phunkie\Http4p\Client\ResponseHead; + use Phunkie\Http4p\Client\SocketTransport; use Phunkie\Http4p\Request; - use Phunkie\Http4p\Response; - use Phunkie\Streams\IO\Network\HttpRequest; use function Phunkie\Effect\Functions\io\io; /** * Send a request to another service and describe its response. * - * The request goes out and the status and headers are read when the IO runs. The body is a - * Stream pulled from the connection chunk by chunk as it is compiled, so a large response is + * The request goes out over its own HTTP/1.1 connection when the IO runs: a body built from + * a string is sent with its Content-Length, a resource-backed body streams out chunked, one + * chunk written as it is pulled. The status and headers are read then; the body is a Stream + * pulled from the connection chunk by chunk as it is compiled, so a large response is * consumed in constant memory: compose it with lines(), decodeLines() or any stream operation. + * Redirects are not followed. * * @param Request $request its uri absolute, http:// or https:// - * @param float $timeout seconds to wait for the connection and each read - * @return IO + * @param float $timeout seconds to wait for the connection and for each read + * @return IO<\Phunkie\Http4p\Response> */ function send(Request $request, float $timeout = 30.0): IO { - return io(function () use ($request, $timeout) { - $body = implode('', $request->body->compile()->toArray()); - $http = new HttpRequest($request->uri, $request->method->value, $request->headers->toArray(), '' === $body ? null : $body, $timeout); - $http->execute(); - $head = ResponseHead::fromWrapperData($http->getResponseHeaders()); - - return new Response($head->status, $head->headers, \Stream($http)); - }); + return io(fn () => (new SocketTransport($timeout))->send($request)); } } diff --git a/src/Server/PhpServer.php b/src/Server/PhpServer.php index 7851bbb..f860df9 100644 --- a/src/Server/PhpServer.php +++ b/src/Server/PhpServer.php @@ -82,7 +82,7 @@ public function handleRequest(): IO } // Get body as Stream - $body = \Stream(new Path('php://input')); + $body = \Stream(new Path('php://input'), 65536); return new Request($method, $uri, Headers($headers), $body); })->flatMap(fn ($request) => ($this->handler)($request)) diff --git a/tests/Integration/Fixtures/rawserver.php b/tests/Integration/Fixtures/rawserver.php new file mode 100644 index 0000000..9e4a772 --- /dev/null +++ b/tests/Integration/Fixtures/rawserver.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// A raw HTTP/1.1 server for the client tests: it reports what it received and answers with the +// framings a client has to handle. One connection at a time, started as: php rawserver.php + +$server = stream_socket_server('tcp://127.0.0.1:' . $argv[1]) ?: exit(1); + +$readBody = function ($conn, array $headers): string { + if (str_contains(strtolower($headers['transfer-encoding'] ?? ''), 'chunked')) { + $body = ''; + while (0 < ($size = hexdec(trim((string) fgets($conn))))) { + $chunk = ''; + while (strlen($chunk) < $size) { + $chunk .= fread($conn, $size - strlen($chunk)); + } + $body .= $chunk; + fgets($conn); + } + + return $body; + } + + $body = ''; + while (strlen($body) < (int) ($headers['content-length'] ?? 0)) { + $body .= fread($conn, (int) $headers['content-length'] - strlen($body)); + } + + return $body; +}; + +while ($conn = @stream_socket_accept($server, -1)) { + [$method, $target] = explode(' ', trim((string) fgets($conn))); + $headers = []; + while ('' !== ($line = trim((string) fgets($conn)))) { + [$name, $value] = explode(':', $line, 2); + $headers[strtolower(trim($name))] = trim($value); + } + $body = $readBody($conn, $headers); + + $response = match ($target) { + '/chunked' => "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfirst \r\n7\r\nsecond \r\n5\r\nthird\r\n0\r\n\r\n", + '/until-close' => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nall of it", + '/empty' => "HTTP/1.1 204 No Content\r\n\r\n", + '/missing' => "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found", + default => (function () use ($method, $target, $headers, $body) { + $json = json_encode(['method' => $method, 'target' => $target, 'headers' => $headers, 'bodyLength' => strlen($body), 'bodyHash' => md5($body)]); + + return "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " . strlen($json) . "\r\n\r\n" . $json; + })(), + }; + fwrite($conn, $response); + fclose($conn); +} diff --git a/tests/Integration/TransportTest.php b/tests/Integration/TransportTest.php new file mode 100644 index 0000000..e92d15a --- /dev/null +++ b/tests/Integration/TransportTest.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration; + +use PHPUnit\Framework\TestCase; +use Phunkie\Http4p\Method; +use Phunkie\Http4p\Response; +use Phunkie\Streams\IO\Resource; +use RuntimeException; + +use function Phunkie\Http4p\Functions\client\send; +use function Phunkie\Http4p\Functions\Headers; +use function Phunkie\Http4p\Functions\Request; + +final class TransportTest extends TestCase +{ + /** @var resource */ + private static $server; + + private static string $base; + + public static function setUpBeforeClass(): void + { + $port = self::freePort(); + self::$base = "http://127.0.0.1:$port"; + self::$server = proc_open( + [PHP_BINARY, '-d', 'xdebug.mode=off', __DIR__ . '/Fixtures/rawserver.php', (string) $port], + [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], + $pipes, + ); + self::waitForPort($port); + } + + public static function tearDownAfterClass(): void + { + proc_terminate(self::$server); + proc_close(self::$server); + } + + public function testSendsAStringBodyWithItsContentLength(): void + { + $seen = $this->inspect(Request(Method::POST, self::$base . '/inspect?x=1', Headers(['content-type' => 'text/plain']), 'hello there')); + + $this->assertSame('POST', $seen['method']); + $this->assertSame('/inspect?x=1', $seen['target']); + $this->assertSame('11', $seen['headers']['content-length']); + $this->assertSame('text/plain', $seen['headers']['content-type']); + $this->assertArrayNotHasKey('transfer-encoding', $seen['headers']); + $this->assertSame(11, $seen['bodyLength']); + } + + public function testStreamsAResourceBodyOutChunkedAsItIsPulled(): void + { + $pulled = 0; + $source = new class ($pulled) implements Resource { + private int $left = 300; + + public function __construct(private int &$pulled) + { + } + + public function pull(int $chunkSize): mixed + { + if (0 === $this->left--) { + return Resource::EOF; + } + $this->pulled++; + + return str_repeat('x', 1000); + } + }; + + $seen = $this->inspect(Request(Method::PUT, self::$base . '/upload', null, \Stream($source))); + + $this->assertSame('chunked', $seen['headers']['transfer-encoding']); + $this->assertArrayNotHasKey('content-length', $seen['headers']); + $this->assertSame(300000, $seen['bodyLength']); + $this->assertSame(md5(str_repeat('x', 300000)), $seen['bodyHash']); + $this->assertSame(300, $pulled); + } + + public function testSendsNoBodyHeadersForAGetWithoutABody(): void + { + $seen = $this->inspect(Request(Method::GET, self::$base . '/inspect')); + + $this->assertArrayNotHasKey('content-length', $seen['headers']); + $this->assertArrayNotHasKey('transfer-encoding', $seen['headers']); + $this->assertSame('close', $seen['headers']['connection']); + } + + public function testDecodesAChunkedResponseBody(): void + { + $response = send(Request(Method::GET, self::$base . '/chunked'))->unsafeRun(); + + $this->assertSame('chunked', $response->headers->get('transfer-encoding')); + $this->assertSame('first second third', $this->text($response)); + } + + public function testReadsABodyUntilTheServerCloses(): void + { + $response = send(Request(Method::GET, self::$base . '/until-close'))->unsafeRun(); + + $this->assertSame('all of it', $this->text($response)); + } + + public function testKeepsTheStatusAndReadsNothingAfterA204(): void + { + $response = send(Request(Method::GET, self::$base . '/empty'))->unsafeRun(); + + $this->assertSame(204, $response->status->code); + $this->assertSame('', $this->text($response)); + } + + public function testHandsBackAFailedStatusWithItsBody(): void + { + $response = send(Request(Method::GET, self::$base . '/missing'))->unsafeRun(); + + $this->assertSame(404, $response->status->code); + $this->assertSame('not found', $this->text($response)); + } + + public function testRefusesARelativeUri(): void + { + $this->expectException(\InvalidArgumentException::class); + + send(Request(Method::GET, '/relative'))->unsafeRun(); + } + + public function testFailsWhenNothingListens(): void + { + $this->expectException(RuntimeException::class); + + send(Request(Method::GET, 'http://127.0.0.1:' . self::freePort() . '/'), 1.0)->unsafeRun(); + } + + private function inspect(\Phunkie\Http4p\Request $request): array + { + return json_decode($this->text(send($request)->unsafeRun()), true, flags: JSON_THROW_ON_ERROR); + } + + private function text(Response $response): string + { + return implode('', $response->body->compile()->toArray()); + } + + private static function freePort(): int + { + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $name = stream_socket_get_name($socket, false); + fclose($socket); + + return (int) substr($name, strrpos($name, ':') + 1); + } + + private static function waitForPort(int $port): void + { + for ($attempt = 0; $attempt < 50; $attempt++) { + $connection = @fsockopen('127.0.0.1', $port, $errno, $error, 0.2); + if (false !== $connection) { + fclose($connection); + + return; + } + usleep(100000); + } + + throw new RuntimeException(sprintf('The raw server did not come up on port %d.', $port)); + } +} diff --git a/tests/Unit/ResponseBodyTest.php b/tests/Unit/ResponseBodyTest.php new file mode 100644 index 0000000..d27f601 --- /dev/null +++ b/tests/Unit/ResponseBodyTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit; + +use PHPUnit\Framework\TestCase; +use Phunkie\Http4p\Client\ResponseBody; +use Phunkie\Streams\IO\Resource; + +final class ResponseBodyTest extends TestCase +{ + public function testDecodesChunkedTransferEncodingHoweverSmallThePulls(): void + { + $body = ResponseBody::chunked($this->socketWith("5\r\nhello\r\n7;ext=1\r\n, world\r\n0\r\nTrailer: x\r\n\r\n")); + + $this->assertSame('hello, world', $this->drain($body, 3)); + } + + public function testCountsAContentLengthDownAndStopsThere(): void + { + $body = ResponseBody::ofLength($this->socketWith('abcdefXXX'), 6); + + $this->assertSame('abcdef', $this->drain($body, 4)); + } + + public function testReadsUntilTheServerCloses(): void + { + $this->assertSame('whole body', $this->drain(ResponseBody::untilClose($this->socketWith('whole body')), 4)); + } + + public function testHasNothingToPullAfterAHeadOrA204(): void + { + $body = ResponseBody::none($this->socketWith('ignored')); + + $this->assertSame(Resource::EOF, $body->pull(10)); + } + + public function testClosesTheSocketOnceTheBodyEnds(): void + { + $socket = $this->socketWith("2\r\nok\r\n0\r\n\r\n"); + $body = ResponseBody::chunked($socket); + + $this->drain($body, 10); + + $this->assertFalse(is_resource($socket)); + } + + /** + * @return resource + */ + private function socketWith(string $bytes) + { + $socket = fopen('php://memory', 'r+'); + fwrite($socket, $bytes); + rewind($socket); + + return $socket; + } + + private function drain(ResponseBody $body, int $bytes): string + { + $read = ''; + while (Resource::EOF !== ($chunk = $body->pull($bytes))) { + $read .= $chunk; + } + + return $read; + } +} From bf3e3d199fda07310fce51782b091f6e876740f1 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Thu, 15 Oct 2026 12:02:29 +0100 Subject: [PATCH 3/3] Keep the socket typed as a resource in the response body --- src/Client/ResponseBody.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Client/ResponseBody.php b/src/Client/ResponseBody.php index 34d87c1..c98af0f 100644 --- a/src/Client/ResponseBody.php +++ b/src/Client/ResponseBody.php @@ -11,14 +11,14 @@ namespace Phunkie\Http4p\Client; -use Phunkie\Streams\IO\Resource; +use Phunkie\Streams\IO\Resource as Source; /** * The body of a response read from the socket as it is pulled: chunked transfer encoding decoded * chunk by chunk, a Content-Length counted down, or everything until the server closes. The socket * is closed when the body ends. */ -final class ResponseBody implements Resource +final class ResponseBody implements Source { private const CHUNKED = 'chunked'; private const COUNTED = 'counted'; @@ -79,7 +79,7 @@ public static function none($socket): self public function pull(int $bytes): mixed { if ($this->done) { - return Resource::EOF; + return Source::EOF; } $data = match ($this->framing) { @@ -87,7 +87,7 @@ public function pull(int $bytes): mixed self::COUNTED => $this->counted($bytes), default => $this->rest($bytes), }; - if (Resource::EOF === $data) { + if (Source::EOF === $data) { $this->close(); } @@ -105,12 +105,12 @@ public function close(): void private function chunk(int $bytes): string { if (0 === $this->remaining && !$this->startChunk()) { - return Resource::EOF; + return Source::EOF; } $data = fread($this->socket, min($bytes, $this->remaining)); if (false === $data || '' === $data) { - return Resource::EOF; + return Source::EOF; } $this->remaining -= strlen($data); @@ -147,12 +147,12 @@ private function startChunk(): bool private function counted(int $bytes): string { if (0 === $this->remaining) { - return Resource::EOF; + return Source::EOF; } $data = fread($this->socket, min($bytes, $this->remaining)); if (false === $data || '' === $data) { - return Resource::EOF; + return Source::EOF; } $this->remaining -= strlen($data); @@ -164,7 +164,7 @@ private function rest(int $bytes): string { $data = fread($this->socket, $bytes); if (false === $data || ('' === $data && feof($this->socket))) { - return Resource::EOF; + return Source::EOF; } return $data;