Passed
Push — master ( 9bdf0e...8b8917 )
by Roeland
14:23 queued 10s
created

WeatherStatusService::formatOsmAddress()   B

Complexity

Conditions 10
Paths 19

Size

Total Lines 31
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 23
nc 19
nop 1
dl 0
loc 31
rs 7.6666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright (c) 2020, Julien Veyssier
7
 *
8
 * @author Julien Veyssier <[email protected]>
9
 *
10
 * @license AGPL-3.0
11
 *
12
 * This code is free software: you can redistribute it and/or modify
13
 * it under the terms of the GNU Affero General Public License, version 3,
14
 * as published by the Free Software Foundation.
15
 *
16
 * This program is distributed in the hope that it will be useful,
17
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
 * GNU Affero General Public License for more details.
20
 *
21
 * You should have received a copy of the GNU Affero General Public License, version 3,
22
 * along with this program. If not, see <http://www.gnu.org/licenses/>
23
 *
24
 */
25
26
namespace OCA\WeatherStatus\Service;
27
28
use OCP\IConfig;
29
use OCP\IL10N;
30
use OCP\App\IAppManager;
31
use OCP\Accounts\IAccountManager;
32
use OCP\Accounts\PropertyDoesNotExistException;
33
use OCP\IUserManager;
34
use OCP\Http\Client\IClientService;
35
use OCP\Http\Client\IClient;
36
use OCP\ICacheFactory;
37
use OCP\ICache;
38
use OCP\ILogger;
39
40
use OCA\WeatherStatus\AppInfo\Application;
41
42
/**
43
 * Class WeatherStatusService
44
 *
45
 * @package OCA\WeatherStatus\Service
46
 */
