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 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/src/Http/Traits/Request/Header.php b/src/Http/Traits/Request/Header.php index b6c4c0c92..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 @@ -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 */ @@ -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 + */ + 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/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); diff --git a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php index 3822b1b19..5da685d9d 100644 --- a/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php +++ b/tests/Unit/Http/Traits/Request/HttpRequestHeaderTest.php @@ -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());