Skip to content
Merged
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on Keep a Changelog.
## [3.1.0] - TBD

### Added
- Added `Request::expectsJson()`: true when the `Accept` header lists a JSON media type (`application/json` or a `+json` type, parameters and case ignored), or for an AJAX request that accepts any content type (#581)
- Added boot stage lifecycle events: stages may declare `BEFORE`/`AFTER` event names and `BootPipeline` dispatches them with the `AppContext` as a named payload. The environment, config, error handler, HTTP, and modules stages declare `boot.<stage>.before` and `boot.<stage>.after` (#536)
- Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533)

Expand Down Expand Up @@ -45,6 +46,7 @@ The format is based on Keep a Changelog.
- **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533)

### Fixed
- The page not found response is JSON for every client that asks for JSON, not only when the `Accept` header is exactly `application/json`: clients such as axios (`application/json, text/plain, */*`), a `charset` parameter, `+json` types and AJAX requests used to get the HTML page. `page_not_found_response()` decides with `Request::expectsJson()` and takes an optional request (#581)
- `core:env` now looks for the existing `.env` in the application base directory, so running it from another working directory asks for the confirmation instead of silently overwriting the file (#575)

## [3.0.3] - 2026-07-10
Expand Down
2 changes: 1 addition & 1 deletion src/App/Stages/Web/HandleRouteNotFoundStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function process(WebContext $context): void
return;
}

$context->setResponse(page_not_found_response());
$context->setResponse(page_not_found_response($context->getRequest()));
$context->stop();
}
}
11 changes: 3 additions & 8 deletions src/Http/Helpers/http.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Quantum\App\Exceptions\BaseException;
use Quantum\Di\Exceptions\DiException;
use Quantum\App\Enums\ReservedKeys;
use Quantum\Http\Enums\ContentType;
use Quantum\Http\Enums\StatusCode;
use Quantum\Http\Response;
use Quantum\Http\Request;
Expand Down Expand Up @@ -114,16 +113,12 @@ function get_referrer(): ?string
}

