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
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"require": {
"php": "^8.2 || ^8.3 || ^8.4",
"phunkie/phunkie": "^1.5",
"phunkie/effect": "^1.3",
"phunkie/streams": "^1.2"
"phunkie/effect": "^1.4",
"phunkie/streams": "^1.3"
},
"require-dev": {
"phpunit/phpunit": "^10.0 || ^11.0",
Expand Down
28 changes: 14 additions & 14 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/api/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Every helper lives under `Phunkie\Http4p\Functions`. Import what you use with `u
|-----------|-----------|
| `Phunkie\Http4p\Functions` | `HttpRoutes`, `Request`, `Response`, `Headers`, `Status`, `StatusOk` and the other status constructors, `decode`, `FileResponse` |
| `Phunkie\Http4p\Functions\routes` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
| `Phunkie\Http4p\Functions\response` | `Ok`, `Created`, `Accepted`, `NoContent`, `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `InternalServerError` |
| `Phunkie\Http4p\Functions\response` | `Ok`, `Created`, `Accepted`, `NoContent`, `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `InternalServerError` |
| `Phunkie\Http4p\Functions\decoding` | `json` |
| `Phunkie\Http4p\Functions\middleware` | `Through`, `Logger`, `Cors` |
| `Phunkie\Http4p\Functions\headers` | `get`, `put`, `remove` |
Expand Down
16 changes: 15 additions & 1 deletion docs/core/entity-decoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,22 @@ POST('/authors', fn(Request $req) =>

- A key matches a parameter by its exact name or by its snake_case form, so `published_year` fills `$publishedYear`.
- Parameters marked `#[Generated]` (the attribute from phunkie/phetch, read by name so http4p does not depend on it) are never expected and are dropped if sent; so are unknown keys.
- Values the route already knows fill the parameter of the same name ahead of the body and are never required from it: the request's path parameters, so `POST /authors/:authorId/books` fills `$authorId`, and anything passed as the third argument, `decode($req, Book::class, ['ownerId' => $user->id])`.
- On `POST` and `PUT` every parameter without a default that is not nullable is required. A `PATCH` may carry any subset, as long as one field is known.
- Values must match the declared types as JSON provides them: no coercion, so `"1843"` is not an `int`. Nested entities are accepted as JSON objects.
- Scalars must match the declared types as JSON provides them: no coercion, so `"1843"` is not an `int`. Nested entities are accepted as JSON objects.
- A parameter typed with a backed enum is built with `tryFrom`, one typed with a date class from the string, and one typed with any other class from the scalar through its constructor. An `InvalidArgumentException` thrown by that constructor becomes the field's error, so a value object such as `Isbn` or `Email` carries its own validation:

```php
final readonly class Isbn
{
public function __construct(public string $value)
{
if (!preg_match('/^\d{13}$/', $value)) {
throw new InvalidArgumentException('must be 13 digits');
}
}
}
```

A body that does not fit fails with a `DecodeFailure` carrying one message per field, and the router answers with a `400`:

Expand Down
85 changes: 69 additions & 16 deletions src/Decoder/EntityDecoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@

namespace Phunkie\Http4p\Decoder;

use BackedEnum;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;
use InvalidArgumentException;
use Phunkie\Http4p\DecodeFailure;
use Phunkie\Http4p\Method;
use ReflectionClass;
Expand All @@ -25,20 +30,27 @@
* Decodes a JSON object into the fields of an entity, keyed by constructor parameter name.
*
* A key matches a parameter by its exact name or by its snake_case form. Unknown keys and
* parameters marked #[Generated] are dropped. On POST and PUT every parameter without a default
* that is not nullable is required; a PATCH may carry any subset, as long as one field is known.
* Values must match the declared parameter types as JSON gives them, with no coercion.
* parameters marked #[Generated] are dropped. Values the route already knows, the request's path
* parameters and anything the caller provides, fill the parameter of the same name ahead of the body.
* On POST and PUT every parameter without a default that is not nullable is required; a PATCH may
* carry any subset, as long as one field is known.
*
* Scalars must match the declared type as JSON gives them, with no coercion. A parameter typed with
* a backed enum, a date or any class with a single-argument constructor is built from the scalar,
* and an InvalidArgumentException thrown by that constructor becomes the field's error.
*/
final class EntityDecoder
{
private const GENERATED = 'Phunkie\Phetch\Attributes\Generated';

/**
* @param class-string $class
* @param array<string, mixed> $known values the route already has, keyed by parameter name
*/
public function __construct(
private string $class,
private Method $method,
private array $known = [],
) {
}

Expand Down Expand Up @@ -68,23 +80,27 @@ private function fields(array $json): array
continue;
}

$name = $param->getName();
if (array_key_exists($name, $this->known)) {
$fields[$name] = $this->known[$name];

continue;
}

$key = $this->keyFor($param, $json);
if (null === $key) {
if ($this->isRequired($param)) {
$errors[$param->getName()] = 'missing';
$errors[$name] = 'missing';
}

continue;
}

$mismatch = $this->typeMismatch($param, $json[$key]);
if (null !== $mismatch) {
$errors[$param->getName()] = $mismatch;

continue;
try {
$fields[$name] = $this->valueFor($param, $json[$key]);
} catch (InvalidArgumentException $e) {
$errors[$name] = $e->getMessage();
}

$fields[$param->getName()] = $json[$key];
}

if ([] !== $errors) {
Expand Down Expand Up @@ -134,25 +150,62 @@ private function keyFor(ReflectionParameter $param, array $json): ?string
return null;
}

private function typeMismatch(ReflectionParameter $param, mixed $value): ?string
/**
* The value to hand to the parameter, or an InvalidArgumentException saying why the JSON value will not do.
*/
private function valueFor(ReflectionParameter $param, mixed $value): mixed
{
$type = $param->getType();
if (null === $type) {
return null;
return $value;
}

if (null === $value) {
return $type->allowsNull() ? null : sprintf('expected %s, got null', $type);
if ($type->allowsNull()) {
return null;
}

throw new InvalidArgumentException(sprintf('expected %s, got null', $type));
}

$names = $this->typeNames($type);
foreach ($names as $name) {
if ($this->accepts($name, $value)) {
return null;
return $value;
}
}

foreach ($names as $name) {
if (is_scalar($value) && class_exists($name)) {
return $this->construct($name, $value);
}
}

throw new InvalidArgumentException(sprintf('expected %s, got %s', implode('|', $names), get_debug_type($value)));
}

/**
* @param class-string $class
*/
private function construct(string $class, int|float|string|bool $value): object
{
if (is_subclass_of($class, BackedEnum::class)) {
return $class::tryFrom($value) ?? throw new InvalidArgumentException(sprintf(
'expected one of %s, got %s',
implode(', ', array_map(fn (BackedEnum $case) => $case->value, $class::cases())),
json_encode($value),
));
}

if (is_a($class, DateTimeInterface::class, true)) {
try {
return new ($class === DateTimeInterface::class ? DateTimeImmutable::class : $class)((string) $value);
} catch (Exception) {
throw new InvalidArgumentException(sprintf('expected a date, got %s', json_encode($value)));
}
}

return sprintf('expected %s, got %s', implode('|', $names), get_debug_type($value));
return new $class($value);
}

/**
Expand Down
20 changes: 19 additions & 1 deletion src/Encoder/JsonEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

namespace Phunkie\Http4p\Encoder;

use BackedEnum;
use DateTimeInterface;
use JsonSerializable;
use Phunkie\Http4p\EntityEncoder;

/**
Expand All @@ -26,7 +29,22 @@ public function encode(mixed $value): string
return $value;
}

return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return json_encode($this->normalized($value), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

/**
* The value with every date replaced by its ISO 8601 form and every backed enum by its value, however deep.
*/
private function normalized(mixed $value): mixed
{
return match (true) {
$value instanceof BackedEnum => $value->value,
$value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM),
$value instanceof JsonSerializable => $this->normalized($value->jsonSerialize()),
is_array($value) => array_map(fn ($item) => $this->normalized($item), $value),
is_object($value) => array_map(fn ($item) => $this->normalized($item), get_object_vars($value)),
default => $value,
};
}

