Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 1.2.2 under development

- no changes in this release.
- Enh #43: Refactor `RequestBodyParser` middleware fallback handling (@olegbaturin)

## 1.2.1 December 21, 2025

Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,52 @@ the middleware:
$requestBodyParser = $requestBodyParser->withParser('application/myformat', MyFormatParser::class);
```

## Errors handling

To handle parsing errors, pass an instance of `BadRequestHandlerInterface` to the `RequestBodyParser` middleware's constructor.
The [Yii Request Body Parser](yiisoft/request-body-parser) package provides the `BadRequestHandler` error handler by default.

To use it, pass it to the `RequestBodyParser` middleware constructor.

```php
use Psr\Http\Message\ResponseFactoryInterface;
use Yiisoft\Request\Body\RequestBodyParser;
use Yiisoft\Request\Body\BadRequestHandler;

$responseFactory = $container->get(ResponseFactoryInterface::class);
$BadRequestHandler = new BadRequestHandler($responseFactory);

$middleware = new RequestBodyParser(
$container,
$badRequestHandler // pass the handler here
);
```

or define the `BadRequestHandlerInterface` configuration in the [DI container](https://github.com/yiisoft/di):

```php
// config/web/di/request-body-parser.php

use Yiisoft\Request\Body\BadRequestHandler;
use Yiisoft\Request\Body\BadRequestHandlerInterface;

