Files
fuel-price/app/Http/Controllers/Api/StationController.php
Ovidiu U 895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00

73 lines
2.4 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Services\PostcodeService;
use App\Services\StationSearch\SearchCriteria;
use App\Services\StationSearch\StationSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class StationController extends Controller
{
public function __construct(
private readonly PostcodeService $postcodeService,
private readonly StationSearchService $searchService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse
{
[$lat, $lng] = $this->resolveCoordinates($request);
$criteria = new SearchCriteria(
lat: $lat,
lng: $lng,
fuelType: $request->fuelType(),
radiusKm: $request->radius(),
sort: $request->sort(),
);
$result = $this->searchService->search(
$criteria,
$request->user(),
hash('sha256', $request->ip() ?? ''),
);
return response()->json([
'data' => StationResource::collection($result->stations),
'meta' => [
'count' => $result->stations->count(),
'fuel_type' => $criteria->fuelType->value,
'radius_km' => $criteria->radiusKm,
'lat' => $criteria->lat,
'lng' => $criteria->lng,
'lowest_pence' => $result->pricesSummary['lowest'],
'highest_pence' => $result->pricesSummary['highest'],
'cheapest_price_pence' => $result->pricesSummary['lowest'],
'avg_pence' => $result->pricesSummary['avg'],
'reliability_counts' => $result->reliabilityCounts,
],
'prediction' => $result->prediction,
]);
}
/** @return array{0: float, 1: float} */
private function resolveCoordinates(NearbyStationsRequest $request): array
{
if ($request->filled('postcode')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
if ($location === null) {
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
}
return [$location->lat, $location->lng];
}
return [(float) $request->input('lat'), (float) $request->input('lng')];
}
}