Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.23% covered (success)
99.23%
129 / 130
92.31% covered (success)
92.31%
12 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapApiController
99.22% covered (success)
99.22%
128 / 129
92.31% covered (success)
92.31%
12 / 13
53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 actionGeocode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 actionReverse
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 actionRoute
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 actionTrip
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 actionNearby
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 actionPoints
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 actionProviders
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 actionHierarchy
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 actionGpx
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
4
 parseTripGeoPoints
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 parseCoordinatePairs
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 jsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Map\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Application\Service\MapGeocodingService;
12use App\Modules\Map\Application\Service\MapRoutingService;
13use App\Modules\Map\Application\Service\MapSpatialQueryService;
14use App\Modules\Map\Domain\Model\GeoPoint;
15use App\Modules\Map\Domain\Repository\IpGeolocationServiceInterface;
16use App\Modules\Map\Infrastructure\Provider\SmartMapLoadBalancer;
17use PDO;
18use Psr\Http\Message\ResponseFactoryInterface;
19use Psr\Http\Message\ResponseInterface;
20use 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 */
29final 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}