API requires accept json header (#2333)

Co-authored-by: Alex Justesen <1144087+alexjustesen@users.noreply.github.com>
Co-authored-by: GitHub Action <actions@github.com>
This commit is contained in:
Alex Justesen
2025-09-14 10:51:24 -04:00
committed by GitHub
parent 5aec7fdd7b
commit 4abbfe40fe
11 changed files with 388 additions and 4 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AcceptJsonMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): SymfonyResponse
{
// Check if the Accept header includes application/json
if (! $request->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;
}
}
@@ -17,6 +17,9 @@ class OoklaAnnotations
description: 'Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.', description: 'Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.',
operationId: 'listOoklaServers', operationId: 'listOoklaServers',
tags: ['Servers'], tags: ['Servers'],
parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
],
responses: [ responses: [
new OA\Response( new OA\Response(
response: Response::HTTP_OK, response: Response::HTTP_OK,
@@ -33,6 +36,11 @@ class OoklaAnnotations
description: 'Forbidden', description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') 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 {} public function listServers(): void {}
@@ -17,6 +17,7 @@ class ResultsAnnotations
operationId: 'listResults', operationId: 'listResults',
tags: ['Results'], tags: ['Results'],
parameters: [ parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
new OA\Parameter( new OA\Parameter(
name: 'per_page', name: 'per_page',
in: 'query', in: 'query',
@@ -104,6 +105,11 @@ class ResultsAnnotations
description: 'Unauthenticated', description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') 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( new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY, response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation failed', description: 'Validation failed',
@@ -119,6 +125,7 @@ class ResultsAnnotations
operationId: 'getResult', operationId: 'getResult',
tags: ['Results'], tags: ['Results'],
parameters: [ parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
new OA\Parameter( new OA\Parameter(
name: 'id', name: 'id',
in: 'path', in: 'path',
@@ -143,6 +150,11 @@ class ResultsAnnotations
description: 'Unauthenticated', description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') 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( new OA\Response(
response: Response::HTTP_NOT_FOUND, response: Response::HTTP_NOT_FOUND,
description: 'Result not found', description: 'Result not found',
@@ -157,6 +169,9 @@ class ResultsAnnotations
summary: 'Get the most recent result', summary: 'Get the most recent result',
operationId: 'getLatestResult', operationId: 'getLatestResult',
tags: ['Results'], tags: ['Results'],
parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
],
responses: [ responses: [
new OA\Response( new OA\Response(
response: Response::HTTP_OK, response: Response::HTTP_OK,
@@ -173,6 +188,11 @@ class ResultsAnnotations
description: 'Unauthenticated', description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError') 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( new OA\Response(
response: Response::HTTP_NOT_FOUND, response: Response::HTTP_NOT_FOUND,
description: 'No result found', description: 'No result found',
@@ -17,6 +17,7 @@ class SpeedtestAnnotations
operationId: 'runSpeedtest', operationId: 'runSpeedtest',
tags: ['Speedtests'], tags: ['Speedtests'],
parameters: [ parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
new OA\Parameter( new OA\Parameter(
name: 'server_id', name: 'server_id',
in: 'query', in: 'query',
@@ -41,6 +42,11 @@ class SpeedtestAnnotations
description: 'Forbidden', description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') 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( new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY, response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation error', description: 'Validation error',
@@ -58,6 +64,9 @@ class SpeedtestAnnotations
summary: 'List available Ookla speedtest servers', summary: 'List available Ookla speedtest servers',
operationId: 'listSpeedtestServers', operationId: 'listSpeedtestServers',
tags: ['Speedtests'], tags: ['Speedtests'],
parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
],
responses: [ responses: [
new OA\Response( new OA\Response(
response: Response::HTTP_OK, response: Response::HTTP_OK,
@@ -77,6 +86,11 @@ class SpeedtestAnnotations
example: ['message' => 'You do not have permission to view speedtest servers.'] 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 {} public function listServers(): void {}
@@ -17,6 +17,7 @@ class StatsAnnotations
operationId: 'getStats', operationId: 'getStats',
tags: ['Stats'], tags: ['Stats'],
parameters: [ parameters: [
new OA\Parameter(ref: '#/components/parameters/AcceptHeader'),
new OA\Parameter( new OA\Parameter(
name: 'start_at', name: 'start_at',
in: 'query', in: 'query',
@@ -48,6 +49,11 @@ class StatsAnnotations
description: 'Forbidden', description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError') 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( new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY, response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation error', description: 'Validation error',
+2 -1
View File
@@ -20,11 +20,12 @@ use OpenApi\Attributes as OA;
], ],
parameters: [ parameters: [
new OA\Parameter( new OA\Parameter(
parameter: 'AcceptHeader',
name: 'Accept', name: 'Accept',
in: 'header', in: 'header',
required: true, required: true,
schema: new OA\Schema(type: 'string', default: 'application/json'), 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'
), ),
] ]
), ),
@@ -0,0 +1,24 @@
<?php
namespace App\OpenApi\Schemas;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotAcceptableError',
description: 'Error response when the Accept header is missing or invalid',
type: 'object',
properties: [
new OA\Property(
property: 'message',
type: 'string',
example: 'This endpoint only accepts JSON. Please include "Accept: application/json" in your request headers.'
),
new OA\Property(
property: 'error',
type: 'string',
example: 'Unsupported Media Type'
),
]
)]
class NotAcceptableErrorSchema {}
+1
View File
@@ -17,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([ $middleware->alias([
'getting-started' => App\Http\Middleware\GettingStarted::class, 'getting-started' => App\Http\Middleware\GettingStarted::class,
'public-dashboard' => App\Http\Middleware\PublicDashboard::class, 'public-dashboard' => App\Http\Middleware\PublicDashboard::class,
'accept-json' => App\Http\Middleware\AcceptJsonMiddleware::class,
]); ]);
$middleware->prependToGroup('api', [ $middleware->prependToGroup('api', [
+113 -2
View File
@@ -17,6 +17,9 @@
"summary": "List all results", "summary": "List all results",
"operationId": "listResults", "operationId": "listResults",
"parameters": [ "parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
},
{ {
"name": "per_page", "name": "per_page",
"in": "query", "in": "query",
@@ -156,6 +159,16 @@
} }
} }
}, },
"406": {
"description": "Not Acceptable - Missing or invalid Accept header",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotAcceptableError"
}
}
}
},
"422": { "422": {
"description": "Validation failed", "description": "Validation failed",
"content": { "content": {
@@ -178,6 +191,9 @@
"summary": "Fetch aggregated Speedtest statistics", "summary": "Fetch aggregated Speedtest statistics",
"operationId": "getStats", "operationId": "getStats",
"parameters": [ "parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
},
{ {
"name": "start_at", "name": "start_at",
"in": "query", "in": "query",
@@ -230,6 +246,16 @@
} }
} }
}, },
"406": {
"description": "Not Acceptable - Missing or invalid Accept header",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotAcceptableError"
}
}
}
},
"422": { "422": {
"description": "Validation error", "description": "Validation error",
"content": { "content": {
@@ -251,6 +277,11 @@
"summary": "List available Ookla speedtest servers", "summary": "List available Ookla speedtest servers",
"description": "Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.", "description": "Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.",
"operationId": "listOoklaServers", "operationId": "listOoklaServers",
"parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Servers retrieved successfully", "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", "summary": "Get a single result",
"operationId": "getResult", "operationId": "getResult",
"parameters": [ "parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
},
{ {
"name": "id", "name": "id",
"in": "path", "in": "path",
@@ -334,6 +378,16 @@
} }
} }
}, },
"406": {
"description": "Not Acceptable - Missing or invalid Accept header",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotAcceptableError"
}
}
}
},
"404": { "404": {
"description": "Result not found", "description": "Result not found",
"content": { "content": {
@@ -354,6 +408,11 @@
], ],
"summary": "Get the most recent result", "summary": "Get the most recent result",
"operationId": "getLatestResult", "operationId": "getLatestResult",
"parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -385,6 +444,16 @@
} }
} }
}, },
"406": {
"description": "Not Acceptable - Missing or invalid Accept header",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotAcceptableError"
}
}
}
},
"404": { "404": {
"description": "No result found", "description": "No result found",
"content": { "content": {
@@ -406,6 +475,9 @@
"summary": "Run a new Ookla speedtest", "summary": "Run a new Ookla speedtest",
"operationId": "runSpeedtest", "operationId": "runSpeedtest",
"parameters": [ "parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
},
{ {
"name": "server_id", "name": "server_id",
"in": "query", "in": "query",
@@ -447,6 +519,16 @@
} }
} }
}, },
"406": {
"description": "Not Acceptable - Missing or invalid Accept header",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotAcceptableError"
}
}
}
},
"422": { "422": {
"description": "Validation error", "description": "Validation error",
"content": { "content": {
@@ -467,6 +549,11 @@
], ],
"summary": "List available Ookla speedtest servers", "summary": "List available Ookla speedtest servers",
"operationId": "listSpeedtestServers", "operationId": "listSpeedtestServers",
"parameters": [
{
"$ref": "#/components/parameters/AcceptHeader"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "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" "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": { "NotFoundError": {
"description": "Error when a requested result is not found", "description": "Error when a requested result is not found",
"properties": { "properties": {
@@ -1038,10 +1149,10 @@
} }
}, },
"parameters": { "parameters": {
"Accept": { "AcceptHeader": {
"name": "Accept", "name": "Accept",
"in": "header", "in": "header",
"description": "Expected response format", "description": "Must be \"application/json\" - this API only accepts and returns JSON",
"required": true, "required": true,
"schema": { "schema": {
"type": "string", "type": "string",
+2 -1
View File
@@ -20,8 +20,9 @@ Route::get('/healthcheck', function () {
* @deprecated * @deprecated
*/ */
Route::get('/speedtest/latest', GetLatestController::class) Route::get('/speedtest/latest', GetLatestController::class)
->middleware('accept-json')
->name('speedtest.latest'); ->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'; require __DIR__.'/api/v1/routes.php';
}); });
+160
View File
@@ -0,0 +1,160 @@
<?php
test('AcceptJsonMiddleware accepts requests without Accept header (Laravel default)', function () {
// Laravel's acceptsJson() returns true when no Accept header is present
$middleware = new \App\Http\Middleware\AcceptJsonMiddleware;
$request = \Illuminate\Http\Request::create('/api/test', 'GET');
$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 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);
});