Skip to content

Commit

Permalink
Use request_parse_body() when available instead of polyfill.
Browse files Browse the repository at this point in the history
  • Loading branch information
Kekos committed Dec 27, 2024
1 parent 9d5698e commit de59856
Show file tree
Hide file tree
Showing 11 changed files with 462 additions and 18 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

PSR-15 middleware for parsing requests with JSON and URI encoded bodies regardless of HTTP method.

Version 2 calls `request_parse_body()` when applicable, which is when a webserver SAPI is used.
This excludes running in test runners or CLI. It's also important to *not* consume the
`php://input` stream or else an `\Kekos\ParseRequestBodyMiddleware\ParserException` will be thrown.

## Install

You can install this package via [Composer](http://getcomposer.org/):
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"scripts": {
"test": "phpunit",
"stan": "phpstan analyse src tests -l 8"
"stan": "phpstan analyse src tests -l 8",
"web-test": "php -S 127.0.0.1:8001 -t web-tests"
}
}
4 changes: 4 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ parameters:
message: '#jsonDecode|urlQueryDecode#'
path: src/Parser.php
identifier: missingType.iterableValue
-
message: '#should return#'
path: src/UploadedFileCollectionFactory.php
identifier: return.type
99 changes: 83 additions & 16 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@
use function json_last_error;
use function json_last_error_msg;
use function parse_str;
use function request_parse_body;

use const JSON_ERROR_NONE;
use const PHP_VERSION_ID;

class Parser
{
private UploadedFileCollectionFactory $file_collection_factory;

public function __construct(
private UploadedFileFactoryInterface $uploaded_file_factory,
private StreamFactoryInterface $stream_factory,
)
{
) {
$this->file_collection_factory = new UploadedFileCollectionFactory(
$this->uploaded_file_factory,
$this->stream_factory,
);
}

public function process(ServerRequestInterface $request): ServerRequestInterface
Expand All @@ -31,21 +38,66 @@ public function process(ServerRequestInterface $request): ServerRequestInterface
if ($content_type === 'application/json') {
$request = $request->withParsedBody($this->jsonDecode((string) $request->getBody()));
} elseif ($request->getMethod() !== 'POST') {
switch ($content_type) {
case 'application/x-www-form-urlencoded':
$request = $request->withParsedBody($this->urlQueryDecode((string) $request->getBody()));
break;

case 'multipart/form-data':
$multipart_parser = MultipartFormDataParser::createFromRequest(
$request,
$this->uploaded_file_factory,
$this->stream_factory
);

$request = $multipart_parser->decorateRequest($request);
break;
if (self::usePolyfill()) {
return $this->parseWithPolyfill($request, $content_type);
}

return $this->parse($request, $content_type);
}

return $request;
}

private function parse(ServerRequestInterface $request, string $content_type): ServerRequestInterface
{
$has_content_length = ((int) ($_SERVER['HTTP_CONTENT_LENGTH'] ?? 0)) > 0;

switch ($content_type) {
case 'application/x-www-form-urlencoded':
[$post_body] = request_parse_body();

if (!$post_body && $has_content_length) {
throw ParserException::unexpectedEmptyParsedBody();
}

$request = $request->withParsedBody($post_body);
break;

case 'multipart/form-data':
[$post_body, $files] = request_parse_body();

if (!$post_body && !$files && $has_content_length) {
throw ParserException::unexpectedEmptyParsedBody();
}

$files = $this->file_collection_factory->fromPhpFilesArray($files);

$request = $request
->withParsedBody($post_body)
->withUploadedFiles($files)
;
break;
}

return $request;
}

private function parseWithPolyfill(ServerRequestInterface $request, string $content_type): ServerRequestInterface
{
switch ($content_type) {
case 'application/x-www-form-urlencoded':
$request = $request->withParsedBody($this->urlQueryDecode((string) $request->getBody()));
break;

case 'multipart/form-data':
$multipart_parser = MultipartFormDataParser::createFromRequest(
$request,
$this->uploaded_file_factory,
$this->stream_factory
);

$request = $multipart_parser->decorateRequest($request);
break;
}

return $request;
Expand All @@ -68,4 +120,19 @@ private function urlQueryDecode(string $url_query): array

return $result;
}

private static function usePolyfill(): bool
{
// PHP 8.4 is required for native parsing
if (PHP_VERSION_ID < 80400) {
return true;
}

// `request_parse_body()` can only be used when the PHP script is invoked through a web context, e.g. via SAPI
if (!isset($_SERVER['CONTENT_TYPE'])) {
return true;
}

return false;
}
}
10 changes: 9 additions & 1 deletion src/ParserException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
class ParserException extends Exception
{
public const ERR_JSON = 1;
public const ERR_NOT_ACCEPTABLE = 2;
public const ERR_UNEXPECTED_EMPTY_PARSED_BODY = 2;

public static function jsonError(string $message): self
{
Expand All @@ -18,4 +18,12 @@ public static function jsonError(string $message): self
self::ERR_JSON
);
}