public function contentType(): string
Expand Down
8 changes: 6 additions & 2 deletions src/Functions/decoding.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,18 @@
*
* The IO fails with a DecodeFailure when the body cannot be decoded; the router turns that into a 400.
*
* When decoding into an entity, the request's path parameters and $known fill the constructor
* parameters of the same name ahead of the body, so a route can supply what the client must not.
*
* @param class-string|callable(string): mixed|null $decoder
* @param array<string, mixed> $known values keyed by constructor parameter name
* @return IO<mixed>
*/
function decode(Request $request, string|callable|null $decoder = null): IO
function decode(Request $request, string|callable|null $decoder = null, array $known = []): IO
{
$decoder = match (true) {
null === $decoder => json(),
is_string($decoder) => new EntityDecoder($decoder, $request->method),
is_string($decoder) => new EntityDecoder($decoder, $request->method, $known + $request->pathParams),
default => $decoder,
};

Expand Down
14 changes: 14 additions & 0 deletions src/Functions/response.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function Response(Status $status, ?Headers $headers = null, mixed $body = ''): R

use function Phunkie\Http4p\Functions\StatusAccepted;
use function Phunkie\Http4p\Functions\StatusBadRequest;
use function Phunkie\Http4p\Functions\StatusConflict;
use function Phunkie\Http4p\Functions\StatusCreated;
use function Phunkie\Http4p\Functions\StatusForbidden;
use function Phunkie\Http4p\Functions\StatusInternalServerError;
Expand Down Expand Up @@ -157,6 +158,19 @@ function NotFound(mixed $body = null, ?EntityEncoder $encoder = null): IO
return createResponse(StatusNotFound(), $body, $encoder);
}

/**
* Create a 409 Conflict response.
*
* @template A
* @param A $body
* @param EntityEncoder|null $encoder
* @return IO<ResponseClass>
*/
function Conflict(mixed $body = null, ?EntityEncoder $encoder = null): IO
{
return createResponse(StatusConflict(), $body, $encoder);
}

/**
* Create a 500 Internal Server Error response.
*
Expand Down
22 changes: 22 additions & 0 deletions src/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ public function __construct(
public array $pathParams = []
) {}

/**
* The value of one query string parameter, or null when absent.
*/
public function query(string $name): ?string
{
$value = $this->queryParams()[$name] ?? null;

return is_string($value) ? $value : null;
}

/**
* The query string parsed into an array, empty when there is none.
*
* @return array<string, mixed>
*/
public function queryParams(): array
{
parse_str((string) parse_url($this->uri, PHP_URL_QUERY), $params);

return $params;
}

public function withPathParams(array $params): self
{
return new self($this->method, $this->uri, $this->headers, $this->body, $params);
Expand Down
Loading
Loading