return [
BadRequestHandlerInterface::class => BadRequestHandler::class,
];
```

> In case [Yii Request Body Parser](yiisoft/request-body-parser) package is used along with [Yii Config](https://github.com/yiisoft/config) plugin, the package is [configured](./config/di-web.php)
automatically to use `BadRequestHandler`.

To disable parsing errors handling pass `null` as the `badRequestHandler` parameter of the `RequestBodyParser` middleware constructor:

```php
$middleware = new RequestBodyParser(
$container,
null // disables parsing errors handling
);
```

## Documentation

- [Internals](docs/internals.md)
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"roave/infection-static-analysis-plugin": "^1.35",
"spatie/phpunit-watcher": "^1.24",
"vimeo/psalm": "^5.26.1 || ^6.13.1",
"yiisoft/di": "^1.4",
"yiisoft/test-support": "^3.0.2"
},
"autoload": {
Expand Down
10 changes: 10 additions & 0 deletions config/di-web.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

use Yiisoft\Request\Body\BadRequestHandler;
use Yiisoft\Request\Body\BadRequestHandlerInterface;

return [
BadRequestHandlerInterface::class => BadRequestHandler::class,
];
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

<source>
<include>
<directory suffix=".php">./config</directory>
<directory suffix=".php">./src</directory>
</include>
</source>
Expand Down
4 changes: 2 additions & 2 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
errorLevel="1"
findUnusedBaselineEntry="true"
findUnusedCode="false"
ensureOverrideAttribute="false"
strictBinaryOperands="false"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
Expand All @@ -17,5 +15,7 @@
</projectFiles>
<issueHandlers>
<MixedAssignment errorLevel="suppress" />
<RiskyTruthyFalsyComparison errorLevel="suppress" />
<MissingOverrideAttribute errorLevel="suppress" />
</issueHandlers>
</psalm>
17 changes: 4 additions & 13 deletions src/BadRequestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,24 @@
*/
final class BadRequestHandler implements BadRequestHandlerInterface
{
private ?ParserException $parserException = null;

public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly ResponseFactoryInterface $responseFactory
) {
}

public function handle(ServerRequestInterface $request): ResponseInterface
public function handle(ServerRequestInterface $request, ?ParserException $e = null): ResponseInterface
{
$response = $this->responseFactory->createResponse(Status::BAD_REQUEST);
$response
->getBody()
->write(Status::TEXTS[Status::BAD_REQUEST]);

if ($this->parserException !== null) {
if ($e !== null) {
$response
->getBody()
->write("\n" . $this->parserException->getMessage());
->write("\n" . $e->getMessage());
}

return $response;
}

public function withParserException(ParserException $e): BadRequestHandlerInterface
{
$new = clone $this;
$new->parserException = $e;
return $new;
}
}
11 changes: 5 additions & 6 deletions src/BadRequestHandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@

namespace Yiisoft\Request\Body;

use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
* Forms response when there is an error parsing request.
*/
interface BadRequestHandlerInterface extends RequestHandlerInterface
interface BadRequestHandlerInterface
{
/**
* Creates new instance of handler with parser exception set.
* Handles a `ParserException` object and produces a response.
*
* @param ParserException $e Exception occurred during parsing request.
*
* @return self
*/
public function withParserException(ParserException $e): self;
public function handle(ServerRequestInterface $request, ?ParserException $e = null): ResponseInterface;
}
2 changes: 1 addition & 1 deletion src/ParserException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
*
* @psalm-suppress ClassMustBeFinal We want to allow extending this exception.
*/
class ParserException extends \RuntimeException
final class ParserException extends \RuntimeException
{
}
80 changes: 35 additions & 45 deletions src/RequestBodyParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
Expand All @@ -16,8 +15,13 @@
use Yiisoft\Request\Body\Parser\JsonParser;

use function array_key_exists;
use function count;
use function explode;
use function is_array;
use function is_object;
use function str_contains;
use function strtolower;
use function trim;

/**
* The package is a PSR-15 middleware that allows parsing PSR-7 server request body selecting the parser according
Expand All @@ -28,23 +32,47 @@
*/
final class RequestBodyParser implements MiddlewareInterface
{
private BadRequestHandlerInterface $badRequestHandler;

/**
* @var string[]
* @psalm-var array<string, string>
*/
private array $parsers = [
'application/json' => JsonParser::class,
];
private bool $ignoreBadRequestBody = false;

/**
* @param ContainerInterface $container PSR-11 container to create parsers.
* @param BadRequestHandlerInterface|null $badRequestHandler Handler for request parsing errors.
* If set to `null`, the request will continue processing unaltered.
*/
public function __construct(
ResponseFactoryInterface $responseFactory,
private readonly ContainerInterface $container,
BadRequestHandlerInterface|null $badRequestHandler = null
private readonly ?BadRequestHandlerInterface $badRequestHandler
) {
$this->badRequestHandler = $badRequestHandler ?? new BadRequestHandler($responseFactory);
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$parser = $this->getParser($this->getContentType($request));
if ($parser !== null) {
try {
/** @var mixed $parsed */
$parsed = $parser->parse((string)$request->getBody());
if ($parsed !== null && !is_object($parsed) && !is_array($parsed)) {
$parserClass = $parser::class;
throw new RuntimeException(
"$parserClass::parse() return value must be an array, an object, or null."
);
}
$request = $request->withParsedBody($parsed);
} catch (ParserException $e) {
if ($this->badRequestHandler !== null) {
return $this->badRequestHandler->handle($request, $e);
}
}
}

return $handler->handle($request);
}

/**
Expand Down Expand Up @@ -83,53 +111,15 @@
$new = clone $this;
if (count($mimeTypes) === 0) {
$new->parsers = [];
return $new;

Check warning on line 114 in src/RequestBodyParser.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.2-ubuntu-latest

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ $new = clone $this; if (count($mimeTypes) === 0) { $new->parsers = []; - return $new; + } foreach ($mimeTypes as $mimeType) { $this->validateMimeType($mimeType);
}
foreach ($mimeTypes as $mimeType) {
$this->validateMimeType($mimeType);

Check warning on line 117 in src/RequestBodyParser.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.2-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ return $new; } foreach ($mimeTypes as $mimeType) { - $this->validateMimeType($mimeType); + unset($new->parsers[$this->normalizeMimeType($mimeType)]); } return $new;
unset($new->parsers[$this->normalizeMimeType($mimeType)]);
}
return $new;
}

/**
* Makes the middleware to simple skip requests it cannot parse.
*
* @return self
*/
public function ignoreBadRequestBody(): self
{
$new = clone $this;
$new->ignoreBadRequestBody = true;
return $new;
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$parser = $this->getParser($this->getContentType($request));
if ($parser !== null) {
try {
/** @var mixed $parsed */
$parsed = $parser->parse((string)$request->getBody());
if ($parsed !== null && !is_object($parsed) && !is_array($parsed)) {
$parserClass = $parser::class;
throw new RuntimeException(
"$parserClass::parse() return value must be an array, an object, or null."
);
}
$request = $request->withParsedBody($parsed);
} catch (ParserException $e) {
if (!$this->ignoreBadRequestBody) {
return $this->badRequestHandler
->withParserException($e)
->handle($request);
}
}
}

return $handler->handle($request);
}

/**
* @psalm-suppress MixedInferredReturnType, MixedReturnStatement
*/
Expand All @@ -144,12 +134,12 @@
private function getContentType(ServerRequestInterface $request): ?string
{
$contentType = $request->getHeaderLine(Header::CONTENT_TYPE);
if (trim($contentType) !== '') {

Check warning on line 137 in src/RequestBodyParser.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.2-ubuntu-latest

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ private function getContentType(ServerRequestInterface $request): ?string { $contentType = $request->getHeaderLine(Header::CONTENT_TYPE); - if (trim($contentType) !== '') { + if ($contentType !== '') { if (str_contains($contentType, ';')) { $contentTypeParts = explode(';', $contentType, 2); return strtolower(trim($contentTypeParts[0]));
if (str_contains($contentType, ';')) {
$contentTypeParts = explode(';', $contentType, 2);
return strtolower(trim($contentTypeParts[0]));
}
return strtolower(trim($contentType));

Check warning on line 142 in src/RequestBodyParser.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.2-ubuntu-latest

Escaped Mutant for Mutator "UnwrapTrim": @@ @@ $contentTypeParts = explode(';', $contentType, 2); return strtolower(trim($contentTypeParts[0])); } - return strtolower(trim($contentType)); + return strtolower($contentType); } return null; }
}
return null;
}
Expand Down
14 changes: 10 additions & 4 deletions tests/BadRequestHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,29 @@
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\Http\Status;
use Yiisoft\Request\Body\BadRequestHandler;
use Yiisoft\Request\Body\ParserException;

final class BadRequestHandlerTest extends TestCase
{
public function testShouldReturnCode400(): void
public function testShouldReturnCode400AndCorrectErrorInBody(): void
{
$response = $this
->createHandler()
->handle($this->createRequest());

$this->assertEquals(Status::BAD_REQUEST, $response->getStatusCode());
$this->assertEquals(Status::TEXTS[Status::BAD_REQUEST], (string)$response->getBody());
}

public function testShouldReturnCorrectErrorInBody(): void
public function testShouldReturnCode400AndExceptionMessageInBody(): void
{
$e = new ParserException('JGURDA');
$response = $this
->createHandler()
->handle($this->createRequest());
$this->assertEquals(Status::TEXTS[Status::BAD_REQUEST], (string)$response->getBody());
->handle($this->createRequest(), $e);

$this->assertEquals(Status::BAD_REQUEST, $response->getStatusCode());
$this->assertEquals(Status::TEXTS[Status::BAD_REQUEST] . "\n" . $e->getMessage(), (string)$response->getBody());
}

private function createHandler(): BadRequestHandler
Expand Down
41 changes: 41 additions & 0 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Request\Body\Tests;

use HttpSoft\Message\ResponseFactory;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseFactoryInterface;
use Yiisoft\Di\Container;
use Yiisoft\Di\ContainerConfig;
use Yiisoft\Request\Body\BadRequestHandler;
use Yiisoft\Request\Body\BadRequestHandlerInterface;

use function dirname;

final class ConfigTest extends TestCase
{
public function testBadRequestHandler(): void
{
$container = $this->createContainer();

$requestHandler = $container->get(BadRequestHandlerInterface::class);
$this->assertInstanceOf(BadRequestHandler::class, $requestHandler);
}

private function createContainer(): Container
{
return new Container(
ContainerConfig::create()->withDefinitions([
ResponseFactoryInterface::class => ResponseFactory::class,
...$this->getContainerDefinitions(),
])
);
}

private function getContainerDefinitions(): array
{
return require dirname(__DIR__) . '/config/di-web.php';
}
}
Loading
Loading