Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/api/types.md
Original file line number Diff line number Diff line change
@@ -1 +1,23 @@
# 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<Route>`: `callable(Request): IO<Response>` |
| `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 |
| `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).
17 changes: 16 additions & 1 deletion docs/core/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 42 additions & 1 deletion docs/core/type-signatures.md
Original file line number Diff line number Diff line change
@@ -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<Response>`

```php
fn(Request $request): IO // IO<Response>
```

A handler sends nothing. It returns an `IO<Response>` 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<Response>` too, so a handler built on a query is one chain:

```php
GET('/users/:id', fn(int $id) => $users->get($id)->flatMap(Ok(...))); // IO<Response>
```

## An application is `callable(Request): IO<Response>`

`Router` is that callable over an `ImmList<Route>`; `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<Response>`.

## 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<mixed>`

`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<Response>` | a response, once the effect runs |
| `IO<Response>` failing with `DecodeFailure` | a 400, answered by the router before the handler's work |
| `Stream` | chunks, produced as they are consumed |
| `callable(Request): IO<Response>` | a handler, an application, a client |
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion docs/routing/definition.md
Original file line number Diff line number Diff line change
@@ -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<Route>`, and `new Router($routes)` turns it into an application: `callable(Request): IO<Response>`.

## 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<Response>`. 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).
23 changes: 22 additions & 1 deletion docs/routing/path-parameters.md
Original file line number Diff line number Diff line change
@@ -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).
172 changes: 172 additions & 0 deletions src/Client/ResponseBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

/*
* This file is part of Phunkie Http4p.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* 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 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 Source
{
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 Source::EOF;
}

$data = match ($this->framing) {
self::CHUNKED => $this->chunk($bytes),
self::COUNTED => $this->counted($bytes),
default => $this->rest($bytes),
};
if (Source::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 Source::EOF;
}

$data = fread($this->socket, min($bytes, $this->remaining));
if (false === $data || '' === $data) {
return Source::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 Source::EOF;
}

$data = fread($this->socket, min($bytes, $this->remaining));
if (false === $data || '' === $data) {
return Source::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 Source::EOF;
}

return $data;
}
}
Loading
Loading