Распарсить multipart/form-data в массив файлов и данных для теста Laravel
Я хочу написать PHPUnit-тесты для кучи форм, которые достались по-наследству. Поэтому решение должно быть серийным.
Что получилось сделать.
- Я заполняю и отправляю форму на сайте
- В консоли браузера делаю "копировать как curl"
- Полученные данные я использую для написания PHPUnit-теста в Laravel
Вот минимальный рабочий пример:
public function testMinimal(): void
{
$headers = [
"Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8",
"Accept" => "application/json, text/javascript, */*; q=0.01"
];
$data = "form%5Bkey%5D=val"; // данные берутся из "скопировать как curl"
parse_str($data, $request); // это ["form[key]" => "val"]
$resp = $this->post("test", $request, $headers);
$resp->assertOk()->assertJson([
"form" => ["key" => "val"]
]);
}
api.php:
Route::post("test", function () {
// эхо-сервер возвращает то, что пришло
return response(app("request")->toArray())->header("Content-Type", "application/json");
});
Я это сделал для форм application/x-www-form-urlencoded. А вот с multipart/form-data возникла проблема - нет подходящего метода в Laravel. Я хочу что-то типа
$boundary = "---------------------------134321616315313667391972112742";
$body = "-----------------------------134321616315313667391972112742\r\nContent-Disposition: form-data; name ..."; // тут я обрезал
$resp = $this->postMultipart("test", $body, $boundary, $headers);
Как мне это сделать?
Ответы (1 шт):
Я сделал то, что хотел. Нужно:
- Распарсить
multipart/form-dataс помощью пакетаriverline/multipart-parserна части - Пройтись по частям и преобразовать в массив, который поддерживается Laravel
Важно замечание. Оказалось, что "копировать как cURL" не сохраняет содержимое загружаемых файлов, и нужно копировать из вкладки "Запрос". Сам запрос выглядит так:
-----------------------------15879248054246973702996273273
Content-Disposition: form-data; name="feedback[text]"
text
-----------------------------15879248054246973702996273273
Content-Disposition: form-data; name="feedback[file_1]"; filename="file.pdf"
Content-Type: application/pdf
content of the file...
-----------------------------15879248054246973702996273273
Content-Disposition: form-data; name="is_draft"
true
-----------------------------15879248054246973702996273273--
Большие файлы таким способом скопировать не получится, браузер пишет что запрос был усечен. В этом случае можно сохранить сырой запрос на веб-сервере или пробовать на файлах ~100 Кб.
Ниже написал, как можно организовать такое тестирование.
Установить пакет: composer require --dev riverline/multipart-parser
Класс для преобразования multipart/form-data в массив для теста - файл MultipartFormDataParser.php:
namespace Tests\TestCase\MultipartFormDataParser;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Riverline\MultiPartParser\Converters\PSR7;
use Riverline\MultiPartParser\StreamedPart;
class MultipartFormDataParser
{
private array $headers;
private string $body;
private Request $request;
private StreamedPart $parts;
private array $data;
public function setHeaders(array $headers): self
{
$this->headers = $headers;
return $this;
}
public function getHeaders(): array
{
return $this->headers;
}
public function setBody(string $body): self
{
$this->body = $body;
return $this;
}
public function getData(): array
{
return $this->data;
}
public function guessBoundary(): self
{
$firstString = strtok($this->body, "\n");
$boundary = Str::replaceFirst("--", "", $firstString);
$this->headers = array_filter($this->headers, fn($o) => Str::lower($o) !== "content-type", ARRAY_FILTER_USE_KEY);
$this->headers["Content-Type"] = "multipart/form-data; boundary=$boundary";
return $this;
}
public function parse(): self
{
$this->request = new Request("POST", "http://localhost", $this->headers, $this->body);
$this->parts = PSR7::convert($this->request);
$this->data = [];
foreach ($this->parts->getParts() as $part) {
if ($part->isFile()) {
$item = UploadedFile::fake()->createWithContent($part->getFileName(), $part->getBody());
$item->mimeType($part->getMimeType());
} else {
$item = $part->getBody();
}
$data = $this->parseKeyAndSetVal($part->getName(), $item);
$this->data = array_merge_recursive($this->data, $data);
}
return $this;
}
private function parseKeyAndSetVal($key, $val): array
{
parse_str($key, $res);
array_walk_recursive($res, function (&$child) use ($val) {
if ($child === "") {
$child = $val;
}
}, $val);
return $res;
}
}
Тест MinTest.php, в котором используется парсер:
class MinTest extends TestCase
{
public function testMin(): void
{
$headers = [
// Заголовок Content-Type будет заменен в guessBoundary(), он тут не обязателен
"Content-Type" => "multipart/form-data; boundary=guess-boundary",
"Accept" => "application/json, text/javascript, */*; q=0.01"
];
$body = "..."; // тут body запроса, взято из dev tools браузера, сеть, вкладка "Запрос"
$parser = new MultipartFormDataParser;
$parser->setHeaders($headers)->setBody($body)->guessBoundary()->parse();
$data = $parser->getData();
$resp = $this->post("test", $data, $parser->getHeaders());
dd($resp->json());
}
}
Эхо-сервер для проверки:
Route::post("test", function () {
// эхо-сервер возвращает то, что пришло
/** @var \Illuminate\Http\Request $request */
$request = app("request");
return response([
"data" => $request->post(),
"files" => array_keys($request->allFiles())
])->header("Content-Type", "application/json");
});