initital commit

This commit is contained in:
Ondrej Vlach 2025-01-26 21:17:23 +01:00
commit 2a7345ba56
No known key found for this signature in database
GPG Key ID: 7F141CDACEDEE2DE
72 changed files with 9458 additions and 0 deletions

20
.env Normal file
View File

@ -0,0 +1,20 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###

4
.env.dev Normal file
View File

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=ea81280f855dc0beea8c54ee21e8bf1c
###< symfony/framework-bundle ###

6
.env.test Normal file
View File

@ -0,0 +1,6 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> squizlabs/php_codesniffer ###
/.phpcs-cache
/phpcs.xml
###< squizlabs/php_codesniffer ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
docker-compose.override.yaml

14
Dockerfile-fpm.dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM git.nanobyte.cz/ovlach-public/php-docker:php-fpm-dev-8.4.3-1.0
ARG UID=1000
ARG GID=1000
USER 0:0
RUN usermod -u $UID www-data
RUN groupmod -g $GID www-data
RUN chown www-data:www-data /var/www
USER $UID:$GID
WORKDIR /var/www/html/

View File

@ -0,0 +1,13 @@
FROM nginx:1.25.3-alpine
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
ARG UID=1000
ARG GID=1000
USER 0:0
RUN apk add shadow
RUN usermod -u $UID nginx
RUN groupmod -g $GID nginx
EXPOSE 80

0
Dockerfile.static Normal file
View File

104
README.md Normal file
View File

@ -0,0 +1,104 @@
How to Start with Development
===
* **Requirements:**
* bash
* Docker with Docker Compose (v2) command
* **Run:**
```bash
bin/develop.sh
```
* Enjoy your coffee ;-)
* Symfony will serve the web at [http://localhost:8000] (API documentation)
* The API endpoint is located at `/api/v1/forecast/{cityName}`
* **To Try the API:**
* Use tools like Swagger at [http://localhost:8000/].
* **Refresh Datastore:**
```bash
docker compose exec php-fpm bin/console nano:fetch-forecast-data -vvv
```
* In the dev/production environment, refreshing will be handled automatically by `batch/v1/CronJob` kube-resource
or a cron daemon if k8s is unavailable. Monitoring is performed using Prometheus/Sentry (see TODO).
---
Tests
---
Run the following command to execute the tests:
```bash
docker compose exec php-fpm vendor/bin/phpunit
```
---
PHPStan & CodeSniffer
---
* Run PHP CodeSniffer:
```bash
docker compose exec php-fpm vendor/bin/phpcs
```
* Run PHPStan with an increased memory limit:
```bash
docker compose exec php-fpm vendor/bin/phpstan --memory-limit=2G
```
---
Containers
---
* `php-fpm` - FPM and additional tooling
* `nginx` - Serves static content and proxies requests to FPM
* `redis` - Redis for caching and data storage
---
Troubleshooting
---
* `/var/www/html/vendor` does not exist and could not be created: - See UID/GID problems.
* UID/GID problems -> Try mapping the UID/GID of containers to your local user. Create `docker-compose.override.yaml` in
the root of
the project with:
```yaml
services:
nginx:
build:
args:
- UID=<YOUR_UID>
- GID=<YOUR_GID>
php-fpm:
build:
args:
- UID=<YOUR_UID>
- GID=<YOUR_GID>
```
... or you can change `x-mapped-user-container` in `docker-compose.yaml`.
---
TODO
---
* [ ] Clean Redis data.
* [ ] Add a monitoring stack (Prometheus, Otel, Loki, Grafana, etc.).
* [ ] Integrate Sentry.
* [ ] Add monitoring (Sentry Cron or Prometheus) into `App\Command::execute` (which wraps Commands).
* [ ] Establish deployment pipelines for dev/test and production environments. Configure production settings and Docker
images.
* [ ] Integrate CI.
---
Notes
---
* Rate-limiting is managed at the NGINX layer (per IP). Therefore, there is no need to send "banned" traffic to
`php-fpm`
and occupy workers.

21
bin/console Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

18
bin/develop.sh Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -ex
docker compose build
# composer
docker compose run --rm -it php-fpm composer install
docker compose up --build -d
echo "Downloading forecast"
docker compose exec -it php-fpm /var/www/html/bin/console nano:fetch-forecast-data
echo "... ready"
# run containers (and build again)
docker compose up

23
bin/phpunit Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

87
composer.json Normal file
View File

@ -0,0 +1,87 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"jms/serializer-bundle": "*",
"nelmio/api-doc-bundle": "^4.36",
"nubium/this-should-never-happen-exception": "*",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.2.*",
"symfony/http-client": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.5",
"slevomat/coding-standard": "^8.15",
"squizlabs/php_codesniffer": "^3.11",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/phpunit-bridge": "^7.2"
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
}
}
}

