initital commit

This commit is contained in:
2025-01-26 21:17:23 +01:00
commit 2a7345ba56
72 changed files with 9458 additions and 0 deletions

18
src/Command/Command.php Normal file
View 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;
}

View 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
View File

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

View File

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

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

View 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,
) {
}
}

View 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,
) {
}
}

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

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

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

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

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

View File

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

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

View 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'),
];
}
}

View 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),
]);
}
}

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

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

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

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

View 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
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Utils\Weather;
enum WeatherInterval
{
case DAILY;
}