47
class WeatherStatusService {
48
	public const MODE_BROWSER_LOCATION = 1;
49
	public const MODE_MANUAL_LOCATION = 2;
50
51
	/** @var IClientService */
52
	private $clientService;
53
54
	/** @var IClient */
55
	private $client;
56
57
	/** @var IConfig */
58
	private $config;
59
60
	/** @var IL10N */
61
	private $l10n;
62
63
	/** @var ILogger */
64
	private $logger;
65
66
	/** @var IAccountManager */
67
	private $accountManager;
68
69
	/** @var IUserManager */
70
	private $userManager;
71
72
	/** @var IAppManager */
73
	private $appManager;
74
75
	/** @var ICacheFactory */
76
	private $cacheFactory;
0 ignored issues
show
introduced by
The private property $cacheFactory is not used, and could be removed.
Loading history...
77
78
	/** @var ICache */
79
	private $cache;
80
81
	/** @var string */
82
	private $userId;
83
84
	/** @var string */
85
	private $version;
86
87
	/**
88
	 * WeatherStatusService constructor
89
	 *
90
	 * @param IClientService $clientService
91
	 * @param IConfig $config
92
	 * @param IL10N $l10n
93
	 * @param ILogger $logger
94
	 * @param IAccountManager $accountManager
95
	 * @param IUserManager $userManager
96
	 * @param IAppManager $appManager
97
	 * @param ICacheFactory $cacheFactory
98
	 * @param string $userId
99
	 */
100
	public function __construct(IClientService $clientService,
101
								IConfig $config,
102
								IL10N $l10n,
103
								ILogger $logger,
104
								IAccountManager $accountManager,
105
								IUserManager $userManager,
106
								IAppManager $appManager,
107
								ICacheFactory $cacheFactory,
108
								?string $userId) {
109
		$this->config = $config;
110
		$this->userId = $userId;
111
		$this->l10n = $l10n;
112
		$this->logger = $logger;
113
		$this->accountManager = $accountManager;
114
		$this->userManager = $userManager;
115
		$this->appManager = $appManager;
116
		$this->version = $appManager->getAppVersion(Application::APP_ID);
117
		$this->clientService = $clientService;
118
		$this->client = $clientService->newClient();
119
		if ($cacheFactory->isAvailable()) {
120
			$this->cache = $cacheFactory->createDistributed();
121
		}
122
	}
123
124
	/**
125
	 * Change the weather status mode. There are currently 2 modes:
126
	 * - ask the browser
127
	 * - use the user defined address
128
	 * @param int $mode New mode
129
	 * @return array success state
130
	 */
131
	public function setMode(int $mode): array {
132
		$this->config->setUserValue($this->userId, Application::APP_ID, 'mode', strval($mode));
133
		return ['success' => true];
134
	}
135
136
	/**
137
	 * Get favorites list
138
	 * @param array $favorites
139
	 * @return array success state
140
	 */
141
	public function getFavorites(): array {
142
		$favoritesJson = $this->config->getUserValue($this->userId, Application::APP_ID, 'favorites', '');
143
		return json_decode($favoritesJson, true) ?: [];
144
	}
145
146
	/**
147
	 * Set favorites list
148
	 * @param array $favorites
149
	 * @return array success state
150
	 */
151
	public function setFavorites(array $favorites): array {
152
		$this->config->setUserValue($this->userId, Application::APP_ID, 'favorites', json_encode($favorites));
153
		return ['success' => true];
154
	}
155
156
	/**
157
	 * Try to use the address set in user personal settings as weather location
158
	 *
159
	 * @return array with success state and address information
160
	 */
161
	public function usePersonalAddress(): array {
162
		$account = $this->accountManager->getAccount($this->userManager->get($this->userId));
163
		try {
164
			$address = $account->getProperty('address')->getValue();
165
		} catch (PropertyDoesNotExistException $e) {
166
			return ['success' => false];
167
		}
168
		if ($address === '') {
169
			return ['success' => false];
170
		}
171
		return $this->setAddress($address);
172
	}
173
174
	/**
175
	 * Set address and resolve it to get coordinates
176
	 * or directly set coordinates and get address with reverse geocoding
177
	 *
178
	 * @param string|null $address Any approximative or exact address
179
	 * @param float|null $lat Latitude in decimal degree format
180
	 * @param float|null $lon Longitude in decimal degree format
181
	 * @return array with success state and address information
182
	 */
183
	public function setLocation(?string $address, ?float $lat, ?float $lon): array {
184
		if (!is_null($lat) && !is_null($lon)) {
185
			// store coordinates
186
			$this->config->setUserValue($this->userId, Application::APP_ID, 'lat', strval($lat));
187
			$this->config->setUserValue($this->userId, Application::APP_ID, 'lon', strval($lon));
188
			// resolve and store formatted address
189
			$address = $this->resolveLocation($lat, $lon);
190
			$address = $address ? $address : $this->l10n->t('Unknown address');
191
			$this->config->setUserValue($this->userId, Application::APP_ID, 'address', $address);
192
			// get and store altitude
193
			$altitude = $this->getAltitude($lat, $lon);
194
			$this->config->setUserValue($this->userId, Application::APP_ID, 'altitude', strval($altitude));
195
			return [
196
				'address' => $address,
197
				'success' => true,
198
			];
199
		} elseif ($address) {
200
			return $this->setAddress($address);
201
		} else {
202
			return ['success' => false];
203
		}
204
	}
205
206
	/**
207
	 * Provide address information from coordinates
208
	 *
209
	 * @param float $lat Latitude in decimal degree format
210
	 * @param float $lon Longitude in decimal degree format
211
	 */
212
	private function resolveLocation(float $lat, float $lon): ?string {
213
		$params = [
214
			'lat' => number_format($lat, 2),
215
			'lon' => number_format($lon, 2),
216
			'addressdetails' => 1,
217
			'format' => 'json',
218
		];
219
		$url = 'https://nominatim.openstreetmap.org/reverse';
220
		$result = $this->requestJSON($url, $params);
221
		return $this->formatOsmAddress($result);
222
	}
223
224
	/**
225
	 * Get altitude from coordinates
226
	 *
227
	 * @param float $lat Latitude in decimal degree format
228
	 * @param float $lon Longitude in decimal degree format
229
	 * @return float altitude in meter
230
	 */
231
	private function getAltitude(float $lat, float $lon): float {
232
		$params = [
233
			'locations' => $lat . ',' . $lon,
234
		];
235
		$url = 'https://api.opentopodata.org/v1/srtm30m';
236
		$result = $this->requestJSON($url, $params);
237
		$altitude = 0;
238
		if (isset($result['results']) && is_array($result['results']) && count($result['results']) > 0
0 ignored issues
show
introduced by
The condition is_array($result['results']) is always false.
Loading history...
239
			&& is_array($result['results'][0]) && isset($result['results'][0]['elevation'])) {
240
			$altitude = floatval($result['results'][0]['elevation']);
241
		}
242
		return $altitude;
243
	}
244
245
	/**
246
	 * @return string Formatted address from JSON nominatim result
247
	 */
248
	private function formatOsmAddress(array $json): ?string {
249
		if (isset($json['address']) && isset($json['display_name'])) {
250
			$jsonAddr = $json['address'];
251
			$cityAddress = '';
252
			// priority : city, town, village, municipality
253
			if (isset($jsonAddr['city'])) {
254
				$cityAddress .= $jsonAddr['city'];
255
			} elseif (isset($jsonAddr['town'])) {
256
				$cityAddress .= $jsonAddr['town'];
257
			} elseif (isset($jsonAddr['village'])) {
258
				$cityAddress .= $jsonAddr['village'];
259
			} elseif (isset($jsonAddr['municipality'])) {
260
				$cityAddress .= $jsonAddr['municipality'];
261
			} else {
262
				return $json['display_name'];
263
			}
264
			// post code
265
			if (isset($jsonAddr['postcode'])) {
266
				$cityAddress .= ', ' . $jsonAddr['postcode'];
267
			}
268
			// country
269
			if (isset($jsonAddr['country'])) {
270
				$cityAddress .= ', ' . $jsonAddr['country'];
271
				return $cityAddress;
272
			} else {
273
				return $json['display_name'];
274
			}
275
		} elseif (isset($json['display_name'])) {
276
			return $json['display_name'];
277
		}
278
		return null;
279
	}
280
281
	/**
282
	 * Set address and resolve it to get coordinates
283
	 *
284
	 * @param string $address Any approximative or exact address
285
	 * @return array with success state and address information (coordinates and formatted address)
286
	 */
287
	public function setAddress(string $address): array {
288
		$addressInfo = $this->searchForAddress($address);
289
		if (isset($addressInfo['display_name']) && isset($addressInfo['lat']) && isset($addressInfo['lon'])) {
290
			$formattedAddress = $this->formatOsmAddress($addressInfo);
291
			$this->config->setUserValue($this->userId, Application::APP_ID, 'address', $formattedAddress);
292
			$this->config->setUserValue($this->userId, Application::APP_ID, 'lat', strval($addressInfo['lat']));
293
			$this->config->setUserValue($this->userId, Application::APP_ID, 'lon', strval($addressInfo['lon']));
294
			$this->config->setUserValue($this->userId, Application::APP_ID, 'mode', strval(self::MODE_MANUAL_LOCATION));
295
			// get and store altitude
296
			$altitude = $this->getAltitude(floatval($addressInfo['lat']), floatval($addressInfo['lon']));
297
			$this->config->setUserValue($this->userId, Application::APP_ID, 'altitude', strval($altitude));
298
			return [
299
				'lat' => $addressInfo['lat'],
300
				'lon' => $addressInfo['lon'],
301
				'address' => $formattedAddress,
302
				'success' => true,
303
			];
304
		} else {
305
			return ['success' => false];
306
		}
307
	}
308
309
	/**
310
	 * Ask nominatim information about an unformatted address
311
	 *
312
	 * @param string Unformatted address
0 ignored issues
show
Bug introduced by
The type OCA\WeatherStatus\Service\Unformatted was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
313
	 * @return array Full Nominatim result for the given address
314
	 */
315
	private function searchForAddress(string $address): array {
316
		$params = [
317
			'format' => 'json',
318
			'addressdetails' => '1',
319
			'extratags' => '1',
320
			'namedetails' => '1',
321
			'limit' => '1',
322
		];
323
		$url = 'https://nominatim.openstreetmap.org/search/' . $address;
324
		$results = $this->requestJSON($url, $params);
325
		if (count($results) > 0) {
326
			return $results[0];
0 ignored issues
show
Bug Best Practice introduced by
The expression return $results[0] returns the type string which is incompatible with the type-hinted return array.
Loading history...
327
		}
328
		return ['error' => $this->l10n->t('No result.')];
329
	}
330
331
	/**
332
	 * Get stored user location
333
	 *
334
	 * @return array which contains coordinates, formatted address and current weather status mode
335
	 */
336
	public function getLocation(): array {
337
		$lat = $this->config->getUserValue($this->userId, Application::APP_ID, 'lat', '');
338
		$lon = $this->config->getUserValue($this->userId, Application::APP_ID, 'lon', '');
339
		$address = $this->config->getUserValue($this->userId, Application::APP_ID, 'address', '');
340
		$mode = $this->config->getUserValue($this->userId, Application::APP_ID, 'mode', self::MODE_MANUAL_LOCATION);
341
		return [
342
			'lat' => $lat,
343
			'lon' => $lon,
344
			'address' => $address,
345
			'mode' => intval($mode),
346
		];
347
	}
348
349
	/**
350
	 * Get forecast for current location
351
	 *
352
	 * @return array which contains success state and filtered forecast data
353
	 */
354
	public function getForecast(): array {
355
		$lat = $this->config->getUserValue($this->userId, Application::APP_ID, 'lat', '');
356
		$lon = $this->config->getUserValue($this->userId, Application::APP_ID, 'lon', '');
357
		$alt = $this->config->getUserValue($this->userId, Application::APP_ID, 'altitude', '');
358
		if (!is_numeric($alt)) {
359
			$alt = 0;
360
		}
361
		if (is_numeric($lat) && is_numeric($lon)) {
362
			return $this->forecastRequest(floatval($lat), floatval($lon), floatval($alt));
363
		} else {
364
			return ['success' => false];
365
		}
366
	}
367
368
	/**
369
	 * Actually make the request to the forecast service
370
	 *
371
	 * @param float $lat Latitude of requested forecast, in decimal degree format
372
	 * @param float $lon Longitude of requested forecast, in decimal degree format
373
	 * @param float $altitude Altitude of requested forecast, in meter
374
	 * @param int $nbValues Number of forecast values (hours)
375
	 * @return array Filtered forecast data
376
	 */
377
	private function forecastRequest(float $lat, float $lon, float $altitude, int $nbValues = 10): array {
378
		$params = [
379
			'lat' => number_format($lat, 2),
380
			'lon' => number_format($lon, 2),
381
			'altitude' => $altitude,
382
		];
383
		$url = 'https://api.met.no/weatherapi/locationforecast/2.0/compact';
384
		$weather = $this->requestJSON($url, $params);
385
		if (isset($weather['properties']) && isset($weather['properties']['timeseries']) && is_array($weather['properties']['timeseries'])) {
386
			return array_slice($weather['properties']['timeseries'], 0, $nbValues);
387
		}
388
		return ['error' => $this->l10n->t('Malformed JSON data.')];
389
	}
390
391
	/**
392
	 * Make a HTTP GET request and parse JSON result.
393
	 * Request results are cached until the 'Expires' response header says so
394
	 *
395
	 * @param string $url Base URL to query
396
	 * @param array $params GET parameters
397
	 * @return array which contains the error message or the parsed JSON result
398
	 */
399
	private function requestJSON(string $url, array $params = []): array {
400
		if (isset($this->cache)) {
401
			$cacheKey = $url . '|' . implode(',', $params) . '|' . implode(',', array_keys($params));
402
			if ($this->cache->hasKey($cacheKey)) {
403
				return $this->cache->get($cacheKey);
404
			}
405
		}
406
		try {
407
			$options = [
408
				'headers' => [
409
					'User-Agent' => 'NextcloudWeatherStatus/' . $this->version . ' nextcloud.com'
410
				],
411
			];
412
413
			$reqUrl = $url;
414
			if (count($params) > 0) {
415
				$paramsContent = http_build_query($params);
416
				$reqUrl = $url . '?' . $paramsContent;
417
			}
418
419
			$response = $this->client->get($reqUrl, $options);
420
			$body = $response->getBody();
421
			$headers = $response->getHeaders();
422
			$respCode = $response->getStatusCode();
423
424
			if ($respCode >= 400) {
425
				return ['error' => $this->l10n->t('Error')];
426
			} else {
427
				$json = json_decode($body, true);
428
				if (isset($this->cache)) {
429
					// default cache duration is one hour
430
					$cacheDuration = 60 * 60;
431
					if (isset($headers['Expires']) && count($headers['Expires']) > 0) {
432
						// if the Expires response header is set, use it to define cache duration
433
						$expireTs = (new \Datetime($headers['Expires'][0]))->getTimestamp();
434
						$nowTs = (new \Datetime())->getTimestamp();
435
						$duration = $expireTs - $nowTs;
436
						if ($duration > $cacheDuration) {
437
							$cacheDuration = $duration;
438
						}
439
					}
440
					$this->cache->set($cacheKey, $json, $cacheDuration);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $cacheKey does not seem to be defined for all execution paths leading up to this point.
Loading history...
441
				}
442
				return $json;
443
			}
444
		} catch (\Exception $e) {
445
			$this->logger->warning($url . 'API error : ' . $e, ['app' => Application::APP_ID]);
446
			return ['error' => $e->getMessage()];
447
		}
448
	}
449
}
450