Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.23% |
129 / 130 |
|
92.31% |
12 / 13 |
CRAP | |
0.00% |
0 / 1 |
| MapApiController | |
99.22% |
128 / 129 |
|
92.31% |
12 / 13 |
53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| actionGeocode | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| actionReverse | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
| actionRoute | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| actionTrip | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| actionNearby | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
9 | |||
| actionPoints | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| actionProviders | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| actionHierarchy | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| actionGpx | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
4 | |||
| parseTripGeoPoints | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| parseCoordinatePairs | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
7 | |||
| jsonResponse | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Map\Presentation\Api; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Map\Application\Service\MapGeocodingService; |
| 12 | use App\Modules\Map\Application\Service\MapRoutingService; |
| 13 | use App\Modules\Map\Application\Service\MapSpatialQueryService; |
| 14 | use App\Modules\Map\Domain\Model\GeoPoint; |
| 15 | use App\Modules\Map\Domain\Repository\IpGeolocationServiceInterface; |
| 16 | use App\Modules\Map\Infrastructure\Provider\SmartMapLoadBalancer; |
| 17 | use PDO; |
| 18 | use Psr\Http\Message\ResponseFactoryInterface; |
| 19 | use Psr\Http\Message\ResponseInterface; |
| 20 | use Psr\Http\Message\ServerRequestInterface; |
| 21 | |
| 22 | /** |
| 23 | * Map REST API Controller. |
| 24 | * |
| 25 | * Exposes JSON API endpoints for internal geocoding, reverse geocoding, route calculations, and nearby markers. |
| 26 | * |
| 27 | * @package App\Modules\Map\Presentation\Api |
| 28 | */ |
| 29 | final readonly class MapApiController |
| 30 | { |
| 31 | private const string ERROR_DB_NOT_CONFIGURED = 'Database connection not configured.'; |
| 32 | private MapSpatialQueryService $spatialQueryService; |
| 33 | |
| 34 | /** |
| 35 | * MapApiController constructor. |
| 36 | * |
| 37 | * @param ResponseFactoryInterface $responseFactory PSR-17 response factory. |
| 38 | * @param MapGeocodingService $geocodingService Geocoding application service. |
| 39 | * @param MapRoutingService $routingService Routing application service. |
| 40 | * @param PDO|null $pdo Database connection. |
| 41 | * @param IpGeolocationServiceInterface|null $ipGeoService IP geolocation resolver service. |
| 42 | * @param SmartMapLoadBalancer|null $loadBalancer Multi-provider map load balancer. |
| 43 | * @param MapSpatialQueryService|null $spatialQueryService Spatial and marker query service. |
| 44 | */ |
| 45 | public function __construct( |
| 46 | private ResponseFactoryInterface $responseFactory, |
| 47 | private MapGeocodingService $geocodingService, |
| 48 | private MapRoutingService $routingService, |
| 49 | private ?PDO $pdo = null, |
| 50 | private ?IpGeolocationServiceInterface $ipGeoService = null, |
| 51 | private ?SmartMapLoadBalancer $loadBalancer = null, |
| 52 | ?MapSpatialQueryService $spatialQueryService = null |
| 53 | ) { |
| 54 | $this->spatialQueryService = $spatialQueryService |
| 55 | ?? new MapSpatialQueryService($this->pdo, $this->ipGeoService); |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * GET /api/v1/maps/geocode?q=... |
| 60 | */ |
| 61 | public function actionGeocode(ServerRequestInterface $request): ResponseInterface |
| 62 | { |
| 63 | $params = $request->getQueryParams(); |
| 64 | $query = (string) ($params['q'] ?? ''); |
| 65 | $limit = isset($params['limit']) ? (int) $params['limit'] : 5; |
| 66 | |
| 67 | $results = $this->geocodingService->searchAddress($query, $limit); |
| 68 | $payload = array_map(static fn(GeoPoint $p): array => $p->toArray(), $results); |
| 69 | |
| 70 | return $this->jsonResponse(['results' => $payload]); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * GET /api/v1/maps/reverse?lat=...&lon=... |
| 75 | */ |
| 76 | public function actionReverse(ServerRequestInterface $request): ResponseInterface |
| 77 | { |
| 78 | $params = $request->getQueryParams(); |
| 79 | $lat = isset($params['lat']) ? (float) $params['lat'] : null; |
| 80 | $lon = isset($params['lon']) ? (float) $params['lon'] : null; |
| 81 | |
| 82 | if ($lat === null || $lon === null) { |
| 83 | return $this->jsonResponse(['error' => 'Missing lat/lon parameters.'], 400); |
| 84 | } |
| 85 | |
| 86 | $result = $this->geocodingService->getAddressFromCoordinates($lat, $lon); |
| 87 | if ($result === null) { |
| 88 | return $this->jsonResponse(['error' => 'Address not found.'], 404); |
| 89 | } |
| 90 | |
| 91 | return $this->jsonResponse(['point' => $result->toArray()]); |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * GET /api/v1/maps/route?origin=lat,lon&destination=lat,lon |
| 96 | */ |
| 97 | public function actionRoute(ServerRequestInterface $request): ResponseInterface |
| 98 | { |
| 99 | $params = $request->getQueryParams(); |
| 100 | $originStr = (string) ($params['origin'] ?? ''); |
| 101 | $destStr = (string) ($params['destination'] ?? ''); |
| 102 | |
| 103 | $originParts = explode(',', $originStr); |
| 104 | $destParts = explode(',', $destStr); |
| 105 | |
| 106 | if (count($originParts) < 2 || count($destParts) < 2) { |
| 107 | return $this->jsonResponse(['error' => 'Invalid origin or destination coordinates.'], 400); |
| 108 | } |
| 109 | |
| 110 | $origin = new GeoPoint(latitude: (float) $originParts[0], longitude: (float) $originParts[1]); |
| 111 | $dest = new GeoPoint(latitude: (float) $destParts[0], longitude: (float) $destParts[1]); |
| 112 | |
| 113 | $route = $this->routingService->calculateRoute($origin, $dest); |
| 114 | if ($route === null) { |
| 115 | return $this->jsonResponse(['error' => 'No route found between coordinates.'], 404); |
| 116 | } |
| 117 | |
| 118 | return $this->jsonResponse(['route' => $route->toArray()]); |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * GET /api/v1/maps/trip?points=lat1,lon1;lat2,lon2;lat3,lon3 |
| 123 | */ |
| 124 | public function actionTrip(ServerRequestInterface $request): ResponseInterface |
| 125 | { |
| 126 | $params = $request->getQueryParams(); |
| 127 | $pointsStr = (string) ($params['points'] ?? ''); |
| 128 | $geoPoints = $this->parseTripGeoPoints($pointsStr); |
| 129 | |
| 130 | if (count($geoPoints) < 2) { |
| 131 | return $this->jsonResponse(['error' => 'At least two stop points are required for trip.'], 400); |
| 132 | } |
| 133 | |
| 134 | $roundtrip = !isset($params['roundtrip']) || (int) $params['roundtrip'] === 1; |
| 135 | $trip = $this->routingService->optimizeTrip($geoPoints, $roundtrip); |
| 136 | if ($trip === null) { |
| 137 | return $this->jsonResponse(['error' => 'Unable to optimize trip for given coordinates.'], 404); |
| 138 | } |
| 139 | |
| 140 | return $this->jsonResponse(['trip' => $trip->toArray()]); |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * GET /api/v1/maps/nearby?lat=...&lon=...&radius_km=...&module=companies&limit=50 |
| 145 | */ |
| 146 | public function actionNearby(ServerRequestInterface $request): ResponseInterface |
| 147 | { |
| 148 | if ($this->pdo === null) { |
| 149 | return $this->jsonResponse(['error' => self::ERROR_DB_NOT_CONFIGURED], 500); |
| 150 | } |
| 151 | |
| 152 | $params = $request->getQueryParams(); |
| 153 | $lat = isset($params['lat']) ? (float) $params['lat'] : null; |
| 154 | $lon = isset($params['lon']) ? (float) $params['lon'] : null; |
| 155 | $radiusKm = isset($params['radius_km']) ? max(0.1, (float) $params['radius_km']) : 25.0; |
| 156 | $module = (string) ($params['module'] ?? 'companies'); |
| 157 | $limit = isset($params['limit']) ? max(1, min((int) $params['limit'], 200)) : 50; |
| 158 | |
| 159 | if ($lat === null || $lon === null) { |
| 160 | return $this->jsonResponse(['error' => 'Missing origin lat/lon parameters.'], 400); |
| 161 | } |
| 162 | |
| 163 | $result = $this->spatialQueryService->findNearbyRecords($lat, $lon, $radiusKm, $module, $limit); |
| 164 | |
| 165 | return $result !== null |
| 166 | ? $this->jsonResponse($result) |
| 167 | : $this->jsonResponse(['error' => 'Unsupported module.'], 400); |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * GET /api/v1/maps/points?module=companies&ids=1,2,3 |
| 172 | */ |
| 173 | public function actionPoints(ServerRequestInterface $request): ResponseInterface |
| 174 | { |
| 175 | if ($this->pdo === null) { |
| 176 | return $this->jsonResponse(['error' => self::ERROR_DB_NOT_CONFIGURED], 500); |
| 177 | } |
| 178 | |
| 179 | $params = $request->getQueryParams(); |
| 180 | $module = (string) ($params['module'] ?? 'companies'); |
| 181 | $idsStr = (string) ($params['ids'] ?? ''); |
| 182 | $ids = array_filter(array_map('intval', explode(',', $idsStr))); |
| 183 | |
| 184 | $records = $this->spatialQueryService->fetchModulePoints($module, $ids); |
| 185 | if ($records === null) { |
| 186 | return $this->jsonResponse(['error' => 'Unsupported module.'], 400); |
| 187 | } |
| 188 | |
| 189 | return $this->jsonResponse(['module' => $module, 'records' => $records]); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * GET /api/v1/maps/providers |
| 194 | */ |
| 195 | public function actionProviders(): ResponseInterface |
| 196 | { |
| 197 | if ($this->loadBalancer === null) { |
| 198 | return $this->jsonResponse(['providers' => []]); |
| 199 | } |
| 200 | |
| 201 | return $this->jsonResponse($this->loadBalancer->exportTileTemplates()); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * GET /api/v1/maps/hierarchy?module=companies&id=5 |
| 206 | */ |
| 207 | public function actionHierarchy(ServerRequestInterface $request): ResponseInterface |
| 208 | { |
| 209 | if ($this->pdo === null) { |
| 210 | return $this->jsonResponse(['error' => self::ERROR_DB_NOT_CONFIGURED], 500); |
| 211 | } |
| 212 | |
| 213 | $params = $request->getQueryParams(); |
| 214 | $module = (string) ($params['module'] ?? 'companies'); |
| 215 | $id = isset($params['id']) ? (int) $params['id'] : 0; |
| 216 | |
| 217 | $result = $id > 0 ? $this->spatialQueryService->fetchHierarchyPoints($module, $id) : null; |
| 218 | if ($result === null) { |
| 219 | return $this->jsonResponse(['error' => 'Invalid module or record ID.'], 400); |
| 220 | } |
| 221 | |
| 222 | return $this->jsonResponse($result); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * GET /api/v1/maps/gpx?points=lat1,lon1;lat2,lon2;...&name=Trasa |
| 227 | * Generates downloadable GPX 1.1 XML route exchange file for GPS navigation devices. |
| 228 | */ |
| 229 | public function actionGpx(ServerRequestInterface $request): ResponseInterface |
| 230 | { |
| 231 | $params = $request->getQueryParams(); |
| 232 | $pointsStr = (string) ($params['points'] ?? ''); |
| 233 | $routeName = trim((string) ($params['name'] ?? 'Ammonly Route')); |
| 234 | if ($routeName === '') { |
| 235 | $routeName = 'Ammonly Route'; |
| 236 | } |
| 237 | |
| 238 | $points = $this->parseCoordinatePairs($pointsStr); |
| 239 | if (count($points) < 2) { |
| 240 | return $this->jsonResponse(['error' => 'At least two coordinate points are required.'], 400); |
| 241 | } |
| 242 | |
| 243 | $now = gmdate('Y-m-d\TH:i:s\Z'); |
| 244 | $safeName = htmlspecialchars($routeName, ENT_XML1, 'UTF-8'); |
| 245 | |
| 246 | $xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" |
| 247 | . "<gpx version=\"1.1\" creator=\"Ammonly OSM Platform\" " |
| 248 | . "xmlns=\"http://www.topografix.com/GPX/1/1\">\n" |
| 249 | . " <metadata>\n" |
| 250 | . " <name>{$safeName}</name>\n" |
| 251 | . " <time>{$now}</time>\n" |
| 252 | . " </metadata>\n" |
| 253 | . " <rte>\n" |
| 254 | . " <name>{$safeName}</name>\n"; |
| 255 | |
| 256 | foreach ($points as $index => $pt) { |
| 257 | $stepNum = $index + 1; |
| 258 | $lat = sprintf('%.6f', $pt['lat']); |
| 259 | $lon = sprintf('%.6f', $pt['lon']); |
| 260 | $xml .= " <rtept lat=\"{$lat}\" lon=\"{$lon}\"><name>Stop {$stepNum}</name></rtept>\n"; |
| 261 | } |
| 262 | |
| 263 | $xml .= " </rte>\n</gpx>\n"; |
| 264 | |
| 265 | $response = $this->responseFactory->createResponse(200); |
| 266 | $response->getBody()->write($xml); |
| 267 | |
| 268 | return $response |
| 269 | ->withHeader('Content-Type', 'application/gpx+xml; charset=utf-8') |
| 270 | ->withHeader('Content-Disposition', 'attachment; filename="ammonly_route.gpx"') |
| 271 | ->withHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * @return array<int, GeoPoint> |
| 276 | */ |
| 277 | private function parseTripGeoPoints(string $pointsStr): array |
| 278 | { |
| 279 | $parts = array_filter(explode(';', $pointsStr)); |
| 280 | if (count($parts) < 2) { |
| 281 | return []; |
| 282 | } |
| 283 | |
| 284 | $geoPoints = []; |
| 285 | foreach ($parts as $part) { |
| 286 | $coords = explode(',', trim($part)); |
| 287 | if (count($coords) >= 2) { |
| 288 | $geoPoints[] = new GeoPoint(latitude: (float) $coords[0], longitude: (float) $coords[1]); |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | return count($geoPoints) >= 2 ? $geoPoints : []; |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Parses semicolon-delimited latitude,longitude coordinate string. |
| 297 | * |
| 298 | * @param string $pointsStr |
| 299 | * @return array<int, array{lat: float, lon: float}> |
| 300 | */ |
| 301 | private function parseCoordinatePairs(string $pointsStr): array |
| 302 | { |
| 303 | $points = []; |
| 304 | $parts = array_filter(explode(';', $pointsStr)); |
| 305 | foreach ($parts as $part) { |
| 306 | $coords = explode(',', trim($part)); |
| 307 | if (count($coords) < 2) { |
| 308 | continue; |
| 309 | } |
| 310 | $lat = (float) trim($coords[0]); |
| 311 | $lon = (float) trim($coords[1]); |
| 312 | if ($lat >= -90.0 && $lat <= 90.0 && $lon >= -180.0 && $lon <= 180.0) { |
| 313 | $points[] = ['lat' => $lat, 'lon' => $lon]; |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | return $points; |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Builds standard JSON PSR-7 response. |
| 322 | * |
| 323 | * @param array<string, mixed> $data |
| 324 | * @param int $status |
| 325 | */ |
| 326 | private function jsonResponse(array $data, int $status = 200): ResponseInterface |
| 327 | { |
| 328 | $response = $this->responseFactory->createResponse($status); |
| 329 | $response->getBody()->write((string) json_encode($data, JSON_THROW_ON_ERROR)); |
| 330 | |
| 331 | return $response->withHeader('Content-Type', 'application/json; charset=utf-8'); |
| 332 | } |
| 333 | } |