public static function unexpectedEmptyParsedBody(): self
{
return new self(
'The parsed body was unexpectedly empty. Did you consume the `php://input` stream by accident?',
self::ERR_UNEXPECTED_EMPTY_PARSED_BODY,
);
}
}
109 changes: 109 additions & 0 deletions src/UploadedFileCollectionFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php declare(strict_types=1);

namespace Kekos\ParseRequestBodyMiddleware;

use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UploadedFileInterface;

use function array_map;
use function is_array;

use const UPLOAD_ERR_OK;

/**
* @phpstan-type PhpSingleFileStruct array{
* tmp_name: string,
* error: int,
* size: int,
* name: string,
* type: string,
* }
* @phpstan-type PhpMultiFileStruct array{
* tmp_name: array<array-key, string>,
* error: array<array-key, int>,
* size: array<array-key, int>,
* name: array<array-key, string>,
* type: array<array-key, string>,
* }
* @phpstan-type UploadedFileArray array<array-key, UploadedFileInterface>
* @phpstan-type RecursiveUploadedFileArray array<array-key, UploadedFileInterface|UploadedFileArray>
*/
final class UploadedFileCollectionFactory
{
public function __construct(
private UploadedFileFactoryInterface $uploaded_file_factory,
private StreamFactoryInterface $stream_factory,
) {
}

/**
* @param array<array-key, mixed> $php_files
* @return RecursiveUploadedFileArray
*/
public function fromPhpFilesArray(array $php_files): array
{
$collection = [];

foreach ($php_files as $key => $file) {
if (!is_array($file)) {
continue;
}

if (isset($file['tmp_name'])) {
/** @var PhpSingleFileStruct $file */
$collection[$key] = $this->createPsrUploadedFileFromStruct($file);
} else {
$collection[$key] = $this->fromPhpFilesArray($file);
}
}

return $collection;
}

/**
* @param PhpSingleFileStruct|PhpMultiFileStruct $php_file
* @return RecursiveUploadedFileArray|UploadedFileInterface
*/
private function createPsrUploadedFileFromStruct(array $php_file): array|UploadedFileInterface
{
if (is_array($php_file['tmp_name'])) {
return $this->transposeNestedStructToFlat($php_file);
}

if ($php_file['error'] !== UPLOAD_ERR_OK) {
$stream = $this->stream_factory->createStream();
} else {
$stream = $this->stream_factory->createStreamFromFile($php_file['tmp_name']);
}

return $this->uploaded_file_factory->createUploadedFile(
$stream,
(int) $php_file['size'],
(int) $php_file['error'],
$php_file['name'],
$php_file['type']
);
}

/**
* @param PhpMultiFileStruct $nested_php_file
* @return RecursiveUploadedFileArray
*/
private function transposeNestedStructToFlat(array $nested_php_file): array
{
$collection = [];

foreach ($nested_php_file as $struct_key => $values) {
if (!is_array($values)) {
continue;
}

foreach ($values as $key => $value) {
$collection[$key][$struct_key] = $value;
}
}

return array_map([$this, 'createPsrUploadedFileFromStruct'], $collection);
}
}
1 change: 1 addition & 0 deletions tests/Fixtures/uploaded_file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is an uploaded file!
1 change: 1 addition & 0 deletions tests/Fixtures/uploaded_fileA.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is an uploaded file A!
1 change: 1 addition & 0 deletions tests/Fixtures/uploaded_fileB.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is an uploaded file B !
Loading

0 comments on commit de59856

Please sign in to comment.