/**
* Handles page not found
* Handles page not found: JSON when the request's Accept header lists it, HTML otherwise
* @throws ConfigException|DiException|BaseException|ReflectionException
*/
function page_not_found_response(): Response
function page_not_found_response(?Request $request = null): Response
{
$acceptHeader = request()->getHeader('Accept');

$isJson = $acceptHeader === ContentType::JSON;

if ($isJson) {
if (($request ?? request())->expectsJson()) {
return response()->json(
['status' => 'error', 'message' => 'Page not found',],
StatusCode::NOT_FOUND
Expand Down
33 changes: 33 additions & 0 deletions src/Http/Traits/Request/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace Quantum\Http\Traits\Request;

use Quantum\Http\Enums\ContentType;

/**
* Trait Header
* @package Quantum\Http
Expand Down Expand Up @@ -128,6 +130,25 @@ public function isAjax(): bool
return $this->hasHeader('X-REQUESTED-WITH') || $this->server->ajax();
}

/**
* Checks whether the client expects JSON: the Accept header lists a JSON media type
* (application/json or a +json type), or it is an AJAX request that accepts anything and does not ask for HTML
*/
public function expectsJson(): bool
{
$mediaTypes = $this->getAcceptedMediaTypes();

foreach ($mediaTypes as $mediaType) {
if ($mediaType === ContentType::JSON || str_ends_with($mediaType, '+json')) {
return true;
}
}

return $this->isAjax()
&& in_array($mediaTypes[0], ['', '*/*', '*'], true)
&& !in_array(ContentType::HTML, $mediaTypes, true);
}

/**
* Gets the referrer
*/
Expand All @@ -136,6 +157,18 @@ public function getReferrer(): ?string
return $this->server->referrer();
}

/**
* Gets the media types listed in the Accept header, without parameters, trimmed and lowercased
* @return array<string>
*/
private function getAcceptedMediaTypes(): array
{
return array_map(
fn (string $mediaType): string => strtolower(trim(explode(';', $mediaType)[0])),
explode(',', (string) $this->getHeader('Accept'))
);
}

/**
* @return array<string>
*/
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/App/Adapters/Web/WebAppAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ public function testWebAppAdapterStartRespondsWithJsonNotFoundForJsonRequests():
$this->assertNull(request()->getUri());
}

public function testWebAppAdapterStartRespondsWithJsonNotFoundWhenAcceptListsJson(): void
{
request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json, text/plain, */*']);

ob_start();
$result = $this->webAppAdapter->start();
ob_end_clean();

$this->assertSame(0, $result);
$this->assertSame(StatusCode::NOT_FOUND, response()->getStatusCode());
$this->assertSame(ContentType::JSON, response()->getContentType());
$this->assertStringContainsString('Page not found', response()->getContent());
}

public function testWebAppAdapterStartRespondsWithHtmlNotFoundForOtherRequests(): void
{
request()->create('GET', '/non-existing-uri');
Expand Down
31 changes: 31 additions & 0 deletions tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
use Quantum\Http\Enums\StatusCode;
use Quantum\App\Adapters\Web\WebContext;
use Quantum\Router\MatchedRoute;
use Quantum\Di\DiContainer;
use Quantum\App\AppContext;
use Quantum\Router\Route;
use Quantum\Http\Request;
use Quantum\App\App;

class HandleRouteNotFoundStageTest extends AppTestCase
Expand Down Expand Up @@ -46,6 +49,34 @@ public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): v
$this->assertTrue($this->webContext->isStopped());
}

public function testHandleRouteNotFoundStageRespondsWithJsonWhenAcceptListsJson(): void
{
request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json, text/plain, */*']);

(new HandleRouteNotFoundStage())->process($this->webContext);

$this->assertSame(StatusCode::NOT_FOUND, $this->webContext->getResponse()->getStatusCode());
$this->assertSame(ContentType::JSON, $this->webContext->getResponse()->getContentType());
$this->assertTrue($this->webContext->isStopped());
}

public function testHandleRouteNotFoundStageDecidesByTheContextRequest(): void
{
request()->create('GET', '/non-existing-uri', [], ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']);

$request = new Request();
$request->setHeader('Accept', 'application/json, text/plain, */*');

$container = new DiContainer();
$container->set(Request::class, $request);

$webContext = new WebContext(new AppContext(PROJECT_ROOT, $container));

(new HandleRouteNotFoundStage())->process($webContext);

$this->assertSame(ContentType::JSON, $webContext->getResponse()->getContentType());
}

public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void
{
request()->setMatchedRoute(
Expand Down
33 changes: 33 additions & 0 deletions tests/Unit/Http/Helpers/HttpHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Quantum\Router\RouteCollection;
use Quantum\Router\RouteFinder;
use Quantum\Router\Route;
use Quantum\Http\Enums\ContentType;
use Quantum\Http\Enums\StatusCode;
use Quantum\Http\Response;
use Quantum\Http\Request;

Expand Down Expand Up @@ -36,6 +38,37 @@ public function testResponseHelperReturnsDiInstance(): void
$this->assertSame(response(), response());
}

public function testPageNotFoundResponseIsJsonWhenAcceptListsJson(): void
{
request()->create('GET', '/no-such-page', [], ['Accept' => 'application/json, text/plain, */*']);

$response = page_not_found_response();

$this->assertSame(StatusCode::NOT_FOUND, $response->getStatusCode());
$this->assertSame(ContentType::JSON, $response->getContentType());
$this->assertStringContainsString('Page not found', $response->getContent());
}

public function testPageNotFoundResponseIsHtmlForBrowsers(): void
{
request()->create('GET', '/no-such-page', [], ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']);

$response = page_not_found_response();

$this->assertSame(StatusCode::NOT_FOUND, $response->getStatusCode());
$this->assertSame(ContentType::HTML, $response->getContentType());
}

public function testPageNotFoundResponseDecidesByTheGivenRequest(): void
{
request()->create('GET', '/no-such-page', [], ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']);

$request = new Request();
$request->setHeader('Accept', 'application/json, text/plain, */*');

$this->assertSame(ContentType::JSON, page_not_found_response($request)->getContentType());
}

public function testBaseUrlWithoutModulePrefix(): void
{
config()->set('app.base_url', null);
Expand Down
66 changes: 66 additions & 0 deletions tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,72 @@ public function testIsAjaxReturnsTrueFromServerWhenHeaderIsMissing(): void
$this->assertTrue($request->isAjax());
}

public function testExpectsJsonWhenAcceptListsApplicationJson(): void
{
$acceptHeaders = [
'application/json',
'application/json, text/plain, */*',
'application/json, text/javascript, */*; q=0.01',
'application/json; charset=utf-8',
'Application/JSON',
' text/html , application/json ',
'application/vnd.api+json',
'application/problem+json',
];

foreach ($acceptHeaders as $accept) {
request()->setHeader('Accept', $accept);

$this->assertTrue(request()->expectsJson(), $accept);
}
}

public function testDoesNotExpectJsonWhenAcceptDoesNotListApplicationJson(): void
{
$this->assertFalse(request()->expectsJson());

$acceptHeaders = [
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'*/*',
'text/html',
'application/jsonp',
'text/json',
'application/json-seq',
'application/vnd.example+jsonish',
'',
];

foreach ($acceptHeaders as $accept) {
request()->setHeader('Accept', $accept);

$this->assertFalse(request()->expectsJson(), $accept);
}
}

public function testExpectsJsonForAjaxRequestsThatAcceptAnything(): void
{
request()->setHeader('X-Requested-With', 'XMLHttpRequest');

$this->assertTrue(request()->expectsJson());

request()->setHeader('Accept', '*/*');

$this->assertTrue(request()->expectsJson());

$acceptHeaders = [
'text/html',
'text/html, */*; q=0.01',
'*/*, text/html',
'*/*, Text/HTML;q=0.9',
];

foreach ($acceptHeaders as $accept) {
request()->setHeader('Accept', $accept);

$this->assertFalse(request()->expectsJson(), $accept);
}
}

public function testGetReferrer(): void
{
$this->assertNull(request()->getReferrer());
Expand Down
Loading