Browse Source

Initial commit

master
Chimo 1 year ago
commit
47c224d3e2
14 changed files with 788 additions and 0 deletions
  1. +2
    -0
      .gitignore
  2. +56
    -0
      README.md
  3. +5
    -0
      private/composer.json
  4. +291
    -0
      private/composer.lock
  5. +14
    -0
      private/config.dist.php
  6. +38
    -0
      private/src/class/Coords.php
  7. +37
    -0
      private/src/class/CurrentConditions.php
  8. +98
    -0
      private/src/class/Site.php
  9. +58
    -0
      private/src/class/SitesFactory.php
  10. +46
    -0
      private/src/helpers.php
  11. +30
    -0
      private/src/scripts/create_table.php
  12. +77
    -0
      private/src/scripts/import_sites.php
  13. BIN
      private/weather.sql.dump
  14. +36
    -0
      public/index.php

+ 2
- 0
.gitignore View File

@ -0,0 +1,2 @@
vendor
config.php

+ 56
- 0
README.md View File

@ -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"
}
```

+ 5
- 0
private/composer.json View File

@ -0,0 +1,5 @@
{
"require": {
"guzzlehttp/guzzle": "~6.0"
}
}

+ 291
- 0
private/composer.lock View File

@ -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": []
}

+ 14
- 0
private/config.dist.php View File

@ -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'
];

+ 38
- 0
private/src/class/Coords.php View File

@ -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";
}
}

+ 37
- 0
private/src/class/CurrentConditions.php View File

@ -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);
}
}

+ 98
- 0
private/src/class/Site.php View File

@ -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);
}
}

+ 58
- 0
private/src/class/SitesFactory.php View File

@ -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;
}
}

+ 46
- 0
private/src/helpers.php View File

@ -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;
}

+ 30
- 0
private/src/scripts/create_table.php View File

@ -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)');

+ 77
- 0
private/src/scripts/import_sites.php View File

@ -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();

BIN
private/weather.sql.dump View File


+ 36
- 0
public/index.php View File

@ -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();

Loading…
Cancel
Save