From a0d4bf6f914c66316b1f4bd7bceb7e6680e90b92 Mon Sep 17 00:00:00 2001 From: Oh Martin Date: Thu, 4 Sep 2025 11:52:12 +0200 Subject: [PATCH] Get geographical weather data by city name or cordinates --- .env.example | 1 + .gitattributes | 12 + .gitignore | 1 + app/dependencies.php | 19 + app/routes.php | 3 + composer.json | 4 +- composer.lock | 835 +++++++++++++++++- public/index.php | 5 + .../Actions/Weather/GetWeatherAction.php | 99 +++ src/Service/OpenWeatherClient.php | 148 ++++ 10 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 src/Application/Actions/Weather/GetWeatherAction.php create mode 100644 src/Service/OpenWeatherClient.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..137887f --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENWEATHER_API_KEY=api_key \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..55fe69c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +/app export-ignore +/public export-ignore +/logs export-ignore +/var export-ignore +/.github export-ignore +/.env.example export-ignore +/.env export-ignore +/docker-compose.yml export-ignore +/phpcs.xml export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml export-ignore +/CONTRIBUTING.md export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63066a6..bfe46ff 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /logs/* !/logs/README.md .phpunit.result.cache +.env diff --git a/app/dependencies.php b/app/dependencies.php index fd3f066..77e6ae6 100644 --- a/app/dependencies.php +++ b/app/dependencies.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Application\Settings\SettingsInterface; +use App\Service\OpenWeatherClient; +use GuzzleHttp\Client; use DI\ContainerBuilder; use Monolog\Handler\StreamHandler; use Monolog\Logger; @@ -26,5 +28,22 @@ return function (ContainerBuilder $containerBuilder) { return $logger; }, + + Client::class => function () { + return new Client([ + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Slim-Weather/1.0', + ], + ]); + }, + + OpenWeatherClient::class => function (ContainerInterface $c) { + $apiKey = getenv('OPENWEATHER_API_KEY') ?: ''; + if ($apiKey === '') { + throw new \RuntimeException('OPENWEATHER_API_KEY is not configured'); + } + return new OpenWeatherClient($c->get(Client::class), $apiKey); + }, ]); }; diff --git a/app/routes.php b/app/routes.php index 17e4b82..0c73de7 100644 --- a/app/routes.php +++ b/app/routes.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Application\Actions\User\ListUsersAction; use App\Application\Actions\User\ViewUserAction; +use App\Application\Actions\Weather\GetWeatherAction; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\App; @@ -24,4 +25,6 @@ return function (App $app) { $group->get('', ListUsersAction::class); $group->get('/{id}', ViewUserAction::class); }); + + $app->get('/weather', GetWeatherAction::class); }; diff --git a/composer.json b/composer.json index 6b8de2a..cfb6737 100644 --- a/composer.json +++ b/composer.json @@ -24,10 +24,12 @@ "require": { "php": "^7.4 || ^8.0", "ext-json": "*", + "guzzlehttp/guzzle": "^7", "monolog/monolog": "^2.8", "php-di/php-di": "^6.4", "slim/psr7": "^1.5", - "slim/slim": "^4.10" + "slim/slim": "^4.10", + "vlucas/phpdotenv": "^5.6" }, "require-dev": { "jangregor/phpstan-prophecy": "^1.0.0", diff --git a/composer.lock b/composer.lock index 4ba4834..6948e8d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "62d60de4b802ec31b840b9197e947981", + "content-hash": "9809d780e02ece6bb0d9bac9956ad62a", "packages": [ { "name": "fig/http-message-util", @@ -62,6 +62,393 @@ }, "time": "2020-11-24T22:02:12+00:00" }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.7", @@ -448,6 +835,81 @@ }, "time": "2020-10-12T12:39:22+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.9.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-08-21T11:53:16+00:00" + }, { "name": "psr/container", "version": "1.1.2", @@ -496,6 +958,58 @@ }, "time": "2021-11-05T16:50:12+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, { "name": "psr/http-factory", "version": "1.1.0", @@ -1009,6 +1523,241 @@ ], "time": "2025-08-20T18:16:16+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.33.0", @@ -1092,6 +1841,90 @@ } ], "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" } ], "packages-dev": [ diff --git a/public/index.php b/public/index.php index bc583c8..9dbdcd6 100644 --- a/public/index.php +++ b/public/index.php @@ -7,11 +7,16 @@ use App\Application\Handlers\ShutdownHandler; use App\Application\ResponseEmitter\ResponseEmitter; use App\Application\Settings\SettingsInterface; use DI\ContainerBuilder; +use Dotenv\Dotenv; use Slim\Factory\AppFactory; use Slim\Factory\ServerRequestCreatorFactory; require __DIR__ . '/../vendor/autoload.php'; +// Load environment variables from .env +$dotenv = Dotenv::createUnsafeImmutable(dirname(__DIR__)); +$dotenv->safeLoad(); + // Instantiate PHP-DI ContainerBuilder $containerBuilder = new ContainerBuilder(); diff --git a/src/Application/Actions/Weather/GetWeatherAction.php b/src/Application/Actions/Weather/GetWeatherAction.php new file mode 100644 index 0000000..da56023 --- /dev/null +++ b/src/Application/Actions/Weather/GetWeatherAction.php @@ -0,0 +1,99 @@ +ow = $ow; + } + + /** + * Handle GET /weather requests. + * + * Supports: + * - city: string city name (uses OpenWeather Geocoding API to resolve to coordinates) + * - lat, lon: floats for direct coordinate queries + * - exclude: comma-separated parts to exclude (current, minutely, hourly, daily,alerts) + * - units: standard|metric|imperial + * - lang: locale code (e.g., en, es) + * + * If "city" is provided, lat/lon are not required. Otherwise, lat and lon must be provided. + * + * @param Request $request PSR-7 Server Request + * @param Response $response PSR-7 Response + * @return Response JSON response with weather data or error payload + */ + public function __invoke(Request $request, Response $response): Response + { + $params = $request->getQueryParams(); + + $city = isset($params['city']) ? trim((string)$params['city']) : null; + + $exclude = isset($params['exclude']) ? (string) $params['exclude'] : null; + $units = isset($params['units']) ? (string) $params['units'] : null; // standard|metric|imperial + $lang = isset($params['lang']) ? (string) $params['lang'] : null; + + try { + if ($city !== null && $city !== '') { + $data = $this->ow->oneCallByCity($city, [ + 'exclude' => $exclude, + 'units' => $units, + 'lang' => $lang, + ]); + return $this->json($response, $data, 200); + } + + // Validate required parameters if city not provided + if (!isset($params['lat'], $params['lon'])) { + return $this->json($response, ['error' => 'city or lat/lon are required'], 400); + } + + $lat = filter_var($params['lat'], FILTER_VALIDATE_FLOAT); + $lon = filter_var($params['lon'], FILTER_VALIDATE_FLOAT); + + if ($lat === false || $lon === false || $lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { + return $this->json($response, ['error' => 'lat/lon are invalid'], 422); + } + + $data = $this->ow->oneCall((float)$lat, (float)$lon, [ + 'exclude' => $exclude, + 'units' => $units, + 'lang' => $lang, + ]); + return $this->json($response, $data, 200); + } catch (\RuntimeException $e) { + return $this->json($response, ['error' => $e->getMessage()], 502); + } + } + + /** + * Write a JSON payload to the response with a given status code. + * + * @param Response $response Response to write into + * @param array $data Data to JSON-encode + * @param int $status HTTP status code + * @return Response + */ + private function json(Response $response, array $data, int $status): Response + { + $response->getBody()->write(json_encode($data)); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus($status); + } +} diff --git a/src/Service/OpenWeatherClient.php b/src/Service/OpenWeatherClient.php new file mode 100644 index 0000000..da03a18 --- /dev/null +++ b/src/Service/OpenWeatherClient.php @@ -0,0 +1,148 @@ +http = $http; + $this->apiKey = $apiKey; + $this->baseUrl = rtrim($baseUrl, '/'); + } + + /** + * Fetch One Call (3.0) current/forecast data + * + * @param float $lat + * @param float $lon + * @param array{ + * exclude?: string, + * units?: 'standard'|'metric'|'imperial', + * lang?: string + * } $options + * @return array + * @throws \RuntimeException on HTTP or API error + */ + public function oneCall(float $lat, float $lon, array $options = []): array + { + $query = [ + 'lat' => $lat, + 'lon' => $lon, + 'appid' => $this->apiKey, + ]; + + if (!empty($options['exclude'])) { + $query['exclude'] = $options['exclude']; // comma-separated string + } + if (!empty($options['units'])) { + $query['units'] = $options['units']; + } + if (!empty($options['lang'])) { + $query['lang'] = $options['lang']; + } + + try { + $res = $this->http->request('GET', "{$this->baseUrl}/onecall", [ + 'query' => $query, + 'http_errors' => false, + 'timeout' => 8.0, + 'connect_timeout' => 5.0, + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException('Weather service is unavailable', 0, $e); + } + + $status = $res->getStatusCode(); + $body = (string) $res->getBody(); + + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid response from weather service'); + } + + if ($status >= 400) { + $message = $data['message'] ?? 'OpenWeather API error'; + throw new \RuntimeException($message); + } + + return $data; + } + + /** + * Geocode a city name to coordinates using OpenWeather Geocoding API. + * + * @param string $city + * @return array{lat: float, lon: float} + */ + public function geocodeCity(string $city): array + { + $query = [ + 'q' => $city, + 'limit' => 1, + 'appid' => $this->apiKey, + ]; + + try { + $res = $this->http->request('GET', 'https://api.openweathermap.org/geo/1.0/direct', [ + 'query' => $query, + 'http_errors' => false, + 'timeout' => 8.0, + 'connect_timeout' => 5.0, + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException('Geocoding service is unavailable', 0, $e); + } + + $status = $res->getStatusCode(); + $body = (string) $res->getBody(); + + $data = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException('Invalid response from geocoding service'); + } + + if ($status >= 400) { + $message = is_array($data) && isset($data['message']) ? $data['message'] : 'Geocoding API error'; + throw new \RuntimeException($message); + } + + if (!is_array($data) || count($data) === 0 || !isset($data[0]['lat'], $data[0]['lon'])) { + throw new \RuntimeException('City not found'); + } + + return [ + 'lat' => (float) $data[0]['lat'], + 'lon' => (float) $data[0]['lon'], + ]; + } + + /** + * Convenience method: One Call by city name. + * + * @param string $city + * @param array{ + * exclude?: string, + * units?: 'standard'|'metric'|'imperial', + * lang?: string + * } $options + * @return array + */ + public function oneCallByCity(string $city, array $options = []): array + { + $coords = $this->geocodeCity($city); + return $this->oneCall($coords['lat'], $coords['lon'], $options); + } +}