Skip to content
Draft
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
80 changes: 77 additions & 3 deletions src/Illuminate/Foundation/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ class Handler implements ExceptionHandlerContract
*/
protected $shouldRenderJsonWhenCallback;

/**
* The callback that determines if the exception handler response should be Markdown.
*
* @var callable|null
*/
protected $shouldRenderMarkdownWhenCallback;

/**
* The callback that prepares responses to be returned to the browser.
*
Expand Down Expand Up @@ -736,9 +743,11 @@ protected function renderViaCallbacks($request, Throwable $e)
*/
protected function renderExceptionResponse($request, Throwable $e)
{
return $this->shouldReturnJson($request, $e)
? $this->prepareJsonResponse($request, $e)
: $this->prepareResponse($request, $e);
return match (true) {
$this->shouldReturnJson($request, $e) => $this->prepareJsonResponse($request, $e),
$this->shouldReturnMarkdown($request, $e) => $this->prepareMarkdownResponse($request, $e),
default => $this->prepareResponse($request, $e),
};
}

/**
Expand Down Expand Up @@ -829,6 +838,71 @@ public function shouldRenderJsonWhen($callback)
return $this;
}

/**
* Determine if the exception handler response should be Markdown.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $e
* @return bool
*/
protected function shouldReturnMarkdown($request, Throwable $e)
{
return $this->shouldRenderMarkdownWhenCallback
? call_user_func($this->shouldRenderMarkdownWhenCallback, $request, $e)
: $request->wantsMarkdown();
}

/**
* Register the callable that determines if the exception handler response should be Markdown.
*
* @param callable(\Illuminate\Http\Request $request, \Throwable): bool $callback
* @return $this
*/
public function shouldRenderMarkdownWhen($callback)
{
$this->shouldRenderMarkdownWhenCallback = $callback;

return $this;
}

/**
* Prepare a Markdown response for the given exception.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $e
* @return \Illuminate\Http\Response
*/
protected function prepareMarkdownResponse($request, Throwable $e)
{
$status = $this->isHttpException($e) ? $e->getStatusCode() : 500;
$headers = $this->isHttpException($e) ? $e->getHeaders() : [];

return response($this->renderExceptionAsMarkdown($e), $status, array_merge($headers, [
'Content-Type' => 'text/markdown; charset=UTF-8',
]))->withException($e);
}

/**
* Render the given exception as a markdown string.
*
* @param \Throwable $e
* @return string
*/
protected function renderExceptionAsMarkdown(Throwable $e)
{
if (
config('app.debug')
&& $this->container->bound(Renderer::class)
&& ! app()->has(ExceptionRenderer::class)
) {
return $this->container->make(Renderer::class)->renderMarkdown(request(), $e);
}

$message = $this->isHttpException($e) ? $e->getMessage() : 'Server Error';

return "# {$message}\n";
}

/**
* Prepare a response for the given exception.
*
Expand Down
39 changes: 34 additions & 5 deletions src/Illuminate/Foundation/Exceptions/Renderer/Renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,7 @@ public function __construct(
*/
public function render(Request $request, Throwable $throwable)
{
$flattenException = $this->bladeMapper->map(
$this->htmlErrorRenderer->render($throwable),
);

$exception = new Exception($flattenException, $request, $this->listener, $this->basePath);
$exception = $this->resolveException($request, $throwable);

$exceptionAsMarkdown = $this->viewFactory->make('laravel-exceptions-renderer::markdown', [
'exception' => $exception,
Expand All @@ -100,6 +96,39 @@ public function render(Request $request, Throwable $throwable)
])->render();
}

/**
* Render the given exception as a markdown string.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $throwable
* @return string
*/
public function renderMarkdown(Request $request, Throwable $throwable)
{
$exception = $this->resolveException($request, $throwable);

return $this->viewFactory->make('laravel-exceptions-renderer::markdown', [
'exception' => $exception,
])->render();
}

/**
* Resolve the exception into a renderable exception instance.
*
* @param \Illuminate\Http\Request $request
* @param \Throwable $throwable
* @return \Illuminate\Foundation\Exceptions\Renderer\Exception
*/
protected function resolveException(Request $request, Throwable $throwable)
{
return new Exception(
$this->bladeMapper->map($this->htmlErrorRenderer->render($throwable)),
$request,
$this->listener,
$this->basePath,
);
}

/**
* Get the renderer's CSS content.
*
Expand Down
84 changes: 84 additions & 0 deletions tests/Integration/Foundation/Exceptions/RendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,88 @@ public function testItExcludesDecorativeAsciiArtInNonBrowserContexts()
->assertSee('Bad route!')
->assertDontSee('viewBox="0 0 1268 308"', false);
}

#[WithConfig('app.debug', true)]
public function testItCanRenderExceptionAsMarkdown()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
$response->assertSee('RuntimeException');
$response->assertSee('Bad route!');
$response->assertSee('## Stack Trace');
}

#[WithConfig('app.debug', true)]
public function testItCanRenderExceptionAsMarkdownWithCharsetParameter()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown; charset=utf-8']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
$response->assertSee('## Stack Trace');
}

#[WithConfig('app.debug', true)]
public function testJsonTakesPrecedenceOverMarkdown()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'application/json, text/markdown']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'application/json');
$response->assertJsonStructure(['message', 'exception']);
}

#[WithConfig('app.debug', true)]
public function testMarkdownTakesPrecedenceOverJson()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown, application/json']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
$response->assertSee('## Stack Trace');
}

#[WithConfig('app.debug', true)]
public function testHtmlTakesPrecedenceOverMarkdown()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/html, text/markdown']);

$response->assertInternalServerError();
$this->assertStringContainsString('text/html', $response->headers->get('Content-Type'));
}

#[WithConfig('app.debug', false)]
public function testMarkdownResponseDoesNotLeakDebugInfoWhenDebugOff()
{
$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
$response->assertDontSee('## Stack Trace');
$response->assertDontSee('Bad route!');
$response->assertSee('Server Error');
}

#[WithConfig('app.debug', true)]
public function testMarkdownFallsBackWhenCustomExceptionRendererRegistered()
{
$this->app->singleton(ExceptionRenderer::class, function () {
return new class() implements ExceptionRenderer
{
public function render($throwable)
{
return response('Custom Exception Renderer: '.$throwable->getMessage(), 500);
}
};
});

$response = $this->call('GET', '/failed', [], [], [], ['HTTP_ACCEPT' => 'text/markdown']);

$response->assertInternalServerError();
$response->assertHeader('Content-Type', 'text/markdown; charset=UTF-8');
$response->assertDontSee('## Stack Trace');
$response->assertSee('Server Error');
}
}
Loading