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