6854
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
config/bundles.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
];

View File

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@ -0,0 +1,30 @@
jms_serializer:
visitors:
xml_serialization:
format_output: '%kernel.debug%'
# metadata:
# auto_detection: false
# directories:
# any-name:
# namespace_prefix: "My\\FooBundle"
# path: "@MyFooBundle/Resources/config/serializer"
# another-name:
# namespace_prefix: "My\\BarBundle"
# path: "@MyBarBundle/Resources/config/serializer"
when@prod:
jms_serializer:
visitors:
json_serialization:
options:
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION
when@dev:
jms_serializer:
visitors:
json_serialization:
options:
- JSON_PRETTY_PRINT
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@ -0,0 +1,9 @@
nelmio_api_doc:
documentation:
info:
title: Forecast API
description: Forecast API
version: 1.0.0
areas: # to filter documented areas
path_patterns:
- ^/api(?!/doc$) # Accepts routes under /api except /api/doc

View File

@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

View File

@ -0,0 +1,5 @@
framework:
test: true
session:
enabled: true
storage_factory_id: "session.storage.factory.mock_file"

View File

@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

5
config/preload.php Normal file
View File

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

11
config/routes.yaml Normal file
View File

@ -0,0 +1,11 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
default_route:
path: /{url}
controller: nelmio_api_doc.controller.swagger_ui
requirements:
url: ".*"

View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

View File

@ -0,0 +1,12 @@
# Expose your documentation as JSON swagger compliant
app.swagger:
path: /api/doc.json
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger }
## Requires the Asset component and the Twig bundle
## $ composer require twig asset
app.swagger_ui:
path: /api/doc
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }

78
config/services.yaml Normal file
View File

@ -0,0 +1,78 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
openMeteoApiUrl: 'https://api.open-meteo.com/v1/forecast'
cities:
Praha:
lat: 50.073658
lon: 14.418540
Brno:
lat: 49.195061
lon: 16.606836
Ostrava:
lat: 49.820923
lon: 18.262524
Olomouc:
lat: 49.593777
lon: 17.250879
Plzeň:
lat: 49.747059
lon: 13.377405
Pardubice:
lat: 50.04075
lon: 15.77659
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
Redis:
class: Redis
calls:
- method: connect
arguments:
- '%env(REDIS_HOST)%'
- '%env(int:REDIS_PORT)%'
App\Services\Remote\OpenMeteoService:
class: App\Services\Remote\OpenMeteoService
arguments:
$meteoApiUrl: '%openMeteoApiUrl%'
$httpClient: '@App\Utils\RetryableHttpClient'
App\Utils\RetryableHttpClient: ~
App\Command\FetchForecastData:
class: App\Command\FetchForecastData
arguments:
$forecastForDays: 'P7D'
App\Controller\ForecastController:
class: App\Controller\ForecastController
arguments:
$forecastForDays: 'P6D'
App\Services\GeoPointResolverService:
class: App\Services\GeoPointResolverService
arguments:
$geoPointsWithCities: '%cities%'
App\Services\WeatherProviderInterface: '@App\Services\Remote\OpenMeteoService'

35
config/services_test.yaml Normal file
View File

