initital commit
This commit is contained in:
18
src/Command/Command.php
Normal file
18
src/Command/Command.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
abstract class Command extends \Symfony\Component\Console\Command\Command
|
||||
{
|
||||
final public function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->doExecute($input, $output);
|
||||
}
|
||||
|
||||
abstract protected function doExecute(InputInterface $input, OutputInterface $output): int;
|
||||
}
|
||||
98
src/Command/FetchForecastData.php
Normal file
98
src/Command/FetchForecastData.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\Services\GeoPointResolverService;
|
||||
use App\Services\Granularity\Factory\WeatherGranularityFactory;
|
||||
use App\Services\WeatherProviderInterface;
|
||||
use App\Services\WeatherStorageStoreInterface;
|
||||
use App\Utils\Weather\WeatherInterval;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class FetchForecastData extends Command
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly string $forecastForDays,
|
||||
protected readonly WeatherProviderInterface $weatherProvider,
|
||||
protected readonly WeatherStorageStoreInterface $weatherStorageStore,
|
||||
protected readonly WeatherGranularityFactory $weatherGranularityFactory,
|
||||
protected readonly GeoPointResolverService $geoPointResolverService,
|
||||
protected readonly LoggerInterface $logger,
|
||||
?string $name = null,
|
||||
) {
|
||||
parent::__construct($name);
|
||||
}
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'nano:fetch-forecast-data';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'cityName',
|
||||
'c',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'City name'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function doExecute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if ($input->getOption('cityName') !== null) {
|
||||
$latLon = $this->geoPointResolverService->getGeoPointFromCityName($input->getOption('cityName'));
|
||||
|
||||
if ($latLon === null) {
|
||||
$this->logger->error(
|
||||
"Invalid city name {cityName}",
|
||||
[
|
||||
'cityName' => $input->getOption('cityName'),
|
||||
]
|
||||
);
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$cities = [$latLon];
|
||||
} else {
|
||||
$cities = $this->geoPointResolverService->getCitiesWithPoints();
|
||||
}
|
||||
|
||||
foreach ($cities as $city) {
|
||||
$this->fetchDataForCity($city);
|
||||
}
|
||||
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function fetchDataForCity(GEOPoint $geoPoint): void
|
||||
{
|
||||
|
||||
$fromDate = new \DateTime();
|
||||
$toDate = new \DateTime();
|
||||
$toDate->add(new \DateInterval($this->forecastForDays));
|
||||
|
||||
$weather = $this->weatherProvider->fetchWeather(
|
||||
$geoPoint,
|
||||
WeatherInterval::DAILY,
|
||||
$fromDate,
|
||||
$toDate
|
||||
);
|
||||
|
||||
// handle granularity
|
||||
$granularityService = $this->weatherGranularityFactory->createWeatherGranularityByInterval(WeatherInterval::DAILY);
|
||||
$granularForCastByDay = $granularityService->calculateFromMultipleRecords($weather);
|
||||
|
||||
// store data
|
||||
$this->weatherStorageStore->storeWeatherForPointByDay($geoPoint, $granularForCastByDay);
|
||||
}
|
||||
}
|
||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
88
src/Controller/ForecastController.php
Normal file
88
src/Controller/ForecastController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DTO\Edge\Response\Mapper\WeatherAtTimePointResponseMapper;
|
||||
use App\DTO\Edge\Response\Mapper\WeatherAtTimeResponseMapper;
|
||||
use App\DTO\Edge\Response\WeatherAtTimeResponse;
|
||||
use App\Services\GeoPointResolverService;
|
||||
use App\Services\WeatherStorageReceiverInterface;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ForecastController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly string $forecastForDays,
|
||||
protected readonly WeatherStorageReceiverInterface $weatherStorageReceiver,
|
||||
protected readonly WeatherAtTimePointResponseMapper $weatherAtTimePointResponseMapper,
|
||||
protected readonly WeatherAtTimeResponseMapper $weatherAtTimeResponseMapper,
|
||||
protected readonly GeoPointResolverService $geoPointResolverService,
|
||||
protected readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[OA\Response(
|
||||
response: 200,
|
||||
description: 'Returns weather forecast for city',
|
||||
content: new OA\JsonContent(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: new Model(type: WeatherAtTimeResponse::class, groups: ['full']))
|
||||
)
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 404,
|
||||
description: 'City or data not found',
|
||||
)]
|
||||
#[OA\Response(
|
||||
response: 429,
|
||||
description: 'Please slow down... Too much requests',
|
||||
)]
|
||||
#[OA\Parameter(
|
||||
name: 'cityName',
|
||||
description: 'Name of searched city',
|
||||
in: 'path',
|
||||
)]
|
||||
#[Route('/api/v1/forecast/{cityName}', name: 'getForecast', methods: ['GET'])]
|
||||
public function getForecast(string $cityName): JsonResponse
|
||||
{
|
||||
$cityGeo = $this->geoPointResolverService->getGeoPointFromCityName($cityName);
|
||||
|
||||
if ($cityGeo === null) {
|
||||
$this->logger->warning('City location not found for {cityName}', ['cityName' => $cityName]);
|
||||
return $this->json('{}', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$startDate = new \DateTime();
|
||||
$startDate->add(new \DateInterval('P1D'));
|
||||
|
||||
$endDate = \DateTime::createFromInterface($startDate);
|
||||
$endDate->add(new \DateInterval($this->forecastForDays));
|
||||
|
||||
|
||||
$receivedData = $this->weatherStorageReceiver->receiveWeatherForPointByDay(
|
||||
$cityGeo,
|
||||
$startDate,
|
||||
$endDate
|
||||
);
|
||||
|
||||
if (count($receivedData) === 0) {
|
||||
$this->logger->notice('Forecast not found for {cityName}', ['cityName' => $cityName, 'cityGeo' => $cityGeo]);
|
||||
return $this->json('{}', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(
|
||||
$this->weatherAtTimeResponseMapper->mapResponse(
|
||||
$cityName,
|
||||
$this->weatherAtTimePointResponseMapper->fromWeatherAtTimePointsDateAsYYYYmmdd($receivedData)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Edge\Response\Mapper;
|
||||
|
||||
use App\DTO\Edge\Response\WeatherAtTimePointResponse;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
|
||||
class WeatherAtTimePointResponseMapper
|
||||
{
|
||||
/**
|
||||
* @param array<WeatherAtTimePoint> $weatherAtTimePoints
|
||||
* @return array<WeatherAtTimePointResponse>
|
||||
*/
|
||||
public function fromWeatherAtTimePointsDateAsYYYYmmdd(array $weatherAtTimePoints): array
|
||||
{
|
||||
$weatherAtTimePointResponse = [];
|
||||
|
||||
foreach ($weatherAtTimePoints as $weatherAtTimePoint) {
|
||||
$weatherAtTimePointResponse[] = new WeatherAtTimePointResponse(
|
||||
$weatherAtTimePoint->date->format('Y-m-d'),
|
||||
$weatherAtTimePoint->min,
|
||||
$weatherAtTimePoint->max,
|
||||
);
|
||||
}
|
||||
|
||||
return $weatherAtTimePointResponse;
|
||||
}
|
||||
}
|
||||
26
src/DTO/Edge/Response/Mapper/WeatherAtTimeResponseMapper.php
Normal file
26
src/DTO/Edge/Response/Mapper/WeatherAtTimeResponseMapper.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Edge\Response\Mapper;
|
||||
|
||||
use App\DTO\Edge\Response\WeatherAtTimePointResponse;
|
||||
use App\DTO\Edge\Response\WeatherAtTimeResponse;
|
||||
|
||||
class WeatherAtTimeResponseMapper
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<WeatherAtTimePointResponse> $points
|
||||
*/
|
||||
public function mapResponse(string $city, array $points): WeatherAtTimeResponse
|
||||
{
|
||||
return new WeatherAtTimeResponse(
|
||||
$city,
|
||||
$points
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/DTO/Edge/Response/WeatherAtTimePointResponse.php
Normal file
29
src/DTO/Edge/Response/WeatherAtTimePointResponse.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Edge\Response;
|
||||
|
||||
use JMS\Serializer\Annotation\Groups;
|
||||
|
||||
readonly class WeatherAtTimePointResponse
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* Date of entry.
|
||||
*/
|
||||
#[Groups(["full"])]
|
||||
public string $date,
|
||||
/**
|
||||
* @var float Minimum temp. Unit in C
|
||||
*/
|
||||
#[Groups(["full"])]
|
||||
public float $min,
|
||||
/**
|
||||
* @var float Maximum temp. Unit in C
|
||||
*/
|
||||
#[Groups(["full"])]
|
||||
public float $max,
|
||||
) {
|
||||
}
|
||||
}
|
||||
26
src/DTO/Edge/Response/WeatherAtTimeResponse.php
Normal file
26
src/DTO/Edge/Response/WeatherAtTimeResponse.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Edge\Response;
|
||||
|
||||
use JMS\Serializer\Annotation\Groups;
|
||||
use JMS\Serializer\Annotation\Type;
|
||||
|
||||
class WeatherAtTimeResponse
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* Date of entry.
|
||||
*/
|
||||
#[Groups(["full"])]
|
||||
public string $city,
|
||||
/**
|
||||
* @var array<WeatherAtTimePointResponse> Temperatures
|
||||
*/
|
||||
#[Groups(["full"])]
|
||||
#[Type('ArrayCollection<App\DTO\Edge\Response\WeatherAtTimePointResponse>')]
|
||||
public array $temperature,
|
||||
) {
|
||||
}
|
||||
}
|
||||
22
src/DTO/Factory/GEOPointFactory.php
Normal file
22
src/DTO/Factory/GEOPointFactory.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Factory;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
|
||||
class GEOPointFactory
|
||||
{
|
||||
public function createFromString(string|float|int $lat, string|float|int $lon): ?GEOPoint
|
||||
{
|
||||
if (is_numeric($lat) && is_numeric($lon)) {
|
||||
$lat = floatval($lat);
|
||||
$lon = floatval($lon);
|
||||
|
||||
return new GEOPoint($lat, $lon);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
32
src/DTO/Factory/WeatherAtTimePointFactory.php
Normal file
32
src/DTO/Factory/WeatherAtTimePointFactory.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO\Factory;
|
||||
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
|
||||
class WeatherAtTimePointFactory
|
||||
{
|
||||
public function createFromUntrustedDataWithTz(\DateTimeImmutable $mutableDate, float $min, float $max, ?float $value): WeatherAtTimePoint
|
||||
{
|
||||
$timeZone = new \DateTimeZone(date_default_timezone_get());
|
||||
$mutableDate = \DateTime::createFromImmutable($mutableDate);
|
||||
$mutableDate->setTimezone($timeZone);
|
||||
return new WeatherAtTimePoint(\DateTimeImmutable::createFromMutable($mutableDate), $min, $max, $value);
|
||||
}
|
||||
|
||||
public function createFromUntrustedWithDateYYYYmmdd(string $date, float $min, float $max, ?float $value): WeatherAtTimePoint
|
||||
{
|
||||
$parsedDate = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . " 00:00:00");
|
||||
if ($parsedDate === false) {
|
||||
throw new \InvalidArgumentException("$date is not in format Y-m-d");
|
||||
}
|
||||
return new WeatherAtTimePoint(
|
||||
$parsedDate,
|
||||
$min,
|
||||
$max,
|
||||
$value,
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/DTO/GEOPoint.php
Normal file
25
src/DTO/GEOPoint.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
readonly class GEOPoint
|
||||
{
|
||||
public function __construct(
|
||||
// float is enough... we don't need extreme accuracy
|
||||
public float $latitude,
|
||||
public float $longitude,
|
||||
) {
|
||||
}
|
||||
|
||||
public function equals(GEOPoint $point): bool
|
||||
{
|
||||
return $point->latitude == $this->latitude && $point->longitude == $this->longitude;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return "lat=" . $this->latitude . ",lng=" . $this->longitude;
|
||||
}
|
||||
}
|
||||
33
src/DTO/WeatherAtTimePoint.php
Normal file
33
src/DTO/WeatherAtTimePoint.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
/**
|
||||
* In case of update, update also SCHEMA_VERSION!
|
||||
*/
|
||||
readonly class WeatherAtTimePoint
|
||||
{
|
||||
public const SCHEMA_VERSION = 2;
|
||||
|
||||
public function __construct(
|
||||
/**
|
||||
* Date of entry.
|
||||
*/
|
||||
public \DateTimeInterface $date,
|
||||
/**
|
||||
* @var float Minimum temp. Unit in C
|
||||
*/
|
||||
public float $min,
|
||||
/**
|
||||
* @var float Maximum temp. Unit in C
|
||||
*/
|
||||
public float $max,
|
||||
/**
|
||||
* @var float|null Temp (if known). Unit in C
|
||||
*/
|
||||
public ?float $value = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
18
src/DTO/WeatherData.php
Normal file
18
src/DTO/WeatherData.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
readonly class WeatherData
|
||||
{
|
||||
public function __construct(
|
||||
public string $lat,
|
||||
public string $lon,
|
||||
/**
|
||||
* @var array<WeatherAtTimePoint>
|
||||
*/
|
||||
public array $weatherDates,
|
||||
) {
|
||||
}
|
||||
}
|
||||
13
src/Kernel.php
Normal file
13
src/Kernel.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
77
src/Services/GeoPointResolverService.php
Normal file
77
src/Services/GeoPointResolverService.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTO\Factory\GEOPointFactory;
|
||||
use App\DTO\GEOPoint;
|
||||
|
||||
class GeoPointResolverService
|
||||
{
|
||||
/**
|
||||
* @var array<string, GEOPoint>
|
||||
*/
|
||||
protected readonly array $citiesWithPoints;
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, string>> $geoPointsWithCities
|
||||
*/
|
||||
public function __construct(
|
||||
array $geoPointsWithCities,
|
||||
protected readonly GEOPointFactory $geoPointFactory,
|
||||
) {
|
||||
$citiesWithPoints = [];
|
||||
foreach ($geoPointsWithCities as $cityName => $cityDef) {
|
||||
if (!preg_match('/^[\w ]+$/u', $cityName)) {
|
||||
throw new \InvalidArgumentException("Invalid city $cityName -- Name must be [\w ]+");
|
||||
}
|
||||
|
||||
$geoPoint = $this->geoPointFactory->createFromString(
|
||||
$cityDef['lat'],
|
||||
$cityDef['lon']
|
||||
);
|
||||
|
||||
if ($geoPoint === null) {
|
||||
throw new \InvalidArgumentException("Can\'t create GEOPoint from " . $cityDef['lat'] . "/" . $cityDef['lon']);
|
||||
}
|
||||
|
||||
$citiesWithPoints[$cityName] = $geoPoint;
|
||||
}
|
||||
$this->citiesWithPoints = $citiesWithPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, GEOPoint>
|
||||
*/
|
||||
public function getCitiesWithPoints(): array
|
||||
{
|
||||
return $this->citiesWithPoints;
|
||||
}
|
||||
|
||||
public function getCityNameFromGeoPoint(GeoPoint $searchedGeoPoint): ?string
|
||||
{
|
||||
foreach ($this->citiesWithPoints as $cityName => $geoPoint) {
|
||||
if ($geoPoint->equals($searchedGeoPoint)) {
|
||||
return $cityName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getGeoPointFromCityName(string $searchedCityName): ?GEOPoint
|
||||
{
|
||||
if (!preg_match('/^[\w ]+$/u', $searchedCityName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->citiesWithPoints as $cityName => $geoPoint) {
|
||||
if ($cityName === $searchedCityName) {
|
||||
return $geoPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
95
src/Services/Granularity/DayWeatherByDate.php
Normal file
95
src/Services/Granularity/DayWeatherByDate.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Granularity;
|
||||
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use App\Services\WeatherGranularityInterface;
|
||||
use DateTimeImmutable;
|
||||
use Nubium\Exception\ThisShouldNeverHappenException;
|
||||
|
||||
class DayWeatherByDate implements WeatherGranularityInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<WeatherAtTimePoint> $records
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function calculateFromMultipleRecords(array $records): array
|
||||
{
|
||||
$weatherDataByDates = [];
|
||||
|
||||
foreach ($records as $weatherDataAtTimePoint) {
|
||||
if (!isset($weatherDataByDates[$weatherDataAtTimePoint->date->format('Y-m-d')])) {
|
||||
$weatherDataByDates[$weatherDataAtTimePoint->date->format('Y-m-d')] = [];
|
||||
}
|
||||
$weatherDataByDates[$weatherDataAtTimePoint->date->format('Y-m-d')][] = $weatherDataAtTimePoint;
|
||||
}
|
||||
|
||||
return $this->resetTimeToMidnight($this->flatizeWeatherData($weatherDataByDates));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<WeatherAtTimePoint>> $weatherData
|
||||
* @return array<int, WeatherAtTimePoint>
|
||||
*/
|
||||
protected function flatizeWeatherData(array $weatherData): array
|
||||
{
|
||||
$flatizedWeatherData = [];
|
||||
foreach ($weatherData as $weatherDataAtTimePoints) {
|
||||
if (count($weatherDataAtTimePoints) === 1) {
|
||||
$flatizedWeatherData[] = $weatherDataAtTimePoints[0];
|
||||
} else {
|
||||
// calculate avg value, max for max, min for min
|
||||
$maxWeatherAtDate = PHP_INT_MIN;
|
||||
$minWeatherAtDate = PHP_INT_MAX;
|
||||
$valueWeatherDataPoints = 0;
|
||||
foreach ($weatherDataAtTimePoints as $weatherDataAtTimePoint) {
|
||||
$maxWeatherAtDate = max($maxWeatherAtDate, $weatherDataAtTimePoint->max);
|
||||
$minWeatherAtDate = min($minWeatherAtDate, $weatherDataAtTimePoint->min);
|
||||
$valueWeatherDataPoints += $weatherDataAtTimePoint->value;
|
||||
}
|
||||
|
||||
$flatizedWeatherData[] = new WeatherAtTimePoint(
|
||||
$weatherDataAtTimePoints[0]->date,
|
||||
$minWeatherAtDate,
|
||||
$maxWeatherAtDate,
|
||||
$valueWeatherDataPoints / count($weatherDataAtTimePoints)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $flatizedWeatherData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<WeatherAtTimePoint> $weatherData
|
||||
* @return array<int, WeatherAtTimePoint>
|
||||
*/
|
||||
protected function resetTimeToMidnight(array $weatherData): array
|
||||
{
|
||||
$resetWeatherAtTimePoints = [];
|
||||
foreach ($weatherData as $weatherDataAtTimePoints) {
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $weatherDataAtTimePoints->date->format('Y-m-d 00:00:00'));
|
||||
|
||||
if ($date === false) {
|
||||
throw new ThisShouldNeverHappenException("Can't create date from date" . $weatherDataAtTimePoints->date->format('Y-m-d 00:00:00'));
|
||||
}
|
||||
|
||||
$weatherDataAtTimePoints = new WeatherAtTimePoint(
|
||||
$date,
|
||||
$weatherDataAtTimePoints->min,
|
||||
$weatherDataAtTimePoints->max,
|
||||
$weatherDataAtTimePoints->value
|
||||
);
|
||||
|
||||
$resetWeatherAtTimePoints[] = $weatherDataAtTimePoints;
|
||||
}
|
||||
|
||||
return $resetWeatherAtTimePoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Granularity\Factory;
|
||||
|
||||
use App\Services\Granularity\DayWeatherByDate;
|
||||
use App\Services\WeatherGranularityInterface;
|
||||
use App\Utils\Weather\WeatherInterval;
|
||||
|
||||
class WeatherGranularityFactory
|
||||
{
|
||||
public function createWeatherGranularityByInterval(WeatherInterval $weatherInterval): WeatherGranularityInterface
|
||||
{
|
||||
return match ($weatherInterval) {
|
||||
WeatherInterval::DAILY => new DayWeatherByDate()
|
||||
};
|
||||
}
|
||||
}
|
||||
142
src/Services/Remote/Mapper/OpenMeteoMapper.php
Normal file
142
src/Services/Remote/Mapper/OpenMeteoMapper.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Remote\Mapper;
|
||||
|
||||
use App\DTO\Factory\WeatherAtTimePointFactory;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Example data:
|
||||
*
|
||||
* {
|
||||
* "latitude": 50.08,
|
||||
* "longitude": 14.439999,
|
||||
* "generationtime_ms": 0.05459785461425781,
|
||||
* "utc_offset_seconds": 3600,
|
||||
* "timezone": "Europe/Prague",
|
||||
* "timezone_abbreviation": "GMT+1",
|
||||
* "elevation": 219,
|
||||
* "daily_units": {
|
||||
* "time": "iso8601",
|
||||
* "temperature_2m_max": "°C",
|
||||
* "temperature_2m_min": "°C"
|
||||
* },
|
||||
* "daily": {
|
||||
* "time": [
|
||||
* "2025-01-26",
|
||||
* "2025-01-27",
|
||||
* "2025-01-28",
|
||||
* "2025-01-29",
|
||||
* "2025-01-30",
|
||||
* "2025-01-31",
|
||||
* "2025-02-01"
|
||||
* ],
|
||||
* "temperature_2m_max": [
|
||||
* 9.1,
|
||||
* 8.3,
|
||||
* 7.6,
|
||||
* 7.6,
|
||||
* 7.2,
|
||||
* 2.7,
|
||||
* 2.5
|
||||
* ],
|
||||
* "temperature_2m_min": [
|
||||
* 3.1,
|
||||
* 2.3,
|
||||
* 4.6,
|
||||
* 3.6,
|
||||
* 1.3,
|
||||
* -0.6,
|
||||
* -0.8
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class OpenMeteoMapper
|
||||
{
|
||||
protected const TIME_PART_KEY = 'time';
|
||||
|
||||
public function __construct(
|
||||
protected readonly WeatherAtTimePointFactory $weatherAtTimePointFactory,
|
||||
protected readonly ValidatorInterface $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $responseJson
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function mapResponseIntoTimePoints(
|
||||
array $responseJson,
|
||||
string $requestedPart,
|
||||
string $maxKey = 'temperature_2m_max',
|
||||
string $minKey = 'temperature_2m_min',
|
||||
?string $valueKey = null
|
||||
): array {
|
||||
$timePoints = [];
|
||||
|
||||
$this->validateData($responseJson, $requestedPart, $minKey, $maxKey, $valueKey);
|
||||
|
||||
foreach ($responseJson[$requestedPart]["time"] as $key => $value) {
|
||||
$timePoints[] = $this->weatherAtTimePointFactory->createFromUntrustedWithDateYYYYmmdd(
|
||||
$value,
|
||||
$responseJson[$requestedPart][$minKey][$key],
|
||||
$responseJson[$requestedPart][$maxKey][$key],
|
||||
$responseJson[$requestedPart][$valueKey] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return $timePoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $responseJson
|
||||
*/
|
||||
protected function validateData(array $responseJson, string $requestedPart, string $minKey, string $maxKey, ?string $value): void
|
||||
{
|
||||
$unitsKey = $requestedPart . '_units';
|
||||
|
||||
$constraints = new Assert\Collection([
|
||||
$unitsKey => new Assert\Collection([
|
||||
static::TIME_PART_KEY => new Assert\EqualTo('iso8601'),
|
||||
$maxKey => new Assert\EqualTo('°C'),
|
||||
$minKey => new Assert\EqualTo('°C'),
|
||||
$value => new Assert\Optional(new Assert\EqualTo('°C'))
|
||||
], allowExtraFields: true),
|
||||
$requestedPart => new Assert\Collection([
|
||||
static::TIME_PART_KEY => new Assert\All([
|
||||
new Assert\Date()
|
||||
]),
|
||||
$maxKey => new Assert\All([
|
||||
new Assert\Type('float')
|
||||
]),
|
||||
$minKey => new Assert\All([
|
||||
new Assert\Type('float')
|
||||
]),
|
||||
$value => new Assert\Optional(
|
||||
new Assert\All([
|
||||
new Assert\Type('float')
|
||||
])
|
||||
)
|
||||
], allowExtraFields: true),
|
||||
], allowExtraFields: true);
|
||||
|
||||
$errors = $this->validator->validate($responseJson, $constraints);
|
||||
|
||||
if ($errors->count() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errorMessages = [];
|
||||
|
||||
foreach ($errors as $error) {
|
||||
$errorMessages[] = $error->getPropertyPath() . ' ' . $error->getMessage();
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Response validation error . " . implode(',', $errorMessages));
|
||||
}
|
||||
}
|
||||
71
src/Services/Remote/OpenMeteoService.php
Normal file
71
src/Services/Remote/OpenMeteoService.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Remote;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use App\Services\Remote\Mapper\OpenMeteoMapper;
|
||||
use App\Services\WeatherProviderInterface;
|
||||
use App\Utils\Weather\WeatherInterval;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class OpenMeteoService implements WeatherProviderInterface
|
||||
{
|
||||
protected const DAILY_REQUEST = 'daily';
|
||||
|
||||
protected const TEMP_MAX = 'temperature_2m_max';
|
||||
|
||||
protected const TEMP_MIN = 'temperature_2m_min';
|
||||
|
||||
public function __construct(
|
||||
protected readonly HttpClientInterface $httpClient,
|
||||
protected readonly OpenMeteoMapper $openMeteoMapper,
|
||||
protected string $meteoApiUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function fetchWeather(GEOPoint $geoPoint, WeatherInterval $weatherInterval, \DateTimeInterface $fromDate, \DateTimeInterface $toDate): array
|
||||
{
|
||||
return match ($weatherInterval) {
|
||||
WeatherInterval::DAILY => $this->fetchDailyWeather($geoPoint, $fromDate, $toDate),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
protected function fetchDailyWeather(GEOPoint $geoPoint, \DateTimeInterface $fromDate, \DateTimeInterface $toDate): array
|
||||
{
|
||||
$queryArgs = $this->buildQueryArgs($geoPoint, $fromDate, $toDate, static::DAILY_REQUEST, [static::TEMP_MAX, static::TEMP_MIN]);
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
$this->meteoApiUrl,
|
||||
['query' => $queryArgs]
|
||||
);
|
||||
|
||||
$responseJson = $response->toArray();
|
||||
|
||||
return $this->openMeteoMapper->mapResponseIntoTimePoints($responseJson, static::DAILY_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, string> $needValues
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function buildQueryArgs(GEOPoint $geoPoint, \DateTimeInterface $fromDate, \DateTimeInterface $toDate, string $endPoint, array $needValues): array
|
||||
{
|
||||
return [
|
||||
'latitude' => (string)round($geoPoint->latitude, 2),
|
||||
'longitude' => (string)round($geoPoint->longitude, 2),
|
||||
$endPoint => implode(',', $needValues),
|
||||
'timezone' => 'UTC',
|
||||
'start_date' => $fromDate->format('Y-m-d'),
|
||||
'end_date' => $toDate->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
}
|
||||
82
src/Services/Storage/RedisStorageStore.php
Normal file
82
src/Services/Storage/RedisStorageStore.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Storage;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use App\Services\WeatherStorageReceiverInterface;
|
||||
use App\Services\WeatherStorageStoreInterface;
|
||||
use DateTimeInterface;
|
||||
use JMS\Serializer\SerializerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RedisStorageStore implements WeatherStorageStoreInterface, WeatherStorageReceiverInterface
|
||||
{
|
||||
protected const BY_DAY = 'DAY';
|
||||
|
||||
public function __construct(
|
||||
protected readonly \Redis $redis,
|
||||
protected readonly LoggerInterface $logger,
|
||||
protected readonly SerializerInterface $serializer
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<WeatherAtTimePoint> $weatherAtTimePoints
|
||||
*/
|
||||
public function storeWeatherForPointByDay(GEOPoint $point, array $weatherAtTimePoints): void
|
||||
{
|
||||
foreach ($weatherAtTimePoints as $weatherAtTimePoint) {
|
||||
$this->logger->debug("Storing new forcast for $point at date " . $weatherAtTimePoint->date->format('Y-m-d'), [
|
||||
'forecast' => $weatherAtTimePoint
|
||||
]);
|
||||
|
||||
$key = $this->getKeyFromWheatherAtTimePoint(
|
||||
\DateTime::createFromInterface($weatherAtTimePoint->date)->setTime(0, 0),
|
||||
$point,
|
||||
static::BY_DAY
|
||||
);
|
||||
|
||||
if ($this->redis->set($key, $this->serializer->serialize($weatherAtTimePoint, 'json')) === false) {
|
||||
throw new \RuntimeException("Can't store data into redis");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function receiveWeatherForPointByDay(GEOPoint $point, DateTimeInterface $startDate, DateTimeInterface $endDate): array
|
||||
{
|
||||
$weatherData = [];
|
||||
|
||||
if ($startDate > $endDate) {
|
||||
throw new \LogicException('Start date cannot be before end date');
|
||||
}
|
||||
|
||||
$this->logger->debug("Retrieving forecast for city by $point");
|
||||
|
||||
$date = \DateTime::createFromInterface($startDate)->setTime(0, 0);
|
||||
$endDate = \DateTime::createFromInterface($endDate)->setTime(0, 0);
|
||||
do {
|
||||
$key = $this->getKeyFromWheatherAtTimePoint($date, $point, self::BY_DAY);
|
||||
$data = $this->redis->get($key);
|
||||
if ($data !== false) {
|
||||
$weatherData[] = $this->serializer->deserialize($data, WeatherAtTimePoint::class, 'json');
|
||||
}
|
||||
$date = $date->add(new \DateInterval('P1D'));
|
||||
} while ($date <= $endDate);
|
||||
|
||||
return $weatherData;
|
||||
}
|
||||
|
||||
protected function getKeyFromWheatherAtTimePoint(DateTimeInterface $weatherItemDate, GEOPoint $point, string $type): string
|
||||
{
|
||||
return implode('|', [
|
||||
'schema_version' => WeatherAtTimePoint::SCHEMA_VERSION,
|
||||
'type' => $type,
|
||||
'lat' => $point->latitude,
|
||||
'lon' => $point->longitude,
|
||||
'date' => $weatherItemDate->format(DateTimeInterface::ISO8601_EXPANDED),
|
||||
]);
|
||||
}
|
||||
}
|
||||
17
src/Services/WeatherGranularityInterface.php
Normal file
17
src/Services/WeatherGranularityInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
|
||||
interface WeatherGranularityInterface
|
||||
{
|
||||
/**
|
||||
* Calculate forecast with granularity.
|
||||
* @param array<WeatherAtTimePoint> $records
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function calculateFromMultipleRecords(array $records): array;
|
||||
}
|
||||
19
src/Services/WeatherProviderInterface.php
Normal file
19
src/Services/WeatherProviderInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use App\Utils\Weather\WeatherInterval;
|
||||
|
||||
interface WeatherProviderInterface
|
||||
{
|
||||
/**
|
||||
* Get forecast from fromDate to toDate, at ForeCastInterval granularity.
|
||||
* Service may return more forecasts per ForecastInterval (for example WeatherAtTimePoint can be repeated multiple times at same date with ForecastInterval::DAILY)
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function fetchWeather(GEOPoint $geoPoint, WeatherInterval $weatherInterval, \DateTimeInterface $fromDate, \DateTimeInterface $toDate): array;
|
||||
}
|
||||
17
src/Services/WeatherStorageReceiverInterface.php
Normal file
17
src/Services/WeatherStorageReceiverInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
use DateTimeInterface;
|
||||
|
||||
interface WeatherStorageReceiverInterface
|
||||
{
|
||||
/**
|
||||
* @return array<WeatherAtTimePoint>
|
||||
*/
|
||||
public function receiveWeatherForPointByDay(GEOPoint $point, DateTimeInterface $startDate, DateTimeInterface $endDate): array;
|
||||
}
|
||||
16
src/Services/WeatherStorageStoreInterface.php
Normal file
16
src/Services/WeatherStorageStoreInterface.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTO\GEOPoint;
|
||||
use App\DTO\WeatherAtTimePoint;
|
||||
|
||||
interface WeatherStorageStoreInterface
|
||||
{
|
||||
/**
|
||||
* @param array<WeatherAtTimePoint> $weatherAtTimePoints
|
||||
*/
|
||||
public function storeWeatherForPointByDay(GEOPoint $point, array $weatherAtTimePoints): void;
|
||||
}
|
||||
79
src/Utils/RetryableHttpClient.php
Normal file
79
src/Utils/RetryableHttpClient.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
||||
|
||||
class RetryableHttpClient implements HttpClientInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly LoggerInterface $logger,
|
||||
protected readonly HttpClientInterface $client,
|
||||
protected int $maxRetries = 3,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $options
|
||||
*/
|
||||
public function request(string $method, string $url, array $options = []): ResponseInterface
|
||||
{
|
||||
$lastException = new \InvalidArgumentException("$this->maxRetries < 1");
|
||||
|
||||
for ($i = 1; $i <= $this->maxRetries; ++$i) {
|
||||
try {
|
||||
$response = $this->client->request($method, $url, $options);
|
||||
|
||||
// TODO: dynamic filters for codes & handle 400 error, slow down response ...
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
$this->logger->warning("Response $method on $url failed.", [
|
||||
'statusCode' => $response->getStatusCode(),
|
||||
]);
|
||||
// TODO: better exception
|
||||
$lastException = new \RuntimeException("Invalid status code, accept: 200, got " . $response->getStatusCode());
|
||||
continue;
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error("Response $method on $url failed.", [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
$lastException = $exception;
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
public function stream(iterable|ResponseInterface $responses, ?float $timeout = null): ResponseStreamInterface
|
||||
{
|
||||
$lastException = new \InvalidArgumentException("$this->maxRetries < 1");
|
||||
|
||||
for ($i = 1; $i <= $this->maxRetries; ++$i) {
|
||||
try {
|
||||
return $this->client->stream($responses, $timeout);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error("Stream failed.", [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
$lastException = $exception;
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $options
|
||||
*/
|
||||
public function withOptions(array $options): static
|
||||
{
|
||||
return new static($this->logger, $this->client->withOptions($options), $this->maxRetries); // @phpstan-ignore new.static
|
||||
}
|
||||
}
|
||||
10
src/Utils/Weather/WeatherInterval.php
Normal file
10
src/Utils/Weather/WeatherInterval.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Utils\Weather;
|
||||
|
||||
enum WeatherInterval
|
||||
{
|
||||
case DAILY;
|
||||
}
|
||||
Reference in New Issue
Block a user