diff --git a/composer.json b/composer.json index 2d244b6..e5d5400 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "yosymfony/toml": "^1.0", "aura/autoload": "^2.0", "php-di/php-di": "^6.0", - "erusev/parsedown-extra": "^0.7.1" + "erusev/parsedown-extra": "^0.7.1", + "mjaschen/phpgeo": "^2.1" }, "license": "MPL-2.0", "authors": [ diff --git a/composer.lock b/composer.lock index 3b6e901..e2ac473 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d42581b26b6c99bcc70da335a64f686b", + "content-hash": "ba074004640df6fbfd575edbf073650f", "packages": [ { "name": "aura/autoload", @@ -203,6 +203,74 @@ ], "time": "2018-03-21T22:21:57+00:00" }, + { + "name": "mjaschen/phpgeo", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/mjaschen/phpgeo.git", + "reference": "0aaec41e7aff030a55db30bb8f6c2671faf03892" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mjaschen/phpgeo/zipball/0aaec41e7aff030a55db30bb8f6c2671faf03892", + "reference": "0aaec41e7aff030a55db30bb8f6c2671faf03892", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "jakub-onderka/php-parallel-lint": "^1.0", + "phpmd/phpmd": "^2.6", + "phpunit/phpunit": "~6.0", + "squizlabs/php_codesniffer": "^3.2", + "vimeo/psalm": "~3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Location\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcus Jaschen", + "email": "mjaschen@gmail.com", + "homepage": "https://www.marcusjaschen.de/" + } + ], + "description": "Simple Geo Library", + "homepage": "https://phpgeo.marcusjaschen.de/", + "keywords": [ + "Polygon", + "area", + "bearing", + "bounds", + "calculation", + "coordinate", + "distance", + "earth", + "ellipsoid", + "geo", + "geofence", + "gis", + "gps", + "haversine", + "length", + "point", + "polyline", + "projection", + "simplify", + "track", + "vincenty" + ], + "time": "2019-03-21T18:53:24+00:00" + }, { "name": "nikic/php-parser", "version": "v4.2.0", diff --git a/docs/05-API-Docs.md b/docs/05-API-Docs.md index 10251fa..2fa511e 100644 --- a/docs/05-API-Docs.md +++ b/docs/05-API-Docs.md @@ -8,6 +8,7 @@ Action | Meaning [`version`](#version) | Gets the version of _Air Quality Web_ that's currently running. [`fetch-data`](#fetch-data) | Fetches air quality data from the system for a specific data type at a specific date and time. [`list-devices`](#list-devices) | Fetches a list of devices currently in the system. +[`list-devices-near`](#list-devices-near) | Lists the devices close to a given location (new since v0.11!) [`device-info`](#device-info) | Gets (lots of) information about a single device. [`list-reading-types`](#list-reading-types) | Lists the different types of readings that can be specified. [`device-data-bounds`](#device-data-bounds) | Gets the start and end DateTime bounds for the data recorded for a specific device. @@ -18,6 +19,8 @@ These are explained in detail below. First though, a few notes: - All dates are in UTC. - All datetime-type fields support the keyword `now`. + - Additional object properties MAY be returned by the API at a later date. Clients MUST ignore additional unknown properties they do not understand. + - Clients SHOULD respect the `cache-control` headers returned by the API. ## version @@ -64,6 +67,49 @@ https://example.com/path/to/api.php?action=list-devices https://example.com/path/to/api.php?action=list-devices&only-with-location=yes ``` +## list-devices-near +> Lists devices close to a given location. + + +**Remember:** Don't forget that unlike most other API actions, this requires a `POST` request and not a `GET`! This is to preserve privacy, as the web server stores GET parameters along with request urls in the server logs. + +### GET Parameters + +Parameter | Type | Meaning +--------------------|-----------|--------------------- +`count` | int | Required. Specifies the number of devices to return. + +### POST Parameters +POST parameters should be specified as part of the request body. The request should have a `content-type` of `application/x-www-form-urlencoded`. + +Parameter | Type | Meaning +--------------------|-----------|--------------------- +`latitude` | float | Required. The latitude of the location to search for nearby devices. +`longitude` | float | Required. The longitude of the location to search for nearby devices. + +### Example HTTP Request + +``` +POST /api.php?action=list-devices-near&count=5 HTTP/1.1 +host: airquality.example.com +content-length: 38 +content-type: application/x-www-form-urlencoded + +latitude=12.345678&longitude=98.765432 +``` + +### Response Object Properties +Since it may not be obvious, the properties returned in a response object are detailed below: + +Property | Meaning +--------------------|---------------------------------- +`id` | The device's unique id. +`name` | The device's name. Should be unique, but don't count on it. +`latitude` | The latitude of the device in question. +`longitude` | The longitude of the aforementioned device. +`distance_calc` | The distance between the specified point and the device, represented as the length of a relative (lat, long) vector between the 2. _Should_ be accurate enough to get the devices in the right order with respect to the actual distance, but if not please [open an issue](https://github.com/ConnectedHumber/Aiq-Quality-Web/issues/new) +`distance_actual` | The actual distance between the specified point and the device, in metres. + ## device-info > Gets (lots of) information about a single device. diff --git a/logic/Actions/ListDevicesNear.php b/logic/Actions/ListDevicesNear.php new file mode 100644 index 0000000..063c6ce --- /dev/null +++ b/logic/Actions/ListDevicesNear.php @@ -0,0 +1,96 @@ +settings = $in_settings; + $this->device_repo = $in_device_repo; + $this->sender = $in_sender; + $this->perfcounter = $in_perfcounter; + + $this->validator_get = new Validator($_GET); + $this->validator_post = new Validator($_POST); + } + + public function handle() : bool { + global $start_time; + + if(strtolower($_SERVER["REQUEST_METHOD"]) !== "post") { + $this->sender->send_error_plain(405, "Error: The devices-near action only takes a POST request, but you sent a {$_SERVER["REQUEST_METHOD"]} request. The parameters 'longitude' and 'latitude' should be specified in the POST body, and 'count' as a regular GET parameter.\nExample POST body (without quotes): 'latitude=12.345678&longitude=98.765432'\nDon't forget that the content-type header should be set to 'application/x-www-form-urlencoded'.", [ + [ "x-time-taken", $this->perfcounter->render() ] + ]); + return false; + } + + // 1: Validate params + $this->validator_get->is_numberish("count"); + $this->validator_get->run(); + + $this->validator_post->is_numberish("latitude"); + $this->validator_post->is_numberish("longitude"); + $this->validator_post->run(); + + + // 2: Pull data from database + $this->perfcounter->start("sql"); + $data = $this->device_repo->get_near_location( + floatval($_POST["latitude"]), + floatval($_POST["longitude"]), + intval($_GET["count"]) + ); + $this->perfcounter->end("sql"); + + // 3: Serialise data + $this->perfcounter->start("encode"); + $response = json_encode($data); + $this->perfcounter->end("encode"); + + // 4: Send response + + // Send a cache-control header, but only in production mode + if($this->settings->get("env.mode") == "production") { + header("cache-control: public, max-age=" . $this->settings->get("cache.max-age")); + } + + header("content-length: " . strlen($response)); + header("content-type: application/json"); + header("x-time-taken: " . $this->perfcounter->render()); + echo($response); + return true; + } +} diff --git a/logic/Database.php b/logic/Database.php index 2a5f95c..979d1f6 100644 --- a/logic/Database.php +++ b/logic/Database.php @@ -57,11 +57,17 @@ class Database error_log("[Database/SQL] $sql"); // FUTURE: Optionally cache prepared statements? - $statement = $this->connection->prepare($sql); + // Note that we replace tabs with spaces for debugging purposes + $statement = $this->connection->prepare(str_replace("\t", " ", $sql)); $statement->execute($variables); return $statement; // fetchColumn(), fetchAll(), etc. are defined on the statement, not the return value of execute() } + /** + * Returns the connection string to use to connect to the database. + * This is calculated from the values specified in the settings file. + * @return string The connection string. + */ private function get_connection_string() { return "{$this->settings->get("database.type")}:host={$this->settings->get("database.host")};dbname={$this->settings->get("database.name")};charset=utf8mb4"; } diff --git a/logic/Repositories/IDeviceRepository.php b/logic/Repositories/IDeviceRepository.php index 93527de..74496c0 100644 --- a/logic/Repositories/IDeviceRepository.php +++ b/logic/Repositories/IDeviceRepository.php @@ -2,6 +2,9 @@ namespace AirQuality\Repositories; +/** + * Defines the interface of repositories that fetch device information. + */ interface IDeviceRepository { /** * Returns an array of all the devices in the system with basic information @@ -19,4 +22,13 @@ interface IDeviceRepository { * @return array The extended information available on the given device. */ public function get_device_info_ext($device_id); + + /** + * Gets a list of devices that are near the specified location. + * @param float $lat The latitude of the location to get devices near. + * @param float $long The longitude of the location to get devices near. + * @param int $count The number of nearby devices to return. + * @return array An array of nearby devices. + */ + public function get_near_location(float $lat, float $long, int $count); } diff --git a/logic/Repositories/IMeasurementDataRepository.php b/logic/Repositories/IMeasurementDataRepository.php index ad79a99..d08f6ff 100644 --- a/logic/Repositories/IMeasurementDataRepository.php +++ b/logic/Repositories/IMeasurementDataRepository.php @@ -6,7 +6,7 @@ interface IMeasurementDataRepository { /** * Returns the specified reading type for all devices at the specified date * and time. - * @param DateTime $datetime The date and time to get the readings for. + * @param \DateTime $datetime The date and time to get the readings for. * @param int $reading_type_id The reading type id to fetch. * @return array The requested readings. */ @@ -15,15 +15,15 @@ interface IMeasurementDataRepository { /** * Gets the first and last DateTimes of the readings for a specified device. * @param int $device_id The device id to fetch for. - * @return DateTime[] The first and last DateTimes for which readings are stored. + * @return \DateTime[] The first and last DateTimes for which readings are stored. */ public function get_device_reading_bounds(int $device_id); /** * Gets the readings of a specified type for a specific device between the 2 given dates and times. * @param int $device_id The id of the device to fetch data for. - * @param string $type_id The reading type to fetch. - * @param DateTime $start The starting DateTime. - * @param DateTime $end The ending DateTime. + * @param int $type_id The reading type to fetch. + * @param \DateTime $start The starting DateTime. + * @param \DateTime $end The ending DateTime. * @param int $average_seconds The number of seconds to averageg the data over. For example a value of 3600 (1 hour) will return 1 data point per hour, with the value of each point an average of all the readings for that hour. * @return array The requested data. */ diff --git a/logic/Repositories/MariaDBDeviceRepository.php b/logic/Repositories/MariaDBDeviceRepository.php index 44a3ce1..793c651 100644 --- a/logic/Repositories/MariaDBDeviceRepository.php +++ b/logic/Repositories/MariaDBDeviceRepository.php @@ -2,6 +2,10 @@ namespace AirQuality\Repositories; +use Location\Coordinate; +use Location\Distance\Vincenty; + + /** * Fetches device info from a MariaDB database. */ @@ -13,6 +17,7 @@ class MariaDBDeviceRepository implements IDeviceRepository { public static $column_owner_id = "owner_id"; public static $column_lat = "device_latitude"; public static $column_long = "device_longitude"; + public static $column_point = "lat_lon"; public static $column_altitude = "device_altitude"; public static $table_name_type = "device_types"; @@ -35,14 +40,21 @@ class MariaDBDeviceRepository implements IDeviceRepository { */ private $database; + /** + * The distance calculator. From the mjaschen/phpgeo library on packagist. + * @var Vincenty + */ + private $distance_calculator; + /** * Function that gets a static variable by it's name. Useful in preparing SQL queries. * @var callable */ private $get_static; - function __construct(\AirQuality\Database $in_database) { + function __construct(\AirQuality\Database $in_database, Vincenty $in_distance_calculator) { $this->database = $in_database; + $this->distance_calculator = $in_distance_calculator; $this->get_static = function($name) { return self::$$name; }; } @@ -103,4 +115,43 @@ class MariaDBDeviceRepository implements IDeviceRepository { } return $result; } + + public function get_near_location(float $lat, float $long, int $count) { + $s = $this->get_static; + + $result = $this->database->query( + "SELECT + {$s("table_name")}.{$s("column_device_id")} AS id, + {$s("table_name")}.{$s("column_device_name")} AS name, + {$s("table_name")}.{$s("column_lat")} AS latitude, + {$s("table_name")}.{$s("column_long")} AS longitude, + ST_DISTANCE(POINT(:latitude, :longitude), {$s("table_name")}.{$s("column_point")}) AS distance_calc + FROM {$s("table_name")} + WHERE {$s("table_name")}.{$s("column_point")} IS NOT NULL + ORDER BY ST_DISTANCE(POINT(:latitude_again, :longitude_again), {$s("table_name")}.{$s("column_point")}) + LIMIT :count;", [ + "latitude" => $lat, + "longitude" => $long, + "latitude_again" => $lat, + "longitude_again" => $long, + "count" => $count + ] + )->fetchAll(); + + // Calculate the *actual* distance in metres. + // This is complicated and requires nasty formulae, so we're using a library here + // FUTURE: Apparently said library supports caching with PSR-6 - maybe we could take advantage of a PSR-6 implementation both here and elsewhere? + $loc = new Coordinate($lat, $long); + foreach($result as &$item) { + $item["distance_actual"] = $this->distance_calculator->getDistance( + $loc, + new Coordinate( + floatval($item["latitude"]), + floatval($item["longitude"]) + ) + ); + } + + return $result; + } } diff --git a/logic/Repositories/MariaDBMeasurementTypeRepository.php b/logic/Repositories/MariaDBMeasurementTypeRepository.php index ef9cdf2..bdc4e77 100644 --- a/logic/Repositories/MariaDBMeasurementTypeRepository.php +++ b/logic/Repositories/MariaDBMeasurementTypeRepository.php @@ -20,8 +20,15 @@ class MariaDBMeasurementTypeRepository implements IMeasurementTypeRepository { */ private $database; - /** Functions that get a static variable by it's name. Useful in preparing SQL queries. */ + /** + * Functions that get a static variable by it's name. Useful in preparing SQL queries. + * @var callable + */ private $get_static; + /** + * Functions that get a static variable by it's class and property names. Useful in preparing SQL queries. + * @var callable + */ private $get_static_extra; function __construct(\AirQuality\Database $in_database) { diff --git a/version b/version index bc3d337..5416288 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.10.4 +v0.11