[Chore] Consolidate Results, Speedtest & Stats API Endpoints into Dedicated Controllers (#2225)

Co-authored-by: Alex Justesen <alexjustesen@users.noreply.github.com>
This commit is contained in:
Sven van Ginkel
2025-07-28 21:39:33 +02:00
committed by GitHub
parent d8d31be6e4
commit 2d40d98c10
21 changed files with 820 additions and 549 deletions
+38 -2
View File
@@ -2,6 +2,7 @@
namespace App\Actions;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -11,7 +12,22 @@ class GetOoklaSpeedtestServers
{
use AsAction;
/**
* For UI: return the ID, Sponsor, and Name to start a manual test
*/
public function handle(): array
{
return collect(self::fetch())->mapWithKeys(function (array $item) {
return [
$item['id'] => ($item['sponsor'] ?? 'Unknown').' ('.($item['name'] ?? 'Unknown').', '.$item['id'].')',
];
})->toArray();
}
/**
* Fetch the raw Ookla server array from the Ookla API.
*/
public static function fetch(): array
{
$query = [
'engine' => 'js',
@@ -23,6 +39,8 @@ class GetOoklaSpeedtestServers
$response = Http::retry(3, 250)
->timeout(5)
->get(url: 'https://www.speedtest.net/api/js/servers', query: $query);
return $response->json();
} catch (Throwable $e) {
Log::error('Unable to retrieve Ookla servers.', [$e->getMessage()]);
@@ -30,10 +48,28 @@ class GetOoklaSpeedtestServers
'⚠️ Unable to retrieve Ookla servers, check internet connection and see logs.',
];
}
}
return $response->collect()->mapWithKeys(function (array $item, int $key) {
/**
* For API: return array of structured server objects
*/
public static function forApi(): array
{
$servers = self::fetch();
// If the first item is not an array, treat as error or empty
if (empty($servers) || ! is_array($servers) || (isset($servers[0]) && ! is_array($servers[0]))) {
// Optionally, you could return an error message here, but to match the controller's behavior, return an empty array
return [];
}
return collect($servers)->map(function (array $item) {
return [
$item['id'] => $item['sponsor'].' ('.$item['name'].', '.$item['id'].')',
'id' => $item['id'],
'host' => Arr::get($item, 'host', 'Unknown'),
'name' => Arr::get($item, 'sponsor', 'Unknown'),
'location' => Arr::get($item, 'name', 'Unknown'),
'country' => Arr::get($item, 'country', 'Unknown'),
];
})->toArray();
}
+3 -3
View File
@@ -53,9 +53,9 @@ class ApiTokenResource extends Resource
->required()
->bulkToggleable()
->descriptions([
'results:read' => 'Allow this token to read results.',
'speedtests:run' => 'Allow this token to run speedtests.',
'ookla:list-servers' => 'Allow this token to list servers.',
'results:read' => 'Grant this token permission to read results and statistics.',
'speedtests:run' => 'Grant this token permission to run speedtests.',
'ookla:list-servers' => 'Grant this token permission to list available servers.',
]),
DateTimePicker::make('expires_at')
->label('Expires at')
@@ -1,52 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\ResultResource;
use App\Models\Result;
use Http\Discovery\Exception\NotFoundException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
class LatestResult extends ApiController
{
#[OA\Get(
path: '/api/v1/results/latest',
summary: 'Fetch the single most recent result',
operationId: 'getLatestResult',
tags: ['Results'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/Result')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_NOT_FOUND,
description: 'No result found',
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundError')
),
]
)]
public function __invoke(Request $request)
{
$result = Result::query()
->latest()
->firstOr(function () {
self::throw(
e: new NotFoundException('No result found.'),
code: 404,
);
});
return self::sendResponse(
data: new ResultResource($result),
);
}
}
@@ -1,86 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\ResultResource;
use App\Models\Result;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\QueryBuilder;
class ListResults extends ApiController
{
#[OA\Get(
path: '/api/v1/results',
summary: 'List results',
operationId: 'listResults',
tags: ['Results'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(
ref: '#/components/schemas/ResultsCollection'
)
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation failed',
content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')
),
]
)]
public function __invoke(Request $request)
{
$validator = Validator::make($request->all(), [
'per_page' => 'integer|min:1|max:500',
]);
if ($validator->fails()) {
return ApiController::sendResponse(
data: $validator->errors(),
message: 'Validation failed.',
code: 422,
);
}
$results = QueryBuilder::for(Result::class)
->allowedFilters([
AllowedFilter::operator('ping', FilterOperator::DYNAMIC),
AllowedFilter::operator('download', FilterOperator::DYNAMIC),
AllowedFilter::operator('upload', FilterOperator::DYNAMIC),
AllowedFilter::exact('healthy')->nullable(),
AllowedFilter::exact('status'),
AllowedFilter::exact('scheduled'),
AllowedFilter::operator(
name: 'start_at',
internalName: 'created_at',
filterOperator: FilterOperator::DYNAMIC,
),
AllowedFilter::operator(
name: 'end_at',
internalName: 'created_at',
filterOperator: FilterOperator::DYNAMIC,
),
])
->allowedSorts([
'ping',
'download',
'upload',
'created_at',
'updated_at',
])
->jsonPaginate($request->input('per_page', 25));
return ResultResource::collection($results);
}
}
@@ -1,57 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\GetOoklaSpeedtestServers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
class ListSpeedtestServers extends ApiController
{
#[OA\Get(
path: '/api/v1/ookla/list-servers',
summary: 'List available Ookla speedtest servers',
operationId: 'listSpeedtestServers',
tags: ['Servers'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(
ref: '#/components/schemas/ServersCollection'
)
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(
ref: '#/components/schemas/ForbiddenError',
example: ['message' => 'You do not have permission to view speedtest servers.']
)
),
]
)]
public function __invoke(Request $request)
{
if ($request->user()->tokenCant('ookla:list-servers')) {
return self::sendResponse(
data: null,
message: 'You do not have permission to view speedtest servers.',
code: Response::HTTP_FORBIDDEN,
);
}
$servers = GetOoklaSpeedtestServers::run();
return self::sendResponse(
data: $servers,
message: 'Speedtest servers fetched successfully.'
);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\GetOoklaSpeedtestServers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class OoklaController extends ApiController
{
/**
* GET /api/v1/ookla/list-servers
* List available Ookla speedtest servers.
*/
public function __invoke(Request $request)
{
if ($request->user()->tokenCant('ookla:list-servers')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to view speedtest servers.',
code: Response::HTTP_FORBIDDEN,
);
}
$servers = GetOoklaSpeedtestServers::forApi();
return $this->sendResponse(
data: $servers,
message: 'Speedtest servers fetched successfully.'
);
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\ResultResource;
use App\Models\Result;
use Http\Discovery\Exception\NotFoundException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\QueryBuilder;
class ResultsController extends ApiController
{
/**
* GET /results
* List or filter results with optional pagination.
*/
public function list(Request $request)
{
if ($request->user()->tokenCant('results:read')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to view results.',
code: Response::HTTP_FORBIDDEN
);
}
$validator = Validator::make($request->all(), [
'per_page' => 'integer|min:1|max:500',
]);
if ($validator->fails()) {
return $this->sendResponse(
data: $validator->errors(),
message: 'Validation failed.',
code: 422
);
}
$results = QueryBuilder::for(Result::class)
->allowedFilters([
AllowedFilter::operator('ping', FilterOperator::DYNAMIC),
AllowedFilter::operator('download', FilterOperator::DYNAMIC),
AllowedFilter::operator('upload', FilterOperator::DYNAMIC),
AllowedFilter::exact('healthy')->nullable(),
AllowedFilter::exact('status'),
AllowedFilter::exact('scheduled'),
AllowedFilter::operator(
name: 'start_at',
internalName: 'created_at',
filterOperator: FilterOperator::DYNAMIC,
),
AllowedFilter::operator(
name: 'end_at',
internalName: 'created_at',
filterOperator: FilterOperator::DYNAMIC,
),
])
->allowedSorts([
'ping',
'download',
'upload',
'created_at',
'updated_at',
])
->jsonPaginate($request->input('per_page', 25));
return ResultResource::collection($results);
}
/**
* GET /results/{id}
* Fetch a single result by ID.
*/
public function show(Request $request, int $id)
{
if ($request->user()->tokenCant('results:read')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to view results.',
code: Response::HTTP_FORBIDDEN
);
}
$result = Result::findOr($id, function () {
self::throw(
e: new NotFoundException('Result not found.'),
code: 404
);
});
return $this->sendResponse(
data: new ResultResource($result)
);
}
/**
* GET /results/latest
* Fetch the single most recent result.
*/
public function latest(Request $request)
{
if ($request->user()->tokenCant('results:read')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to view results.',
code: Response::HTTP_FORBIDDEN
);
}
$result = Result::latest()
->firstOrFail();
return $this->sendResponse(
data: new ResultResource($result)
);
}
}
@@ -1,59 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\ResultResource;
use App\Models\Result;
use Http\Discovery\Exception\NotFoundException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
class ShowResult extends ApiController
{
#[OA\Get(
path: '/api/v1/results/{id}',
summary: 'Fetch a single result by ID',
operationId: 'getResult',
tags: ['Results'],
parameters: [
new OA\Parameter(
name: 'id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'The ID of the result'
),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/ResultResponse')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_NOT_FOUND,
description: 'Result not found',
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundError')
),
]
)]
public function __invoke(Request $request, int $id)
{
$result = Result::findOr($id, function () {
self::throw(
e: new NotFoundException('Result not found.'),
code: 404,
);
});
return self::sendResponse(
data: new ResultResource($result),
);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\Ookla\RunSpeedtest as RunSpeedtestAction;
use App\Http\Resources\V1\ResultResource;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
class SpeedtestController extends ApiController
{
/**
* POST /api/v1/speedtests/run
* Run a new Ookla speedtest.
*/
public function __invoke(Request $request)
{
if ($request->user()->tokenCant('speedtests:run')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to run speedtests.',
code: Response::HTTP_FORBIDDEN,
);
}
$validator = Validator::make($request->all(), [
'server_id' => 'sometimes|integer',
]);
if ($validator->fails()) {
return $this->sendResponse(
data: $validator->errors(),
message: 'Validation failed.',
code: Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$result = RunSpeedtestAction::run(
serverId: $request->input('server_id'),
);
return $this->sendResponse(
data: new ResultResource($result),
message: 'Speedtest added to the queue.',
code: Response::HTTP_CREATED,
);
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\StatResource;
use App\Models\Result;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
use OpenApi\Attributes\Schema as OASchema;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\QueryBuilder;
class Stats extends ApiController
{
/**
* Handle the incoming request.
*/
#[OA\Get(
path: '/api/v1/stats',
summary: 'Fetch aggregated Speedtest statistics',
operationId: 'getStats',
tags: ['Stats'],
parameters: [
new OA\Parameter(
name: 'start_at',
in: 'query',
description: 'ISO8601 start datetime filter',
required: false,
schema: new OASchema(type: 'string', format: 'date-time')
),
new OA\Parameter(
name: 'end_at',
in: 'query',
description: 'ISO8601 end datetime filter',
required: false,
schema: new OASchema(type: 'string', format: 'date-time')
),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/Stats')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation failed',
content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')
),
]
)]
public function __invoke(Request $request)
{
$stats = QueryBuilder::for(Result::class)
->selectRaw('count(*) as total_results')
->selectRaw('avg(ping) as avg_ping')
->selectRaw('avg(download) as avg_download')
->selectRaw('avg(upload) as avg_upload')
->selectRaw('min(ping) as min_ping')
->selectRaw('min(download) as min_download')
->selectRaw('min(upload) as min_upload')
->selectRaw('max(ping) as max_ping')
->selectRaw('max(download) as max_download')
->selectRaw('max(upload) as max_upload')
->AllowedFilters([
AllowedFilter::operator(name: 'start_at', internalName: 'created_at', filterOperator: FilterOperator::DYNAMIC),
AllowedFilter::operator(name: 'end_at', internalName: 'created_at', filterOperator: FilterOperator::DYNAMIC),
])
->first();
return self::sendResponse(
data: new StatResource($stats),
filters: $request->input('filter'),
);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\StatResource;
use App\Models\Result;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\Enums\FilterOperator;
use Spatie\QueryBuilder\QueryBuilder;
class StatsController extends ApiController
{
/**
* GET /api/v1/stats
* Fetch aggregated Speedtest statistics with optional start/end filters.
*/
public function __invoke(Request $request)
{
if ($request->user()->tokenCant('results:read')) {
return $this->sendResponse(
data: null,
message: 'You do not have permission to view statistics.',
code: Response::HTTP_FORBIDDEN
);
}
// Build the stats query
$stats = QueryBuilder::for(Result::class)
->selectRaw('count(*) as total_results')
->selectRaw('avg(ping) as avg_ping')
->selectRaw('avg(download) as avg_download')
->selectRaw('avg(upload) as avg_upload')
->selectRaw('min(ping) as min_ping')
->selectRaw('min(download) as min_download')
->selectRaw('min(upload) as min_upload')
->selectRaw('max(ping) as max_ping')
->selectRaw('max(download) as max_download')
->selectRaw('max(upload) as max_upload')
->allowedFilters([
AllowedFilter::operator(name: 'start_at', internalName: 'created_at', filterOperator: FilterOperator::DYNAMIC),
AllowedFilter::operator(name: 'end_at', internalName: 'created_at', filterOperator: FilterOperator::DYNAMIC),
])
->first();
// Return wrapped in a resource
return $this->sendResponse(
data: new StatResource($stats)
);
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Support\Enums\IconPosition;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class RunSpeedtestAction extends Component implements HasActions, HasForms
@@ -68,7 +69,7 @@ class RunSpeedtestAction extends Component implements HasActions, HasForms
->label('Speedtest')
->icon('heroicon-o-rocket-launch')
->iconPosition(IconPosition::Before)
->hidden(! auth()->user()->is_admin)
->hidden(! Auth::check() && Auth::user()->is_admin)
->extraAttributes([
'id' => 'speedtestAction',
]);
@@ -0,0 +1,39 @@
<?php
namespace App\OpenApi\Annotations\V1;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
#[OA\PathItem(
path: '/api/v1/ookla',
description: 'Endpoints for retrieving Ookla speedtest servers and related resources.'
)]
class OoklaAnnotations
{
#[OA\Get(
path: '/api/v1/ookla/list-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.',
operationId: 'listOoklaServers',
tags: ['Servers'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'Servers retrieved successfully',
content: new OA\JsonContent(ref: '#/components/schemas/ServersCollection')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError')
),
]
)]
public function listServers(): void {}
}
@@ -0,0 +1,112 @@
<?php
namespace App\OpenApi\Annotations\V1;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
#[OA\PathItem(
path: '/api/v1/results',
description: 'Endpoints for retrieving speedtest results.'
)]
class ResultsAnnotations
{
#[OA\Get(
path: '/api/v1/results',
summary: 'List all results',
operationId: 'listResults',
tags: ['Results'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/ResultsCollection')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation failed',
content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')
),
]
)]
public function index(): void {}
#[OA\Get(
path: '/api/v1/results/{id}',
summary: 'Get a single result',
operationId: 'getResult',
tags: ['Results'],
parameters: [
new OA\Parameter(
name: 'id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'The ID of the result'
),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/ResultResponse')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_NOT_FOUND,
description: 'Result not found',
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundError')
),
]
)]
public function show(): void {}
#[OA\Get(
path: '/api/v1/results/latest',
summary: 'Get the most recent result',
operationId: 'getLatestResult',
tags: ['Results'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/Result')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_NOT_FOUND,
description: 'No result found',
content: new OA\JsonContent(ref: '#/components/schemas/NotFoundError')
),
]
)]
public function latest(): void {}
}
@@ -1,15 +1,15 @@
<?php
namespace App\Http\Controllers\Api\V1;
namespace App\OpenApi\Annotations\V1;
use App\Actions\Ookla\RunSpeedtest as RunSpeedtestAction;
use App\Http\Resources\V1\ResultResource;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class RunSpeedtest extends ApiController
#[OA\Tag(
name: 'Speedtests',
description: 'Endpoints for running speedtests and listing servers.'
)]
class SpeedtestAnnotations
{
#[OA\Post(
path: '/api/v1/speedtests/run',
@@ -48,36 +48,36 @@ class RunSpeedtest extends ApiController
),
]
)]
public function __invoke(Request $request)
public function run(): void
{
if ($request->user()->tokenCant('speedtests:run')) {
return self::sendResponse(
data: null,
message: 'You do not have permission to run speedtests.',
code: Response::HTTP_FORBIDDEN,
);
}
$validator = Validator::make($request->all(), [
'server_id' => 'sometimes|integer',
]);
if ($validator->fails()) {
return ApiController::sendResponse(
data: $validator->errors(),
message: 'Validation failed.',
code: Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$result = RunSpeedtestAction::run(
serverId: $request->input('server_id'),
);
return self::sendResponse(
data: new ResultResource($result),
message: 'Speedtest added to the queue.',
code: Response::HTTP_CREATED,
);
// Annotation placeholder for runSpeedtest
}
#[OA\Get(
path: '/api/v1/speedtests/list-servers',
summary: 'List available Ookla speedtest servers',
operationId: 'listSpeedtestServers',
tags: ['Speedtests'],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'OK',
content: new OA\JsonContent(ref: '#/components/schemas/ServersCollection')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(
ref: '#/components/schemas/ForbiddenError',
example: ['message' => 'You do not have permission to view speedtest servers.']
)
),
]
)]
public function listServers(): void {}
}
@@ -0,0 +1,59 @@
<?php
namespace App\OpenApi\Annotations\V1;
use Illuminate\Http\Response;
use OpenApi\Attributes as OA;
#[OA\PathItem(
path: '/api/v1/stats',
description: 'Endpoints for viewing performance statistics.'
)]
class StatsAnnotations
{
#[OA\Get(
path: '/api/v1/stats',
summary: 'Fetch aggregated Speedtest statistics',
operationId: 'getStats',
tags: ['Stats'],
parameters: [
new OA\Parameter(
name: 'start_at',
in: 'query',
description: 'Filter stats from this date/time (ISO 8601)',
required: false,
schema: new OA\Schema(type: 'string', format: 'date-time')
),
new OA\Parameter(
name: 'end_at',
in: 'query',
description: 'Filter stats up to this date/time (ISO 8601)',
required: false,
schema: new OA\Schema(type: 'string', format: 'date-time')
),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'Statistics fetched successfully',
content: new OA\JsonContent(ref: '#/components/schemas/Stats')
),
new OA\Response(
response: Response::HTTP_UNAUTHORIZED,
description: 'Unauthenticated',
content: new OA\JsonContent(ref: '#/components/schemas/UnauthenticatedError')
),
new OA\Response(
response: Response::HTTP_FORBIDDEN,
description: 'Forbidden',
content: new OA\JsonContent(ref: '#/components/schemas/ForbiddenError')
),
new OA\Response(
response: Response::HTTP_UNPROCESSABLE_ENTITY,
description: 'Validation error',
content: new OA\JsonContent(ref: '#/components/schemas/ValidationError')
),
]
)]
public function getStats(): void {}
}
+12 -4
View File
@@ -29,10 +29,18 @@ use OpenApi\Attributes as OA;
]
),
tags: [
new OA\Tag(name: 'Results', description: "Operations related to Speedtest results.\nRequires an API token with scope `results:read`."),
new OA\Tag(name: 'Speedtests', description: "Operations to run speedtests.\nRequires an API token with scope `speedtests:run`."),
new OA\Tag(name: 'Servers', description: "Operations for speedtest servers.\nRequires an API token with scope `ookla:list-servers`."),
new OA\Tag(name: 'Stats', description: "Operations for statistics.\nRequires an API token with scope `results:read`."),
new OA\Tag(
name: 'Results',
description: 'Endpoints for accessing and filtering speedtest results. Requires API token with `results:read` scope.'
),
new OA\Tag(
name: 'Speedtests',
description: 'Endpoints for initiating speedtests and retrieving available servers. Requires `speedtests:run` or `speedtests:read` token scopes.'
),
new OA\Tag(
name: 'Stats',
description: 'Endpoints for retrieving aggregated statistics and performance metrics. Requires `speedtests:read` token scope.'
),
]
)]
class OpenApiDefinition {}
@@ -7,17 +7,22 @@ use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'ServersCollection',
type: 'object',
description: 'Mapping of server IDs to display names',
description: 'Collection of Ookla speedtest servers',
properties: [
new OA\Property(
property: 'data',
type: 'object',
description: 'Map of server ID to display name',
example: [
'data' => [
'12345' => 'Fibernet (New York, 12345)',
],
],
type: 'array',
description: 'List of server objects',
items: new OA\Items(
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'string'),
new OA\Property(property: 'host', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'location', type: 'string'),
new OA\Property(property: 'country', type: 'string'),
]
)
),
new OA\Property(
property: 'message',
+244 -143
View File
@@ -5,53 +5,16 @@
"version": "1.0.0"
},
"paths": {
"/api/v1/results/latest": {
"get": {
"tags": [
"Results"
],
"summary": "Fetch the single most recent result",
"operationId": "getLatestResult",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthenticatedError"
}
}
}
},
"404": {
"description": "No result found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
}
}
"/api/v1/ookla": {
"description": "Endpoints for retrieving Ookla speedtest servers and related resources."
},
"/api/v1/results": {
"description": "Endpoints for retrieving speedtest results.",
"get": {
"tags": [
"Results"
],
"summary": "List results",
"summary": "List all results",
"operationId": "listResults",
"responses": {
"200": {
@@ -64,6 +27,16 @@
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
@@ -87,16 +60,91 @@
}
}
},
"/api/v1/stats": {
"description": "Endpoints for viewing performance statistics.",
"get": {
"tags": [
"Stats"
],
"summary": "Fetch aggregated Speedtest statistics",
"operationId": "getStats",
"parameters": [
{
"name": "start_at",
"in": "query",
"description": "Filter stats from this date/time (ISO 8601)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "end_at",
"in": "query",
"description": "Filter stats up to this date/time (ISO 8601)",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
}
}
],
"responses": {
"200": {
"description": "Statistics fetched successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Stats"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthenticatedError"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
}
}
}
},
"422": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
}
}
}
},
"/api/v1/ookla/list-servers": {
"get": {
"tags": [
"Servers"
],
"summary": "List available Ookla speedtest servers",
"operationId": "listSpeedtestServers",
"description": "Returns an array of available Ookla speedtest servers. Requires an API token with `ookla:list-servers` scope.",
"operationId": "listOoklaServers",
"responses": {
"200": {
"description": "OK",
"description": "Servers retrieved successfully",
"content": {
"application/json": {
"schema": {
@@ -121,9 +169,119 @@
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"example": {
"message": "You do not have permission to view speedtest servers."
}
}
}
}
}
}
},
"/api/v1/results/{id}": {
"get": {
"tags": [
"Results"
],
"summary": "Get a single result",
"operationId": "getResult",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the result",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResultResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthenticatedError"
}
}
}
},
"404": {
"description": "Result not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
}
}
},
"/api/v1/results/latest": {
"get": {
"tags": [
"Results"
],
"summary": "Get the most recent result",
"operationId": "getLatestResult",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthenticatedError"
}
}
}
},
"404": {
"description": "No result found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
@@ -193,31 +351,20 @@
}
}
},
"/api/v1/results/{id}": {
"/api/v1/speedtests/list-servers": {
"get": {
"tags": [
"Results"
],
"summary": "Fetch a single result by ID",
"operationId": "getResult",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the result",
"required": true,
"schema": {
"type": "integer"
}
}
"Speedtests"
],
"summary": "List available Ookla speedtest servers",
"operationId": "listSpeedtestServers",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResultResponse"
"$ref": "#/components/schemas/ServersCollection"
}
}
}
@@ -232,76 +379,15 @@
}
}
},
"404": {
"description": "Result not found",
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
}
}
}
}
}
}
},
"/api/v1/stats": {
"get": {
"tags": [
"Stats"
],
"summary": "Fetch aggregated Speedtest statistics",
"description": "Handle the incoming request.",
"operationId": "getStats",
"parameters": [
{
"name": "start_at",
"in": "query",
"description": "ISO8601 start datetime filter",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
}
},
{
"name": "end_at",
"in": "query",
"description": "ISO8601 end datetime filter",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Stats"
}
}
}
},
"401": {
"description": "Unauthenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthenticatedError"
}
}
}
},
"422": {
"description": "Validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
"$ref": "#/components/schemas/ForbiddenError"
},
"example": {
"message": "You do not have permission to view speedtest servers."
}
}
}
@@ -664,15 +750,30 @@
"additionalProperties": false
},
"ServersCollection": {
"description": "Mapping of server IDs to display names",
"description": "Collection of Ookla speedtest servers",
"properties": {
"data": {
"description": "Map of server ID to display name",
"type": "object",
"example": {
"data": {
"12345": "Fibernet (New York, 12345)"
}
"description": "List of server objects",
"type": "array",
"items": {
"properties": {
"id": {
"type": "string"
},
"host": {
"type": "string"
},
"name": {
"type": "string"
},
"location": {
"type": "string"
},
"country": {
"type": "string"
}
},
"type": "object"
}
},
"message": {
@@ -850,19 +951,19 @@
"tags": [
{
"name": "Results",
"description": "Operations related to Speedtest results.\nRequires an API token with scope `results:read`."
"description": "Endpoints for accessing and filtering speedtest results. Requires API token with `results:read` scope."
},
{
"name": "Speedtests",
"description": "Operations to run speedtests.\nRequires an API token with scope `speedtests:run`."
},
{
"name": "Servers",
"description": "Operations for speedtest servers.\nRequires an API token with scope `ookla:list-servers`."
"description": "Endpoints for running speedtests and listing servers."
},
{
"name": "Stats",
"description": "Operations for statistics.\nRequires an API token with scope `results:read`."
"description": "Endpoints for retrieving aggregated statistics and performance metrics. Requires `speedtests:read` token scope."
},
{
"name": "Servers",
"description": "Servers"
}
]
}
-2
View File
@@ -5,8 +5,6 @@ use Illuminate\Support\Facades\Route;
/**
* Health check route to ensure the API is up and running.
*
* @deprecated
*/
Route::get('/healthcheck', function () {
return response()->json([
+11 -13
View File
@@ -1,29 +1,27 @@
<?php
use App\Http\Controllers\Api\V1\LatestResult;
use App\Http\Controllers\Api\V1\ListResults;
use App\Http\Controllers\Api\V1\ListSpeedtestServers;
use App\Http\Controllers\Api\V1\RunSpeedtest;
use App\Http\Controllers\Api\V1\ShowResult;
use App\Http\Controllers\Api\V1\Stats;
use App\Http\Controllers\Api\V1\OoklaController;
use App\Http\Controllers\Api\V1\ResultsController;
use App\Http\Controllers\Api\V1\SpeedtestController;
use App\Http\Controllers\Api\V1\StatsController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/results', ListResults::class)
Route::get('/results', [ResultsController::class, 'list'])
->name('results.list');
Route::get('/results/latest', LatestResult::class)
Route::get('/results/latest', [ResultsController::class, 'latest'])
->name('results.latest');
Route::get('/results/{result}', ShowResult::class)
Route::get('/results/{id}', [ResultsController::class, 'show'])
->name('results.show');
Route::post('/speedtests/run', RunSpeedtest::class)
Route::post('/speedtests/run', SpeedtestController::class)
->name('speedtests.run');
Route::get('/ookla/list-servers', ListSpeedtestServers::class)
Route::get('/ookla/list-servers', OoklaController::class)
->name('ookla.list-servers');
Route::get('/stats', Stats::class)
->name('stats');
Route::get('/stats', StatsController::class)
->name('stats.aggregated');
});