@ -0,0 +1,35 @@
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
App\Services\Remote\OpenMeteoService:
class: App\Services\Remote\OpenMeteoService
public: true
arguments:
$meteoApiUrl: '%openMeteoApiUrl%'
App\Services\GeoPointResolverService:
class: App\Services\GeoPointResolverService
arguments:
$geoPointsWithCities: '%cities%'
App\Command\FetchForecastData:
class: App\Command\FetchForecastData
arguments:
$forecastForDays: 'P7D'
App\Controller\ForecastController:
class: App\Controller\ForecastController
arguments:
$forecastForDays: 'P7D'

39
docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
x-mapped-user-container: &x-mapped-user-container
args:
- UID=1000
- GID=1000
services:
nginx:
build:
context: .
dockerfile: Dockerfile-nginx.dockerfile
<<: *x-mapped-user-container
ports:
- '8000:80'
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./:/var/www/html/
php-fpm:
build:
context: .
dockerfile: Dockerfile-fpm.dockerfile
<<: *x-mapped-user-container
volumes:
- ./:/var/www/html/
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
redis:
condition: service_healthy
dns:
- 8.8.8.8
redis:
image: redis:7.0
ports:
- "6379:6379"
command: [ "redis-server", "--save", "60", "1", "--loglevel", "warning" ]
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]

