From 99b5d1c34c6e1746e6a9781a23e69193dd908c62 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:41:53 +0400 Subject: [PATCH 1/5] [#581] Add Request::expectsJson() Checks whether the Accept header lists application/json, ignoring media type parameters, surrounding spaces and case, so a header such as "application/json, text/plain, */*" counts as JSON. Not used yet. --- src/Http/Traits/Request/Header.php | 16 ++++++++ .../Traits/Request/HttpRequestHeaderTest.php | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/Http/Traits/Request/Header.php b/src/Http/Traits/Request/Header.php index b6c4c0c92..e927181d6 100644 --- a/src/Http/Traits/Request/Header.php +++ b/src/Http/Traits/Request/Header.php @@ -10,6 +10,8 @@ namespace Quantum\Http\Traits\Request; +use Quantum\Http\Enums\ContentType; + /** * Trait Header * @package Quantum\Http @@ -128,6 +130,20 @@ public function isAjax(): bool return $this->hasHeader('X-REQUESTED-WITH') || $this->server->ajax(); } + /** + * Checks whether the Accept header lists JSON, ignoring media type parameters and q-values + */ + public function expectsJson(): bool + { + foreach (explode(',', (string) $this->getHeader('Accept')) as $mediaType) { + if (strtolower(trim(explode(';', $mediaType)[0])) === ContentType::JSON) { + return true; + } + } + + return false; + } + /** * Gets the referrer */ diff --git a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php index 3822b1b19..19953702f 100644 --- a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php +++ b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php @@ -136,6 +136,43 @@ 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 ', + ]; + + 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', + '', + ]; + + foreach ($acceptHeaders as $accept) { + request()->setHeader('Accept', $accept); + + $this->assertFalse(request()->expectsJson(), $accept); + } + } + public function testGetReferrer(): void { $this->assertNull(request()->getReferrer()); From 2f283c2254a863473f20d6838a6f8a57549f6ba7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:20:13 +0400 Subject: [PATCH 2/5] [#581] Answer JSON 404s for any Accept header that lists application/json page_not_found_response() returned the JSON 404 only when the Accept header was exactly "application/json", so clients such as axios ("application/json, text/plain, */*") got the HTML page. It now decides with Request::expectsJson(). It takes an optional request, and HandleRouteNotFoundStage passes the one from its context. --- .../Stages/Web/HandleRouteNotFoundStage.php | 2 +- src/Http/Helpers/http.php | 11 ++----- .../App/Adapters/Web/WebAppAdapterTest.php | 14 ++++++++ .../Web/HandleRouteNotFoundStageTest.php | 31 +++++++++++++++++ tests/Unit/Http/Helpers/HttpHelperTest.php | 33 +++++++++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/App/Stages/Web/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php index a3e7fad4e..e17471506 100644 --- a/src/App/Stages/Web/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -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(); } } diff --git a/src/Http/Helpers/http.php b/src/Http/Helpers/http.php index 981f11f2c..0a1aa22ef 100644 --- a/src/Http/Helpers/http.php +++ b/src/Http/Helpers/http.php @@ -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; @@ -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 diff --git a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php index 9ab09af93..47236e5ad 100644 --- a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php @@ -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'); diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php index a3b322eff..af6223f72 100644 --- a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -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 @@ -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( diff --git a/tests/Unit/Http/Helpers/HttpHelperTest.php b/tests/Unit/Http/Helpers/HttpHelperTest.php index edaaabb30..ed686742d 100644 --- a/tests/Unit/Http/Helpers/HttpHelperTest.php +++ b/tests/Unit/Http/Helpers/HttpHelperTest.php @@ -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; @@ -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); From 8a6716fad735f74c5bf48d2c4f72efce66b71838 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:54:17 +0400 Subject: [PATCH 3/5] [#581] Treat +json types and AJAX requests that accept anything as JSON Request::expectsJson() now also returns true when a listed media type contains "/json" or "+json" (application/vnd.api+json, application/problem+json), and for an AJAX request without an Accept header or with "*/*" first, as Laravel does. An AJAX request that asks for text/html still gets HTML. --- src/Http/Traits/Request/Header.php | 18 +++++++++++----- .../Traits/Request/HttpRequestHeaderTest.php | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/Http/Traits/Request/Header.php b/src/Http/Traits/Request/Header.php index e927181d6..1e9504fc1 100644 --- a/src/Http/Traits/Request/Header.php +++ b/src/Http/Traits/Request/Header.php @@ -10,8 +10,6 @@ namespace Quantum\Http\Traits\Request; -use Quantum\Http\Enums\ContentType; - /** * Trait Header * @package Quantum\Http @@ -131,12 +129,22 @@ public function isAjax(): bool } /** - * Checks whether the Accept header lists JSON, ignoring media type parameters and q-values + * Checks whether the client expects JSON: the Accept header lists a JSON media type + * (application/json, a +json type), or it is an AJAX request that accepts anything */ public function expectsJson(): bool { - foreach (explode(',', (string) $this->getHeader('Accept')) as $mediaType) { - if (strtolower(trim(explode(';', $mediaType)[0])) === ContentType::JSON) { + $mediaTypes = array_map( + fn (string $mediaType): string => strtolower(trim(explode(';', $mediaType)[0])), + explode(',', (string) $this->getHeader('Accept')) + ); + + if ($this->isAjax() && in_array($mediaTypes[0], ['', '*/*', '*'], true)) { + return true; + } + + foreach ($mediaTypes as $mediaType) { + if (str_contains($mediaType, '/json') || str_contains($mediaType, '+json')) { return true; } } diff --git a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php index 19953702f..5f16c99fe 100644 --- a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php +++ b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php @@ -145,6 +145,9 @@ public function testExpectsJsonWhenAcceptListsApplicationJson(): void 'application/json; charset=utf-8', 'Application/JSON', ' text/html , application/json ', + 'application/vnd.api+json', + 'application/problem+json', + 'application/jsonp', ]; foreach ($acceptHeaders as $accept) { @@ -162,7 +165,6 @@ public function testDoesNotExpectJsonWhenAcceptDoesNotListApplicationJson(): voi 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', '*/*', 'text/html', - 'application/jsonp', '', ]; @@ -173,6 +175,23 @@ public function testDoesNotExpectJsonWhenAcceptDoesNotListApplicationJson(): voi } } + public function testExpectsJsonForAjaxRequestsThatAcceptAnything(): void + { + request()->setHeader('X-Requested-With', 'XMLHttpRequest'); + + $this->assertTrue(request()->expectsJson()); + + request()->setHeader('Accept', '*/*'); + + $this->assertTrue(request()->expectsJson()); + + foreach (['text/html', 'text/html, */*; q=0.01'] as $accept) { + request()->setHeader('Accept', $accept); + + $this->assertFalse(request()->expectsJson(), $accept); + } + } + public function testGetReferrer(): void { $this->assertNull(request()->getReferrer()); From 1cc7c0b0ab159aacf25a4878c6eb23ce5f0228a1 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:09:27 +0400 Subject: [PATCH 4/5] [#581] Add the changelog entries for the JSON 404 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945d4cb47..f59357b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..before` and `boot..after` (#536) - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) @@ -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 From fa8a9eaaa58c7d0e397fa59bd479ae9e757c8ae3 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:05:38 +0400 Subject: [PATCH 5/5] [#581] Match JSON media types exactly in Request::expectsJson() Review fixes. application/json must match exactly and +json only as a suffix, so application/jsonp, text/json and application/json-seq are no longer taken for JSON. An AJAX request that lists text/html anywhere gets HTML, so "*/*, text/html" behaves like "text/html, */*". The Accept parsing moved to a private method and uses the ContentType constants. --- src/Http/Traits/Request/Header.php | 31 ++++++++++++------- .../Traits/Request/HttpRequestHeaderTest.php | 14 +++++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/Http/Traits/Request/Header.php b/src/Http/Traits/Request/Header.php index 1e9504fc1..ef7c5722f 100644 --- a/src/Http/Traits/Request/Header.php +++ b/src/Http/Traits/Request/Header.php @@ -10,6 +10,8 @@ namespace Quantum\Http\Traits\Request; +use Quantum\Http\Enums\ContentType; + /** * Trait Header * @package Quantum\Http @@ -130,26 +132,21 @@ public function isAjax(): bool /** * Checks whether the client expects JSON: the Accept header lists a JSON media type - * (application/json, a +json type), or it is an AJAX request that accepts anything + * (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 = array_map( - fn (string $mediaType): string => strtolower(trim(explode(';', $mediaType)[0])), - explode(',', (string) $this->getHeader('Accept')) - ); - - if ($this->isAjax() && in_array($mediaTypes[0], ['', '*/*', '*'], true)) { - return true; - } + $mediaTypes = $this->getAcceptedMediaTypes(); foreach ($mediaTypes as $mediaType) { - if (str_contains($mediaType, '/json') || str_contains($mediaType, '+json')) { + if ($mediaType === ContentType::JSON || str_ends_with($mediaType, '+json')) { return true; } } - return false; + return $this->isAjax() + && in_array($mediaTypes[0], ['', '*/*', '*'], true) + && !in_array(ContentType::HTML, $mediaTypes, true); } /** @@ -160,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 + */ + private function getAcceptedMediaTypes(): array + { + return array_map( + fn (string $mediaType): string => strtolower(trim(explode(';', $mediaType)[0])), + explode(',', (string) $this->getHeader('Accept')) + ); + } + /** * @return array */ diff --git a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php index 5f16c99fe..5da685d9d 100644 --- a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php +++ b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php @@ -147,7 +147,6 @@ public function testExpectsJsonWhenAcceptListsApplicationJson(): void ' text/html , application/json ', 'application/vnd.api+json', 'application/problem+json', - 'application/jsonp', ]; foreach ($acceptHeaders as $accept) { @@ -165,6 +164,10 @@ public function testDoesNotExpectJsonWhenAcceptDoesNotListApplicationJson(): voi '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', '', ]; @@ -185,7 +188,14 @@ public function testExpectsJsonForAjaxRequestsThatAcceptAnything(): void $this->assertTrue(request()->expectsJson()); - foreach (['text/html', 'text/html, */*; q=0.01'] as $accept) { + $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);