@ -0,0 +1,2 @@ | |||
vendor | |||
config.php |
@ -0,0 +1,56 @@ | |||
(status: alpha/brain-dump) | |||
# Weather | |||
Simple private API to get current weather information at a lon/lat position | |||
from Environment Canada (EC). | |||
## Requirements | |||
* webserver (tested with nginx and php-fpm) | |||
* php7 (tested with PHP 7.2) | |||
* php7-simplexml | |||
* composer | |||
* postgresql (tested with 11.3) | |||
* postgis (postgresql extension) | |||
## How does it work | |||
The list of weather stations[1] published by Environment Canada and their | |||
lat/lon locations[2] are imported into the database. | |||
When we pass a lat/lon pair to our API, we use PostGIS to find the closest | |||
weather station, and then query the proper EC endpoint to get current | |||
weather[2] from that weather station. | |||
[1] http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml/siteList.xml | |||
[2] example: http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml/BC/s0000671_e.xml | |||
## Configure | |||
* Run `composer install` in the "private" folder | |||
* Copy "config.dist.php" to "config.php" and edit the values | |||
If you want to bootstrap the database with the weather station information, use | |||
`pg_restore` with the dump provided in "private/weather.sql.dump" | |||
If you want to import the weather station data yourself: | |||
* Create a new database | |||
* Enable the postgis features on it | |||
* Run the "create_table.php" script in "private/src/scripts" | |||
* Run the "import_sites.php" script in "private/src/scripts" (this takes a while) | |||
## Request | |||
`curl -H "secret: xxx" https://example.org?lat=45.00&lon=-75.00` | |||
## Response | |||
``` | |||
{ | |||
"condition": "Sunny", | |||
"temperature": "16", | |||
"humidity": "12" | |||
} | |||
``` | |||
@ -0,0 +1,5 @@ | |||
{ | |||
"require": { | |||
"guzzlehttp/guzzle": "~6.0" | |||
} | |||
} |
@ -0,0 +1,291 @@ | |||
{ | |||
"_readme": [ | |||
"This file locks the dependencies of your project to a known state", | |||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
"This file is @generated automatically" | |||
], | |||
"content-hash": "35a239f26f96a30f3493aef7e0f87f9b", | |||
"packages": [ | |||
{ | |||
"name": "guzzlehttp/guzzle", | |||
"version": "6.3.3", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/guzzle/guzzle.git", | |||
"reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba", | |||
"reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"guzzlehttp/promises": "^1.0", | |||
"guzzlehttp/psr7": "^1.4", | |||
"php": ">=5.5" | |||
}, | |||
"require-dev": { | |||
"ext-curl": "*", | |||
"phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", | |||
"psr/log": "^1.0" | |||
}, | |||
"suggest": { | |||
"psr/log": "Required for using the Log middleware" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "6.3-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"files": [ | |||
"src/functions_include.php" | |||
], | |||
"psr-4": { | |||
"GuzzleHttp\\": "src/" | |||
} | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Michael Dowling", | |||
"email": "mtdowling@gmail.com", | |||
"homepage": "https://github.com/mtdowling" | |||
} | |||
], | |||
"description": "Guzzle is a PHP HTTP client library", | |||
"homepage": "http://guzzlephp.org/", | |||
"keywords": [ | |||
"client", | |||
"curl", | |||
"framework", | |||
"http", | |||
"http client", | |||
"rest", | |||
"web service" | |||
], | |||
"time": "2018-04-22T15:46:56+00:00" | |||
}, | |||
{ | |||
"name": "guzzlehttp/promises", | |||
"version": "v1.3.1", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/guzzle/promises.git", | |||
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", | |||
"reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": ">=5.5.0" | |||
}, | |||
"require-dev": { | |||
"phpunit/phpunit": "^4.0" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "1.4-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"psr-4": { | |||
"GuzzleHttp\\Promise\\": "src/" | |||
}, | |||
"files": [ | |||
"src/functions_include.php" | |||
] | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Michael Dowling", | |||
"email": "mtdowling@gmail.com", | |||
"homepage": "https://github.com/mtdowling" | |||
} | |||
], | |||
"description": "Guzzle promises library", | |||
"keywords": [ | |||
"promise" | |||
], | |||
"time": "2016-12-20T10:07:11+00:00" | |||
}, | |||
{ | |||
"name": "guzzlehttp/psr7", | |||
"version": "1.5.2", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/guzzle/psr7.git", | |||
"reference": "9f83dded91781a01c63574e387eaa769be769115" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/9f83dded91781a01c63574e387eaa769be769115", | |||
"reference": "9f83dded91781a01c63574e387eaa769be769115", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": ">=5.4.0", | |||
"psr/http-message": "~1.0", | |||
"ralouphie/getallheaders": "^2.0.5" | |||
}, | |||
"provide": { | |||
"psr/http-message-implementation": "1.0" | |||
}, | |||
"require-dev": { | |||
"phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "1.5-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"psr-4": { | |||
"GuzzleHttp\\Psr7\\": "src/" | |||
}, | |||
"files": [ | |||
"src/functions_include.php" | |||
] | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Michael Dowling", | |||
"email": "mtdowling@gmail.com", | |||
"homepage": "https://github.com/mtdowling" | |||
}, | |||
{ | |||
"name": "Tobias Schultze", | |||
"homepage": "https://github.com/Tobion" | |||
} | |||
], | |||
"description": "PSR-7 message implementation that also provides common utility methods", | |||
"keywords": [ | |||
"http", | |||
"message", | |||
"psr-7", | |||
"request", | |||
"response", | |||
"stream", | |||
"uri", | |||
"url" | |||
], | |||
"time": "2018-12-04T20:46:45+00:00" | |||
}, | |||
{ | |||
"name": "psr/http-message", | |||
"version": "1.0.1", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/php-fig/http-message.git", | |||
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", | |||
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": ">=5.3.0" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "1.0.x-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"psr-4": { | |||
"Psr\\Http\\Message\\": "src/" | |||
} | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "PHP-FIG", | |||
"homepage": "http://www.php-fig.org/" | |||
} | |||
], | |||
"description": "Common interface for HTTP messages", | |||
"homepage": "https://github.com/php-fig/http-message", | |||
"keywords": [ | |||
"http", | |||
"http-message", | |||
"psr", | |||
"psr-7", | |||
"request", | |||
"response" | |||
], | |||
"time": "2016-08-06T14:39:51+00:00" | |||
}, | |||
{ | |||
"name": "ralouphie/getallheaders", | |||
"version": "2.0.5", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/ralouphie/getallheaders.git", | |||
"reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa", | |||
"reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": ">=5.3" | |||
}, | |||
"require-dev": { | |||
"phpunit/phpunit": "~3.7.0", | |||
"satooshi/php-coveralls": ">=1.0" | |||
}, | |||
"type": "library", | |||
"autoload": { | |||
"files": [ | |||
"src/getallheaders.php" | |||
] | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Ralph Khattar", | |||
"email": "ralph.khattar@gmail.com" | |||
} | |||
], | |||
"description": "A polyfill for getallheaders.", | |||
"time": "2016-02-11T07:05:27+00:00" | |||
} | |||
], | |||
"packages-dev": [], | |||
"aliases": [], | |||
"minimum-stability": "stable", | |||
"stability-flags": [], | |||
"prefer-stable": false, | |||
"prefer-lowest": false, | |||
"platform": [], | |||
"platform-dev": [] | |||
} |
@ -0,0 +1,14 @@ | |||
<?php | |||
// Example config | |||
// Requires postgres with postgis extension | |||
$config = [ | |||
'dbUser' => 'weather', | |||
'dbPassword' => 'weather', | |||
'dbSocket' => '/run/postgresql/.s.PGSQL.5432', | |||
'dbName' => 'weather', | |||
'secret' => 'xxx' | |||
]; | |||
@ -0,0 +1,38 @@ | |||
<?php | |||
class Coords { | |||
public $lat; | |||
public $lon; | |||
public function __construct($lat, $lon) { | |||
if ( | |||
!is_string($lat) || empty($lat) || | |||
!is_string($lon) || empty($lon) | |||
) { | |||
throw new Exception('Garbage coords'); | |||
} | |||
$this->lat = $this->format($lat); | |||
$this->lon = $this->format($lon); | |||
} | |||
// Remove direction and make number negative if appropriate... | |||
public function format($val) { | |||
// Remove last character (direction), keep the number part | |||
$coord = substr($val, 0, -1); | |||
// Keep only the last character (direction) | |||
$direction = substr($val, -1); | |||
// If the direction is South or West, then the number should be negative | |||
if ($direction === 'S' || $direction === 'W') { | |||
$coord = '-' . $coord; | |||
} | |||
return $coord; | |||
} | |||
public function toSQL() { | |||
return "$this->lon, $this->lat"; | |||
} | |||
} | |||
@ -0,0 +1,37 @@ | |||
<?php | |||
class CurrentConditions { | |||
private $condition; | |||
private $humidity; // percent | |||
private $temperature; // celcius | |||
public function __construct($condition, $temperature, $humidity) { | |||
$this->condition = $condition; | |||
$this->temperature = $temperature; | |||
$this->humidity = $humidity; | |||
} | |||
public function getCondition() { | |||
return $this->condition; | |||
} | |||
public function getTemperature() { | |||
return $this->temperature; | |||
} | |||
public function getHumidity() { | |||
return $this->humidity; | |||
} | |||
public function toJSON() { | |||
$data = [ | |||
'humidity' => $this->humidity, | |||
'temperature' => $this->temperature, | |||
'condition' => $this->condition | |||
]; | |||
return json_encode($data); | |||
} | |||
} | |||
@ -0,0 +1,98 @@ | |||
<?php | |||
require realpath(dirname(__FILE__)) . '/Coords.php'; | |||
require realpath(dirname(__FILE__)) . '/CurrentConditions.php'; | |||
class Site { | |||
private $id; | |||
private $code; | |||
private $coords; | |||
private $name; | |||
private $province; | |||
public function __construct($code, $name, $province, $coords = null) { | |||
$this->code = (string)$code; | |||
$this->coords = $coords; | |||
$this->name = (string)$name; | |||
$this->province = (string)$province; | |||
} | |||
public function getName() { | |||
return $this->name; | |||
} | |||
public function getCode() { | |||
return $this->code; | |||
} | |||
public function getProvince() { | |||
return $this->province; | |||
} | |||
public function getCoords() { | |||
return $this->coords; | |||
} | |||
public function fillCoords() { | |||
$code = $this->code; | |||
$province = $this->province; | |||
$urlSegments = [$code, $province]; | |||
$baseUrl = "http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml"; | |||
$endpoint = $baseUrl . "/{$province}/{$code}_e.xml"; | |||
$client = new \GuzzleHttp\Client(); | |||
$response = $client->get($endpoint); | |||
$xml = $response->getBody(); | |||
$location = $this->getLocationInfo($xml); | |||
$coords = new Coords((string)$location['lat'], (string)$location['lon']); | |||
$this->coords = $coords; | |||
return $this; | |||
} | |||
public function getLocationInfo($xmlstr) { | |||
// There are two lat/lon pairs in the XML: | |||
// 1) $xml->currentConditions->station | |||
// 2) $xml->location->name | |||
// | |||
// "currentCondition" isn't always populated though, | |||
// so use the pair in "location" instead | |||
$xml = new SimpleXMLElement($xmlstr); | |||
return $xml->location->name; | |||
} | |||
public function getCurrentConditions() { | |||
$province = $this->province; | |||
$code = $this->code; | |||
$baseUrl = "http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml"; | |||
$endpoint = $baseUrl . "/{$province}/{$code}_e.xml"; | |||
$client = new \GuzzleHttp\Client(); | |||
$response = $client->get($endpoint); | |||
$xml = $response->getBody(); | |||
return $this->parseCurrentConditions($xml); | |||
} | |||
public function parseCurrentConditions($xmlstr) { | |||
$data = new SimpleXMLElement($xmlstr); | |||
$currentConditions = $data->currentConditions; | |||
$condition = (string)$currentConditions->condition; | |||
$temperature = (string)$currentConditions->temperature; | |||
$humidity = (string)$currentConditions->relativeHumidity; | |||
return new CurrentConditions($condition, $temperature, $humidity); | |||
} | |||
} | |||
@ -0,0 +1,58 @@ | |||
<?php | |||
require realpath(dirname(__FILE__)) . '/Site.php'; | |||
class SitesFactory { | |||
private $sites; | |||
public function __construct() { | |||
$this->sites = []; | |||
} | |||
public function getSiteByCode($code) { | |||
return $this->sites[$code]; | |||
} | |||
public function _fillSites() { | |||
$xml = file_get_contents('siteList.xml'); | |||
$sites = $this->parseSites($xml); | |||
$this->sites = $sites; | |||
return $this; | |||
} | |||
public function fillSites() { | |||
$endpoint = 'http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml/siteList.xml'; | |||
$client = new \GuzzleHttp\Client(); | |||
$response = $client->request('GET', $endpoint); | |||
$xml = $response->getBody(); | |||
$sites = $this->parseSites($xml); | |||
$this->sites = $sites; | |||
return $this; | |||
} | |||
public function getCodeList() { | |||
return array_keys($this->sites); | |||
} | |||
public function parseSites($xmlstr) { | |||
$sites = new SimpleXMLElement($xmlstr); | |||
$arr = []; | |||
foreach($sites->site as $site) { | |||
$siteCode = (string)$site['code']; | |||
$arr[$siteCode] = new Site($siteCode, $site->nameEn, $site->provinceCode); | |||
} | |||
return $arr; | |||
} | |||
} | |||
@ -0,0 +1,46 @@ | |||
<?php | |||
function db($user, $pass, $db_name) { | |||
// Connect to DB | |||
$dbh = new PDO('pgsql:dbname=' . $db_name, $user, $pass); | |||
// Throw exception on error | |||
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); | |||
return $dbh; | |||
} | |||
function find_new_sites_query($nbCodes) { | |||
$subs = []; | |||
for ($i = 0; $i < $nbCodes; $i += 1) { | |||
$subs[] = '(?)'; | |||
} | |||
$subs = implode(',', $subs); | |||
$query = <<<SQL | |||
WITH tmp (code) as (values $subs) | |||
SELECT tmp.code FROM tmp LEFT JOIN site ON tmp.code = site.code WHERE site.code IS NULL; | |||
SQL; | |||
return $query; | |||
} | |||
function insert_sites_query($nbSites) { | |||
$subs = []; | |||
for($i = 0; $i < $nbSites; $i += 1) { | |||
$strIdx = (string)$i; | |||
// $subs[] = "(:code$strIdx, :name$strIdx, :province$strIdx, ST_MakePoint(:location$strIdx))"; | |||
$subs[] = "(:code$strIdx, :name$strIdx, :province$strIdx, ST_MakePoint(:lon$strIdx,:lat$strIdx))"; | |||
} | |||
$subs = implode(',', $subs); | |||
$query = 'INSERT INTO site (code, name, province, location) VALUES ' . $subs; | |||
return $query; | |||
} | |||
@ -0,0 +1,30 @@ | |||
<?php | |||
/* | |||
* Utility script to create a new, empty SQL `site` table | |||
*/ | |||
require '../../config.php'; | |||
$query = <<<SQL | |||
CREATE TABLE site( | |||
id serial primary key, | |||
code varchar(12) unique not null, | |||
name varchar(191) not null, | |||
province varchar(10) not null, | |||
location geography | |||
); | |||
SQL; | |||
// Connect to DB | |||
$dbh = new PDO('pgsql:dbname=' . $config['dbName'], $config['dbUser'], $config['dbPassword']); | |||
// Throw exception on error | |||
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); | |||
// Create table | |||
$dbh->query($query); | |||
// Create index | |||
$dbh->query('CREATE INDEX idx_site_location ON site USING GIST(location)'); | |||
@ -0,0 +1,77 @@ | |||
<?php | |||
require '../../vendor/autoload.php'; | |||
require '../class/SitesFactory.php'; | |||
require '../../config.php'; | |||
require '../helpers.php'; | |||
$dbh = db($config['dbUser'], $config['dbPassword'], $config['dbName']); | |||
// Fetch sites from endpoint | |||
$sites = new SitesFactory(); | |||
$sites->_fillSites(); | |||
// Extract site codes | |||
$siteCodes = $sites->getCodeList(); | |||
$nbSiteCodes = sizeof($siteCodes); | |||
// Build prepared statement | |||
$query = find_new_sites_query($nbSiteCodes); | |||
$statement = $dbh->prepare($query); | |||
// Fill the parameters | |||
for ($i = 0; $i < $nbSiteCodes; $i += 1) { | |||
$statement->bindValue($i + 1, $siteCodes[$i], PDO::PARAM_STR); | |||
} | |||
// Get the codes of all unknown sites | |||
$statement->execute(); | |||
$new_sites_codes = $statement->fetchAll(PDO::FETCH_COLUMN, 0); | |||
$filledSites = []; | |||
// Fill the coordinates for new sites | |||
foreach($new_sites_codes as $new_site_code) { | |||
$new_site = $sites->getSiteByCode($new_site_code); | |||
try { | |||
$new_site->fillCoords(); | |||
$filledSites[] = $new_site; | |||
} catch(Exception $e) { | |||
error_log('Unable to get coords for site: ' . $new_site->getName()); | |||
echo $e->getMessage(); | |||
} | |||
// Don't hammer the endpoint | |||
// (each fillCoords() is a HTTP request) | |||
sleep(2); | |||
} | |||
$nbNewSites = sizeof($filledSites); | |||
// No new sites, exit normally | |||
if ($nbNewSites === 0) { | |||
echo "No new sites\n"; | |||
exit(0); | |||
} | |||
// Build prepared statement | |||
$query = insert_sites_query($nbNewSites); | |||
$statement = $dbh->prepare($query); | |||
// Fill the parameters | |||
$i = 0; | |||
foreach($filledSites as $filledSite) { | |||
$strIdx = (string)$i; | |||
$statement->bindValue(":code$strIdx", $filledSite->getCode(), PDO::PARAM_STR); | |||
$statement->bindValue(":name$strIdx", $filledSite->getName(), PDO::PARAM_STR); | |||
$statement->bindValue(":province$strIdx", $filledSite->getProvince(), PDO::PARAM_STR); | |||
$statement->bindValue(":lon$strIdx", $filledSite->getCoords()->lon, PDO::PARAM_INT); | |||
$statement->bindValue(":lat$strIdx", $filledSite->getCoords()->lat, PDO::PARAM_INT); | |||
$i += 1; | |||
} | |||
// Insert new sites in DB | |||
$statement->execute(); | |||
@ -0,0 +1,36 @@ | |||
<?php | |||
require '../private/vendor/autoload.php'; | |||
require '../private/config.php'; | |||
require '../private/src/helpers.php'; | |||
require '../private/src/class/Site.php'; | |||
if (!isset($_SERVER['HTTP_SECRET']) || $_SERVER['HTTP_SECRET'] !== $config['secret']) { | |||
header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden'); | |||
exit(0); | |||
} | |||
if (!isset($_GET['lat']) || !isset($_GET['lon'])) { | |||
header($_SERVER['SERVER_PROTOCOL'] . ' 400 Bad Request'); | |||
exit(0); | |||
} | |||
// DB Connection | |||
$dbh = db($config['dbUser'], $config['dbPassword'], $config['dbName']); | |||
// Get the closest weather station to us | |||
$statement = $dbh->prepare('SELECT code, name, province FROM site order by location <-> ST_MakePoint(:lon, :lat) limit 1'); | |||
$statement->bindValue(':lon', $_GET['lon'], PDO::PARAM_INT); | |||
$statement->bindValue(':lat', $_GET['lat'], PDO::PARAM_INT); | |||
$statement->execute(); | |||
$row = $statement->fetch(PDO::FETCH_ASSOC); | |||
$site = new Site($row['code'], $row['name'], $row['province']); | |||
$currentConditions = $site->getCurrentConditions(); | |||
header('Content-Type: application/json'); | |||
echo $currentConditions->toJSON(); | |||