33
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,33 @@
limit_req_zone $binary_remote_addr zone=limiter:10m rate=60r/m;
server {
index index.php index.html;
server_name symfony_example_app;
error_log stderr debug;
access_log stderr;
listen 80;
root /var/www/html/public;
location = /errors/rate-limiting.json {
root /var/www/html/public;
internal;
}
location /api {
limit_req zone=limiter burst=10 nodelay;
limit_req_status 429;
try_files $uri /index.php$is_args$args;
}
error_page 429 /errors/rate-limiting.json;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/.+\.php(/|$) {
fastcgi_pass php-fpm:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}

30
phpcs.xml.dist Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<arg name="basepath" value="."/>
<arg name="cache" value=".phpcs-cache"/>
<arg name="colors"/>
<arg name="extensions" value="php"/>
<config name="installed_paths" value="vendor/slevomat/coding-standard"/>
<rule ref="vendor/slevomat/coding-standard/SlevomatCodingStandard/Sniffs/TypeHints/DeclareStrictTypesSniff.php">
<properties>
<property name="spacesCountAroundEqualsSign">0</property>
</properties>
</rule>
<rule ref="PSR12"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="N"/>
<property name="absoluteLineLimit" value="M"/>
</properties>
</rule>
<file>src/</file>
<file>tests/</file>
</ruleset>

8
phpstan.dist.neon Normal file
View File

@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

6
phpstan.neon.dist Normal file
View File

@ -0,0 +1,6 @@
parameters:
level: 8
paths:
- src/
- tests/
ignoreErrors:

38
phpunit.xml.dist Normal file
View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions>
</extensions>
</phpunit>

View File

@ -0,0 +1 @@
{}

9
public/index.php Normal file
View File

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

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

165
symfony.lock Normal file
View File

@ -0,0 +1,165 @@
{
"jms/serializer-bundle": {
"version": "5.5",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "4.0",
"ref": "cc04e10cf7171525b50c18b36004edf64cb478be"
},
"files": [
"config/packages/jms_serializer.yaml"
]
},
"nelmio/api-doc-bundle": {
"version": "4.36",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "3.0",
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94"
},
"files": [
"config/packages/nelmio_api_doc.yaml",
"config/routes/nelmio_api_doc.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"phpunit/phpunit": {
"version": "11.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"squizlabs/php_codesniffer": {
"version": "3.11",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "3.6",
"ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
},
"files": [
"phpcs.xml.dist"
]
},
"symfony/console": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "87bcf6f7c55201f345d8895deda46d2adbdbaa89"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/phpunit-bridge": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "a411a0480041243d97382cac7984f7dce7813c08"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
},
"twig/extra-bundle": {
"version": "v3.18.0"
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Controller\ForecastController;
use App\DTO\Edge\Response\Mapper\WeatherAtTimePointResponseMapper;
use App\DTO\GEOPoint;
use App\DTO\WeatherAtTimePoint;
use App\Services\GeoPointResolverService;
use App\Services\WeatherStorageReceiverInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class ForecastControllerTest extends KernelTestCase
{
public function testGetForecast(): void
{
list($weatherPoint, $controller) = $this->createController('Prague', false, true);
/**
* @var WeatherAtTimePointResponseMapper $timePointResponseMapper
*/
$timePointResponseMapper = $this->getContainer()->get(WeatherAtTimePointResponseMapper::class);
$expectedResponse = new \stdClass();
$expectedResponse->city = 'Prague';
$expectedResponse->temperature = $timePointResponseMapper->fromWeatherAtTimePointsDateAsYYYYmmdd($weatherPoint);
$result = $controller->getForecast('Prague');
$this->assertEquals($result->getStatusCode(), 200);
$this->assertEquals(
json_encode($expectedResponse),
$result->getContent()
);
}
public function testGetInvalidCity(): void
{
list($_, $controller) = $this->createController('Atlantis', true, true);
$result = $controller->getForecast('Atlantis');
$this->assertEquals($result->getStatusCode(), 404);
}
public function testDataNotFound(): void
{
list($_, $controller) = $this->createController('Prague', false, false);
$result = $controller->getForecast('Prague');
$this->assertEquals($result->getStatusCode(), 404);
}
/**
* @return array<mixed>
*/
public function createController(string $cityName, bool $invalidCity, bool $createWeatherPoints): array
{
$weatherPoint = match ($createWeatherPoints) {
true => [
new WeatherAtTimePoint(
new \DateTime(),
10,
10,
10
),
new WeatherAtTimePoint(
new \DateTime(),
9,
9,
9
),
],
false => []
};
$point = new GEOPoint(
22,
22
);
$weatherStorageReceiverMock = \Mockery::mock(WeatherStorageReceiverInterface::class);
// @phpstan-ignore-next-line method.notFound
$weatherStorageReceiverMock->shouldReceive('receiveWeatherForPointByDay')->with($point, \Mockery::any(), \Mockery::any())
->andReturn(
$weatherPoint
);
$geoPointResolverMock = \Mockery::mock(GeoPointResolverService::class);
// @phpstan-ignore-next-line method.notFound
$geoPointResolverMock->shouldReceive('getGeoPointFromCityName')->with($cityName)->andReturn(
$invalidCity === true ? null : $point
);
$this->getContainer()->set(WeatherStorageReceiverInterface::class, $weatherStorageReceiverMock);
$this->getContainer()->set(GeoPointResolverService::class, $geoPointResolverMock);
$controller = $this->getContainer()->get(ForecastController::class);
return array($weatherPoint, $controller);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Services\Storage;
use App\DTO\GEOPoint;
use App\DTO\WeatherAtTimePoint;
use App\Services\Storage\RedisStorageStore;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class RedisStorageStoreTest extends KernelTestCase
{
public function setUp(): void
{
$this->bootKernel();
}
public function testStoreAndReceiveData(): void
{
$dateTime = new \DateTime();
$dateTime->setTime(0, 0);
$tomorrowDateTime = \DateTime::createFromInterface($dateTime);
$tomorrowDateTime->add(new \DateInterval('P1D'));
$weatherAtTimePoints = [
new WeatherAtTimePoint(
$dateTime,
10,
-10,
1
),
new WeatherAtTimePoint(
$tomorrowDateTime,
10,
-10,
1
)
];
/**
* @var RedisStorageStore $redisStorage
*/
$redisStorage = $this->getContainer()->get(RedisStorageStore::class);
$redisStorage->storeWeatherForPointByDay(
new GEOPoint(
24,
24
),
$weatherAtTimePoints
);
$data = $redisStorage->receiveWeatherForPointByDay(
new GEOPoint(
24,
24
),
$weatherAtTimePoints[0]->date,
$weatherAtTimePoints[1]->date,
);
$this->assertEquals($weatherAtTimePoints, $data);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\OpenMeteo;
use App\DTO\GEOPoint;
use App\Services\Remote\OpenMeteoService;
use App\Utils\Weather\WeatherInterval;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class OpenMeteoApiTest extends KernelTestCase
{
public function setUp(): void
{
parent::setUp();
self::bootKernel();
}
public function testOpenMetoReturnsDataForDaily(): void
{
/**
* @var OpenMeteoService $openMeteoApi
*/
$openMeteoApi = $this->getContainer()->get(OpenMeteoService::class);
$fromDate = new \DateTime();
$toDate = \DateTime::createFromInterface($fromDate)->add(new \DateInterval('P1D'));
$weather = $openMeteoApi->fetchWeather(
$this->geoPointPrague(),
WeatherInterval::DAILY,
$fromDate,
$toDate,
);
$this->assertCount(2, $weather);
$this->assertEquals($fromDate->format('Y-m-d'), $weather[0]->date->format('Y-m-d'));
$this->assertEquals($toDate->format('Y-m-d'), $weather[1]->date->format('Y-m-d'));
}
private function geoPointPrague(): GEOPoint
{
return new GEOPoint(
50.073658,
14.418540
);
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\DTO\Factory;
use App\DTO\Factory\GEOPointFactory;
use App\DTO\GEOPoint;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class GEOPointFactoryTest extends TestCase
{
public function testCreateFromStringWillAcceptGeopoints(): void
{
$service = $this->createService();
$geopoint = $service->createFromString('20.303', '30.22');
$this->assertInstanceOf(GEOPoint::class, $geopoint);
$this->assertEquals(20.303, $geopoint->latitude);
$this->assertEquals(30.22, $geopoint->longitude);
}
#[DataProvider('invalidGeoPointProvider')]
public function testCreateFromStringInvalidString(string $lat, string $lon): void
{
$service = $this->createService();
$geopoint = $service->createFromString($lat, $lon);
$this->assertEquals(null, $geopoint);
}
/**
* @return array<int|string, array<int|string, string>>
*/
public static function invalidGeoPointProvider(): array
{
return [
['foo', 'foo'],
['33.33', 'foo'],
['foo', '33.33'],
['foo3', 'foo3'],
['33.33', 'foo3'],
['foo3', '33.33'],
];
}
public function createService(): GEOPointFactory
{
return new GEOPointFactory();
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\DTO\Factory;
use App\DTO\Factory\WeatherAtTimePointFactory;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
class WeatherAtTimePointFactoryTest extends TestCase
{
public function testDifferentTimeZone(): void
{
$oldTz = date_default_timezone_get();
// POSIX convention change +/- sign
date_default_timezone_set('Etc/GMT-2');
$service = $this->createService();
$weatherAtTimePoint = $service->createFromUntrustedDataWithTz(
new \DateTimeImmutable('2020-01-01 00:00:00', new DateTimeZone('Etc/GMT+0')),
0,
0,
0
);
$this->assertEquals('Etc/GMT-2', $weatherAtTimePoint->date->getTimezone()->getName());
$this->assertEquals('2020-01-01 02:00:00', $weatherAtTimePoint->date->format('Y-m-d H:i:s'));
date_default_timezone_set($oldTz);
}
public function testCreateWithYYYYmmddFormat(): void
{
$tz = date_default_timezone_get();
// POSIX convention change +/- sign
$service = $this->createService();
$weatherAtTimePoint = $service->createFromUntrustedWithDateYYYYmmdd(
"2020-01-01",
0,
0,
0
);
$this->assertEquals($tz, $weatherAtTimePoint->date->getTimezone()->getName());
$this->assertEquals('2020-01-01 00:00:00', $weatherAtTimePoint->date->format('Y-m-d H:i:s'));
}
private function createService(): WeatherAtTimePointFactory
{
return new WeatherAtTimePointFactory();
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Services;
use App\DTO\Factory\GEOPointFactory;
use App\DTO\GEOPoint;
use App\Services\GeoPointResolverService;
use PHPUnit\Framework\TestCase;
class GeoPointResolverServiceTest extends TestCase
{
public function testGetCityNameFromGeoPoint(): void
{
$this->assertEquals('Prague', $this->createService()->getCityNameFromGeoPoint(
new GEOPoint(50.073658, 14.418540)
));
$this->assertEquals('New York', $this->createService()->getCityNameFromGeoPoint(
new GEOPoint(40.730610, -73.935242)
));
}
public function testGetCityNameFromGeoPointInvalid(): void
{
$this->assertEquals(null, $this->createService()->getCityNameFromGeoPoint(
new GEOPoint(40.730610, -1)
));
}
public function testGetGeoPointFromCityName(): void
{
$resolvedGeoPoint = $this->createService()->getGeoPointFromCityName('Prague');
if ($resolvedGeoPoint === null) {
$this->fail('Geo point not found');
}
$this->assertEquals(50.073658, $resolvedGeoPoint->latitude);
$this->assertEquals(14.418540, $resolvedGeoPoint->longitude);
$resolvedGeoPoint = $this->createService()->getGeoPointFromCityName('New York');
if ($resolvedGeoPoint === null) {
$this->fail('Geo point not found');
}
$this->assertEquals(40.730610, $resolvedGeoPoint->latitude);
$this->assertEquals(-73.935242, $resolvedGeoPoint->longitude);
}
public function testGetGeoPointFromCityNameInvalid(): void
{
$resolvedGeoPoint = $this->createService()->getGeoPointFromCityName('Atlantis');
$this->assertNull($resolvedGeoPoint);
}
public function createService(): GeoPointResolverService
{
$geoPointFactory = \Mockery::mock(GEOPointFactory::class);
// @phpstan-ignore-next-line method.notFound
$geoPointFactory
->shouldReceive('createFromString')
->with('50.073658', '14.418540')
->andReturn(new GEOPoint(50.073658, 14.418540));
// @phpstan-ignore-next-line method.notFound
$geoPointFactory
->shouldReceive('createFromString')
->with('40.730610', '-73.935242')
->andReturn(new GEOPoint(40.730610, -73.935242));
return new GeoPointResolverService(
[
'Prague' => [
'lat' => '50.073658',
'lon' => '14.418540'
],
'New York' => [
'lat' => '40.730610',
'lon' => '-73.935242'
]
],
$geoPointFactory // @phpstan-ignore argument.type
);
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Services\Granularity;
use App\DTO\WeatherAtTimePoint;
use App\Services\Granularity\DayWeatherByDate;
use PHPUnit\Framework\TestCase;
class DayWeatherByDateTest extends TestCase
{
public function testCalculateFromMultipleRecordsInDay(): void
{
$data = [
new WeatherAtTimePoint(
new \DateTimeImmutable('2020-01-01 01:01:01'),
0.2,
5.2,
5
),
new WeatherAtTimePoint(
new \DateTimeImmutable('2020-01-01 01:01:10'),
-1.1,
6.2,
10.5
)
];
$service = $this->createService();
$calculatedData = $service->calculateFromMultipleRecords($data);
$this->assertEquals(1, count($calculatedData));
$this->assertEquals("2020-01-01 00:00:00", $calculatedData[0]->date->format('Y-m-d H:i:s'));
$this->assertEquals($calculatedData[0]->min, -1.1);
$this->assertEquals($calculatedData[0]->max, 6.2);
$temperatureValue = ($data[0]->value + $data[1]->value) / 2;
$this->assertEquals($calculatedData[0]->value, $temperatureValue);
}
public function testCalculateFromSingleRecordsInDay(): void
{
$data = [
new WeatherAtTimePoint(
new \DateTimeImmutable('2020-01-01 01:01:01'),
0.2,
5.2,
5
),
new WeatherAtTimePoint(
new \DateTimeImmutable('2020-01-02 01:01:10'),
-1.1,
6.2,
10.5
)
];
$service = $this->createService();
$calculatedData = $service->calculateFromMultipleRecords($data);
$this->assertEquals(2, count($calculatedData));
$this->assertEquals("2020-01-01 00:00:00", $calculatedData[0]->date->format('Y-m-d H:i:s'));
$this->assertEquals($calculatedData[0]->min, 0.2);
$this->assertEquals($calculatedData[0]->max, 5.2);
$this->assertEquals($calculatedData[0]->value, 5);
$this->assertEquals("2020-01-02 00:00:00", $calculatedData[1]->date->format('Y-m-d H:i:s'));
$this->assertEquals($calculatedData[1]->min, -1.1);
$this->assertEquals($calculatedData[1]->max, 6.2);
$this->assertEquals($calculatedData[1]->value, 10.5);
}
private function createService(): DayWeatherByDate
{
return new DayWeatherByDate();
}
}

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Services\Remote\Mapper;
use App\DTO\Factory\WeatherAtTimePointFactory;
use App\DTO\WeatherAtTimePoint;
use App\Services\Remote\Mapper\OpenMeteoMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
class OpenMeteoMapperTest extends TestCase
{
public function testDailyData(): void
{
$data = '{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3],"temperature_2m_min":[3.1,2.3]}}';
$json = json_decode($data, true);
$service = $this->createService();
$data = $service->mapResponseIntoTimePoints($json, 'daily');
$this->assertEquals("2025-01-26", $data[0]->date->format('Y-m-d'));
$this->assertEquals(3.1, $data[0]->min);
$this->assertEquals(9.1, $data[0]->max);
$this->assertEquals("2025-01-27", $data[1]->date->format('Y-m-d'));
$this->assertEquals(2.3, $data[1]->min);
$this->assertEquals(8.3, $data[1]->max);
}
#[DataProvider('invalidDataGenerator')]
public function testDailyInvalidDataTimeFormat(string $json): void
{
$this->expectException(\InvalidArgumentException::class);
$json = json_decode($json, true);
$service = $this->createService();
$service->mapResponseIntoTimePoints($json, 'daily');
}
/**
* @return array<int, array<int, string>>
*/
public static function invalidDataGenerator(): array
{
return [
// invalid date format (no iso)
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso86013","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3],"temperature_2m_min":[3.1,2.3]}}'],
// invalid temp format (from)
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3],"temperature_2m_min":[3.1,"fff"]}}'],
// invalid temp format (to)
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":["fff",8.3],"temperature_2m_min":[3.1,2.3]}}'],
// missing daily units
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3],"temperature_2m_min":[3.1,2.3]}}'],
// missing tempature min
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3]}}'],
// different units
['{"latitude":50.08,"longitude":14.439999,"generationtime_ms":0.027298927307128906,"utc_offset_seconds":3600,"timezone":"Europe/Prague","timezone_abbreviation":"GMT+1","elevation":219.0,"daily_units":{"time":"iso8601","temperature_2m_max":"°K","temperature_2m_min":"°K"},"daily":{"time":["2025-01-26","2025-01-27"],"temperature_2m_max":[9.1,8.3],"temperature_2m_min":[3.1,2.3]}}']
];
}
private function createService(): OpenMeteoMapper
{
$mock = \Mockery::mock(WeatherAtTimePointFactory::class);
$firstDate = \DateTimeImmutable::createFromFormat('Y-m-d', '2025-01-26');
$secondDate = \DateTimeImmutable::createFromFormat('Y-m-d', '2025-01-27');
if ($firstDate === false || $secondDate === false) {
$this->fail('Can\'t create date');
}
// @phpstan-ignore-next-line method.notFound
$mock->shouldReceive('createFromUntrustedWithDateYYYYmmdd')->with('2025-01-26', '3.1', '9.1', null)->andReturn(
new WeatherAtTimePoint(
$firstDate,
3.1,
9.1,
null
)
);
// @phpstan-ignore-next-line method.notFound
$mock->shouldReceive('createFromUntrustedWithDateYYYYmmdd')->with('2025-01-27', '2.3', '8.3', null)->andReturn(
new WeatherAtTimePoint(
$secondDate,
2.3,
8.3,
null
)
);
return new OpenMeteoMapper(
$mock, // @phpstan-ignore argument.type
Validation::createValidator()
);
}
}

13
tests/bootstrap.php Normal file
View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/config/bootstrap.php')) {
require dirname(__DIR__) . '/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}