Skip to content

Commit f344b88

Browse files
pyrechfabpot
authored andcommitted
[ErrorHandler] Add a command to dump static error pages
1 parent d916392 commit f344b88

File tree

4 files changed

+207
-1
lines changed

4 files changed

+207
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add `error:dump` command
8+
49
7.1
510
---
611

Command/ErrorDumpCommand.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\ErrorHandler\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface;
22+
use Symfony\Component\Filesystem\Filesystem;
23+
use Symfony\Component\HttpFoundation\Response;
24+
use Symfony\Component\HttpKernel\Exception\HttpException;
25+
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
26+
27+
/**
28+
* Dump error pages to plain HTML files that can be directly served by a web server.
29+
*
30+
* @author Loïck Piera <[email protected]>
31+
*/
32+
#[AsCommand(
33+
name: 'error:dump',
34+
description: 'Dump error pages to plain HTML files that can be directly served by a web server',
35+
)]
36+
final class ErrorDumpCommand extends Command
37+
{
38+
public function __construct(
39+
private readonly Filesystem $filesystem,
40+
private readonly ErrorRendererInterface $errorRenderer,
41+
private readonly ?EntrypointLookupInterface $entrypointLookup = null,
42+
) {
43+
parent::__construct();
44+
}
45+
46+
protected function configure(): void
47+
{
48+
$this
49+
->addArgument('path', InputArgument::REQUIRED, 'Path where to dump the error pages in')
50+
->addArgument('status-codes', InputArgument::IS_ARRAY, 'Status codes to dump error pages for, all of them by default')
51+
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force directory removal before dumping new error pages')
52+
;
53+
}
54+
55+
protected function execute(InputInterface $input, OutputInterface $output): int
56+
{
57+
$path = $input->getArgument('path');
58+
59+
$io = new SymfonyStyle($input, $output);
60+
$io->title('Dumping error pages');
61+
62+
$this->dump($io, $path, $input->getArgument('status-codes'), (bool) $input->getOption('force'));
63+
$io->success(\sprintf('Error pages have been dumped in "%s".', $path));
64+
65+
return Command::SUCCESS;
66+
}
67+
68+
private function dump(SymfonyStyle $io, string $path, array $statusCodes, bool $force = false): void
69+
{
70+
if (!$statusCodes) {
71+
$statusCodes = array_filter(array_keys(Response::$statusTexts), fn ($statusCode) => $statusCode >= 400);
72+
}
73+
74+
if ($force || ($this->filesystem->exists($path) && $io->confirm(\sprintf('The "%s" directory already exists. Do you want to remove it before dumping the error pages?', $path), false))) {
75+
$this->filesystem->remove($path);
76+
}
77+
78+
foreach ($statusCodes as $statusCode) {
79+
// Avoid assets to be included only on the first dumped page
80+
$this->entrypointLookup?->reset();
81+
82+
$this->filesystem->dumpFile($path.\DIRECTORY_SEPARATOR.$statusCode.'.html', $this->errorRenderer->render(new HttpException((int) $statusCode))->getAsString());
83+
}
84+
}
85+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\ErrorHandler\Tests\Command;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use Symfony\Bundle\FrameworkBundle\Console\Application;
16+
use Symfony\Bundle\TwigBundle\Tests\TestCase;
17+
use Symfony\Component\Console\Tester\CommandTester;
18+
use Symfony\Component\DependencyInjection\Container;
19+
use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand;
20+
use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface;
21+
use Symfony\Component\ErrorHandler\Exception\FlattenException;
22+
use Symfony\Component\Filesystem\Filesystem;
23+
use Symfony\Component\HttpKernel\Exception\HttpException;
24+
use Symfony\Component\HttpKernel\KernelInterface;
25+
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
26+
27+
class ErrorDumpCommandTest extends TestCase
28+
{
29+
private string $tmpDir = '';
30+
31+
protected function setUp(): void
32+
{
33+
$this->tmpDir = sys_get_temp_dir().'/error_pages';
34+
35+
$fs = new Filesystem();
36+
$fs->remove($this->tmpDir);
37+
}
38+
39+
public function testDumpPages()
40+
{
41+
$tester = $this->getCommandTester($this->getKernel(), []);
42+
$tester->execute([
43+
'path' => $this->tmpDir,
44+
]);
45+
46+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
47+
$this->assertStringContainsString('Error 404', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html'));
48+
}
49+
50+
public function testDumpPagesOnlyForGivenStatusCodes()
51+
{
52+
$fs = new Filesystem();
53+
$fs->mkdir($this->tmpDir);
54+
$fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
55+
56+
$tester = $this->getCommandTester($this->getKernel());
57+
$tester->execute([
58+
'path' => $this->tmpDir,
59+
'status-codes' => ['400', '500'],
60+
]);
61+
62+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
63+
$this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
64+
65+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html');
66+
$this->assertStringContainsString('Error 400', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html'));
67+
}
68+
69+
public function testForceRemovalPages()
70+
{
71+
$fs = new Filesystem();
72+
$fs->mkdir($this->tmpDir);
73+
$fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
74+
75+
$tester = $this->getCommandTester($this->getKernel());
76+
$tester->execute([
77+
'path' => $this->tmpDir,
78+
'--force' => true,
79+
]);
80+
81+
$this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
82+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
83+
}
84+
85+
private function getKernel(): MockObject&KernelInterface
86+
{
87+
return $this->createMock(KernelInterface::class);
88+
}
89+
90+
private function getCommandTester(KernelInterface $kernel): CommandTester
91+
{
92+
$errorRenderer = $this->createStub(ErrorRendererInterface::class);
93+
$errorRenderer
94+
->method('render')
95+
->willReturnCallback(function (HttpException $e) {
96+
$exception = FlattenException::createFromThrowable($e);
97+
$exception->setAsString(\sprintf('<html><body>Error %s</body></html>', $e->getStatusCode()));
98+
99+
return $exception;
100+
})
101+
;
102+
103+
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
104+
105+
$application = new Application($kernel);
106+
$application->add(new ErrorDumpCommand(
107+
new Filesystem(),
108+
$errorRenderer,
109+
$entrypointLookup,
110+
));
111+
112+
return new CommandTester($application->find('error:dump'));
113+
}
114+
}

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
"symfony/var-dumper": "^6.4|^7.0"
2222
},
2323
"require-dev": {
24+
"symfony/console": "^6.4|^7.0",
2425
"symfony/http-kernel": "^6.4|^7.0",
2526
"symfony/serializer": "^6.4|^7.0",
26-
"symfony/deprecation-contracts": "^2.5|^3"
27+
"symfony/deprecation-contracts": "^2.5|^3",
28+
"symfony/webpack-encore-bundle": "^1.0|^2.0"
2729
},
2830
"conflict": {
2931
"symfony/deprecation-contracts": "<2.5",

0 commit comments

Comments
 (0)