diff --git a/app/Http/Middleware/AcceptJsonMiddleware.php b/app/Http/Middleware/AcceptJsonMiddleware.php new file mode 100644 index 000000000..35259d437 --- /dev/null +++ b/app/Http/Middleware/AcceptJsonMiddleware.php @@ -0,0 +1,38 @@ +acceptsJson()) { + return response()->json([ + 'message' => 'This endpoint only accepts JSON. Please include "Accept: application/json" in your request headers.', + 'error' => 'Unsupported Media Type', + ], Response::HTTP_NOT_ACCEPTABLE); + } + + // Ensure the response is JSON + $response = $next($request); + + // Force JSON content type if not already set + if (! $response->headers->has('Content-Type') || + ! str_contains($response->headers->get('Content-Type'), 'application/json')) { + $response->headers->set('Content-Type', 'application/json'); + } + + return $response; + } +} diff --git a/app/OpenApi/Annotations/V1/OoklaAnnotations.php b/app/OpenApi/Annotations/V1/OoklaAnnotations.php index 9dc612395..af559b059 100644 --- a/app/OpenApi/Annotations/V1/OoklaAnnotations.php +++ b/app/OpenApi/Annotations/V1/OoklaAnnotations.php @@ -17,6 +17,9 @@ class OoklaAnnotations description: 'Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.', operationId: 'listOoklaServers', tags: ['Servers'], + parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), + ], responses: [ new OA\Response( response: Response::HTTP_OK, @@ -33,6 +36,11 @@ class OoklaAnnotations description: 'Forbidden', content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), ] )] public function listServers(): void {} diff --git a/app/OpenApi/Annotations/V1/ResultsAnnotations.php b/app/OpenApi/Annotations/V1/ResultsAnnotations.php index 5e3f41740..9bfe6a691 100644 --- a/app/OpenApi/Annotations/V1/ResultsAnnotations.php +++ b/app/OpenApi/Annotations/V1/ResultsAnnotations.php @@ -17,6 +17,7 @@ class ResultsAnnotations operationId: 'listResults', tags: ['Results'], parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), new OA\Parameter( name: 'per_page', in: 'query', @@ -104,6 +105,11 @@ class ResultsAnnotations description: 'Unauthenticated', content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), new OA\Response( response: Response::HTTP_UNPROCESSABLE_ENTITY, description: 'Validation failed', @@ -119,6 +125,7 @@ public function index(): void {} operationId: 'getResult', tags: ['Results'], parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), new OA\Parameter( name: 'id', in: 'path', @@ -143,6 +150,11 @@ public function index(): void {} description: 'Unauthenticated', content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), new OA\Response( response: Response::HTTP_NOT_FOUND, description: 'Result not found', @@ -157,6 +169,9 @@ public function show(): void {} summary: 'Get the most recent result', operationId: 'getLatestResult', tags: ['Results'], + parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), + ], responses: [ new OA\Response( response: Response::HTTP_OK, @@ -173,6 +188,11 @@ public function show(): void {} description: 'Unauthenticated', content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), new OA\Response( response: Response::HTTP_NOT_FOUND, description: 'No result found', diff --git a/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php b/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php index 399486262..2a18ecbf2 100644 --- a/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php +++ b/app/OpenApi/Annotations/V1/SpeedtestAnnotations.php @@ -17,6 +17,7 @@ class SpeedtestAnnotations operationId: 'runSpeedtest', tags: ['Speedtests'], parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), new OA\Parameter( name: 'server_id', in: 'query', @@ -41,6 +42,11 @@ class SpeedtestAnnotations description: 'Forbidden', content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), new OA\Response( response: Response::HTTP_UNPROCESSABLE_ENTITY, description: 'Validation error', @@ -58,6 +64,9 @@ public function run(): void summary: 'List available Ookla speedtest servers', operationId: 'listSpeedtestServers', tags: ['Speedtests'], + parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), + ], responses: [ new OA\Response( response: Response::HTTP_OK, @@ -77,6 +86,11 @@ public function run(): void example: ['message' => 'You do not have permission to view speedtest servers.'] ) ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), ] )] public function listServers(): void {} diff --git a/app/OpenApi/Annotations/V1/StatsAnnotations.php b/app/OpenApi/Annotations/V1/StatsAnnotations.php index 78d8b188a..20145b338 100644 --- a/app/OpenApi/Annotations/V1/StatsAnnotations.php +++ b/app/OpenApi/Annotations/V1/StatsAnnotations.php @@ -17,6 +17,7 @@ class StatsAnnotations operationId: 'getStats', tags: ['Stats'], parameters: [ + new OA\Parameter(ref: '#/components/parameters/AcceptHeader'), new OA\Parameter( name: 'start_at', in: 'query', @@ -48,6 +49,11 @@ class StatsAnnotations description: 'Forbidden', content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') ), + new OA\Response( + response: Response::HTTP_NOT_ACCEPTABLE, + description: 'Not Acceptable - Missing or invalid Accept header', + content: new OA\JsonContent(ref: '#/components/schemas/NotAcceptableError') + ), new OA\Response( response: Response::HTTP_UNPROCESSABLE_ENTITY, description: 'Validation error', diff --git a/app/OpenApi/OpenApiDefinition.php b/app/OpenApi/OpenApiDefinition.php index d77da4165..c0bf78bd5 100644 --- a/app/OpenApi/OpenApiDefinition.php +++ b/app/OpenApi/OpenApiDefinition.php @@ -20,11 +20,12 @@ ], parameters: [ new OA\Parameter( + parameter: 'AcceptHeader', name: 'Accept', in: 'header', required: true, schema: new OA\Schema(type: 'string', default: 'application/json'), - description: 'Expected response format' + description: 'Must be "application/json" - this API only accepts and returns JSON' ), ] ), diff --git a/app/OpenApi/Schemas/NotAcceptableErrorSchema.php b/app/OpenApi/Schemas/NotAcceptableErrorSchema.php new file mode 100644 index 000000000..0db83544c --- /dev/null +++ b/app/OpenApi/Schemas/NotAcceptableErrorSchema.php @@ -0,0 +1,24 @@ +alias([ 'getting-started' => App\Http\Middleware\GettingStarted::class, 'public-dashboard' => App\Http\Middleware\PublicDashboard::class, + 'accept-json' => App\Http\Middleware\AcceptJsonMiddleware::class, ]); $middleware->prependToGroup('api', [ diff --git a/openapi.json b/openapi.json index 66f90cc37..ffe15d82d 100644 --- a/openapi.json +++ b/openapi.json @@ -17,6 +17,9 @@ "summary": "List all results", "operationId": "listResults", "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + }, { "name": "per_page", "in": "query", @@ -156,6 +159,16 @@ } } }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + }, "422": { "description": "Validation failed", "content": { @@ -178,6 +191,9 @@ "summary": "Fetch aggregated Speedtest statistics", "operationId": "getStats", "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + }, { "name": "start_at", "in": "query", @@ -230,6 +246,16 @@ } } }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -251,6 +277,11 @@ "summary": "List available Ookla speedtest servers", "description": "Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.", "operationId": "listOoklaServers", + "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + } + ], "responses": { "200": { "description": "Servers retrieved successfully", @@ -281,6 +312,16 @@ } } } + }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } } } } @@ -293,6 +334,9 @@ "summary": "Get a single result", "operationId": "getResult", "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + }, { "name": "id", "in": "path", @@ -334,6 +378,16 @@ } } }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + }, "404": { "description": "Result not found", "content": { @@ -354,6 +408,11 @@ ], "summary": "Get the most recent result", "operationId": "getLatestResult", + "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + } + ], "responses": { "200": { "description": "OK", @@ -385,6 +444,16 @@ } } }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + }, "404": { "description": "No result found", "content": { @@ -406,6 +475,9 @@ "summary": "Run a new Ookla speedtest", "operationId": "runSpeedtest", "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + }, { "name": "server_id", "in": "query", @@ -447,6 +519,16 @@ } } }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } + }, "422": { "description": "Validation error", "content": { @@ -467,6 +549,11 @@ ], "summary": "List available Ookla speedtest servers", "operationId": "listSpeedtestServers", + "parameters": [ + { + "$ref": "#/components/parameters/AcceptHeader" + } + ], "responses": { "200": { "description": "OK", @@ -500,6 +587,16 @@ } } } + }, + "406": { + "description": "Not Acceptable - Missing or invalid Accept header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotAcceptableError" + } + } + } } } } @@ -517,6 +614,20 @@ }, "type": "object" }, + "NotAcceptableError": { + "description": "Error response when the Accept header is missing or invalid", + "properties": { + "message": { + "type": "string", + "example": "This endpoint only accepts JSON. Please include \"Accept: application/json\" in your request headers." + }, + "error": { + "type": "string", + "example": "Unsupported Media Type" + } + }, + "type": "object" + }, "NotFoundError": { "description": "Error when a requested result is not found", "properties": { @@ -1038,10 +1149,10 @@ } }, "parameters": { - "Accept": { + "AcceptHeader": { "name": "Accept", "in": "header", - "description": "Expected response format", + "description": "Must be \"application/json\" - this API only accepts and returns JSON", "required": true, "schema": { "type": "string", diff --git a/routes/api.php b/routes/api.php index 30c477875..18b7a82cc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,8 +20,9 @@ * @deprecated */ Route::get('/speedtest/latest', GetLatestController::class) + ->middleware('accept-json') ->name('speedtest.latest'); -Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () { +Route::middleware(['auth:sanctum', 'throttle:api', 'accept-json'])->group(function () { require __DIR__.'/api/v1/routes.php'; }); diff --git a/tests/Unit/AcceptJsonMiddlewareTest.php b/tests/Unit/AcceptJsonMiddlewareTest.php new file mode 100644 index 000000000..60287c096 --- /dev/null +++ b/tests/Unit/AcceptJsonMiddlewareTest.php @@ -0,0 +1,160 @@ +handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); + + $content = json_decode($response->getContent(), true); + expect($content['success'])->toBe(true); +}); + +test('AcceptJsonMiddleware accepts requests with Accept: application/json header', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); + + $content = json_decode($response->getContent(), true); + expect($content['success'])->toBe(true); +}); + +test('AcceptJsonMiddleware rejects requests with Accept: */json header', function () { + // Laravel's acceptsJson() returns false for */json + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => '*/json', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(406); + + $content = json_decode($response->getContent(), true); + expect($content['message'])->toBe('This endpoint only accepts JSON. Please include "Accept: application/json" in your request headers.'); + expect($content['error'])->toBe('Unsupported Media Type'); +}); + +test('AcceptJsonMiddleware accepts requests with Accept: application/* header', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'application/*', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); +}); + +test('AcceptJsonMiddleware accepts requests with multiple Accept headers including application/json', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'text/html,application/json,application/xml;q=0.9,*/*;q=0.8', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); +}); + +test('AcceptJsonMiddleware rejects requests with only non-JSON Accept headers', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'text/html,application/xml', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(406); + + $content = json_decode($response->getContent(), true); + expect($content['message'])->toBe('This endpoint only accepts JSON. Please include "Accept: application/json" in your request headers.'); + expect($content['error'])->toBe('Unsupported Media Type'); +}); + +test('AcceptJsonMiddleware sets Content-Type header to application/json when not already set', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + $response = $middleware->handle($request, function () { + return response(['success' => true]); + }); + + expect($response->headers->get('Content-Type'))->toBe('application/json'); +}); + +test('AcceptJsonMiddleware preserves existing application/json Content-Type header', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->headers->get('Content-Type'))->toContain('application/json'); +}); + +test('AcceptJsonMiddleware rejects requests that only accept HTML', function () { + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => 'text/html', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(406); + + $content = json_decode($response->getContent(), true); + expect($content['message'])->toBe('This endpoint only accepts JSON. Please include "Accept: application/json" in your request headers.'); + expect($content['error'])->toBe('Unsupported Media Type'); +}); + +test('AcceptJsonMiddleware accepts requests with */* Accept header', function () { + // Laravel's acceptsJson() returns true for */* + $middleware = new \App\Http\Middleware\AcceptJsonMiddleware; + + $request = \Illuminate\Http\Request::create('/api/test', 'GET', [], [], [], [ + 'HTTP_ACCEPT' => '*/*', + ]); + + $response = $middleware->handle($request, function () { + return response()->json(['success' => true]); + }); + + expect($response->getStatusCode())->toBe(200); +});