From 62d76fe31977511d58e77b1b2b6e647643218c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 09:59:57 +0100 Subject: [PATCH 001/113] docs: add project development guidance --- .gitignore | 3 ++- AGENTS.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 6239dbb..f88b923 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ /vendor/ /logs/ /.idea -/index.php \ No newline at end of file +/index.php +/plans/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ff4cad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Project Instructions + +## Project Overview + +This repository contains a PHP library for accessing OpenWeather APIs. It uses +Composer with PSR-4 autoloading under the +`ProgrammatorDev\OpenWeatherMap` namespace and is built on +`programmatordev/php-api-sdk`. + +## Sources Of Truth + +- Use the official OpenWeather documentation for endpoint paths, parameters, + response fields, availability, and subscription constraints. +- Use the installed PHP API SDK documentation and source for its supported + authoring patterns. +- Read existing resources, entities, tests, and documentation before changing + related behavior. +- Do not infer API availability from OpenWeather documentation sidebars; verify + it against the current official API catalog or pricing information. + +## Code Changes + +- Prefer focused changes that follow the architecture and naming conventions + documented for the active target version. Do not preserve legacy patterns + when the active public contract intentionally replaces them. +- Reuse shared entities, helpers, resource behavior, test utilities, and + constants when they fit. +- Keep endpoint construction in resource classes and response mapping in typed + entity or response classes. +- Keep request-local fluent options immutable so they do not affect later + resource calls. +- Treat official documentation and representative response fixtures as + complementary schema evidence; neither source is exhaustive on its own. +- Tolerate missing, explicitly `null`, conditional, and unknown response fields. + Reject known non-null fields with invalid types through descriptive hydration + errors rather than silent coercion. +- Represent returned timestamps as nullable UTC `DateTimeImmutable` values and + keep location timezone identifiers or offsets as separate metadata. +- Make destructive operations explicit in method naming and documentation. +- Do not expose API keys through exceptions, logs, fixtures, or committed + example files. + +## Dependencies And Tooling + +- Run project PHP and Composer commands through DDEV. +- Respect the PHP versions declared by `composer.json` and CI. +- Remove a dependency only when its remaining usages have been eliminated. +- Do not introduce a formatter, static analyzer, or new test framework without + explicit approval. + +## Testing + +- Use PHPUnit and the existing PSR-18 mock-client approach. +- Do not make live OpenWeather requests in the automated test suite. +- Build automated response tests from sanitized real API captures, supplemented + by synthetic edge-case fixtures. Keep credentials and private or + account-specific data out of committed fixtures. +- Test endpoint method, URL, path parameters, query parameters, headers, body, + response mapping, error mapping, and immutable resource-chain behavior. +- Add focused entity tests for present, missing, explicitly `null`, + conditionally present, unknown, invalidly typed, and nested fields. +- Cover empty-body responses for successful write and delete operations. +- Run the full test suite before handing off an implementation batch when + practical. + +## Documentation + +- Update public documentation alongside implemented API areas. +- Keep method signatures, examples, supported endpoints, and response entities + aligned with the implementation. +- Clearly distinguish standard free-plan APIs from APIs requiring separate or + paid subscriptions. +- Document potentially billable or destructive behavior prominently. From 068d0291f5adef2197e7b1f9e1006205d1f66396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 12:49:07 +0100 Subject: [PATCH 002/113] feat(core): add foundational API value types --- AGENTS.md | 3 ++ src/Enum/Language.php | 59 +++++++++++++++++++++ src/Enum/Unit.php | 24 +++++++++ src/Enum/Units.php | 27 ++++++++++ src/Value/Coordinates.php | 38 ++++++++++++++ tests/Unit/Enum/LanguageTest.php | 73 ++++++++++++++++++++++++++ tests/Unit/Enum/UnitsTest.php | 73 ++++++++++++++++++++++++++ tests/Unit/Value/CoordinatesTest.php | 78 ++++++++++++++++++++++++++++ 8 files changed, 375 insertions(+) create mode 100644 src/Enum/Language.php create mode 100644 src/Enum/Unit.php create mode 100644 src/Enum/Units.php create mode 100644 src/Value/Coordinates.php create mode 100644 tests/Unit/Enum/LanguageTest.php create mode 100644 tests/Unit/Enum/UnitsTest.php create mode 100644 tests/Unit/Value/CoordinatesTest.php diff --git a/AGENTS.md b/AGENTS.md index 9ff4cad..e983070 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,9 @@ Composer with PSR-4 autoloading under the - Prefer focused changes that follow the architecture and naming conventions documented for the active target version. Do not preserve legacy patterns when the active public contract intentionally replaces them. +- Organize source files by clear responsibility or domain and mirror that + structure in tests. Keep the root namespace focused on primary entry points; + avoid both unrelated root-level classes and arbitrary one-file folders. - Reuse shared entities, helpers, resource behavior, test utilities, and constants when they fit. - Keep endpoint construction in resource classes and response mapping in typed diff --git a/src/Enum/Language.php b/src/Enum/Language.php new file mode 100644 index 0000000..2418821 --- /dev/null +++ b/src/Enum/Language.php @@ -0,0 +1,59 @@ +value; + } +} diff --git a/src/Enum/Units.php b/src/Enum/Units.php new file mode 100644 index 0000000..1bd2304 --- /dev/null +++ b/src/Enum/Units.php @@ -0,0 +1,27 @@ + Unit::KELVIN, + self::METRIC => Unit::CELSIUS, + self::IMPERIAL => Unit::FAHRENHEIT, + }; + } + + public function speedUnit(): Unit + { + return match ($this) { + self::STANDARD, self::METRIC => Unit::METERS_PER_SECOND, + self::IMPERIAL => Unit::MILES_PER_HOUR, + }; + } +} diff --git a/src/Value/Coordinates.php b/src/Value/Coordinates.php new file mode 100644 index 0000000..b666f5d --- /dev/null +++ b/src/Value/Coordinates.php @@ -0,0 +1,38 @@ + 90) { + throw new \InvalidArgumentException( + 'Latitude must be a finite number between -90 and 90.', + ); + } + + if (!is_finite($longitude) || $longitude < -180 || $longitude > 180) { + throw new \InvalidArgumentException( + 'Longitude must be a finite number between -180 and 180.', + ); + } + } + + public static function from(float $latitude, float $longitude): self + { + return new self($latitude, $longitude); + } + + public function latitude(): float + { + return $this->latitude; + } + + public function longitude(): float + { + return $this->longitude; + } +} diff --git a/tests/Unit/Enum/LanguageTest.php b/tests/Unit/Enum/LanguageTest.php new file mode 100644 index 0000000..6bdb47e --- /dev/null +++ b/tests/Unit/Enum/LanguageTest.php @@ -0,0 +1,73 @@ +name] = $language->value; + } + + self::assertSame([ + 'AFRIKAANS' => 'af', + 'ALBANIAN' => 'sq', + 'ARABIC' => 'ar', + 'AZERBAIJANI' => 'az', + 'BASQUE' => 'eu', + 'BELARUSIAN' => 'be', + 'BULGARIAN' => 'bg', + 'CATALAN' => 'ca', + 'CHINESE_SIMPLIFIED' => 'zh_cn', + 'CHINESE_TRADITIONAL' => 'zh_tw', + 'CROATIAN' => 'hr', + 'CZECH' => 'cz', + 'DANISH' => 'da', + 'DUTCH' => 'nl', + 'ENGLISH' => 'en', + 'FINNISH' => 'fi', + 'FRENCH' => 'fr', + 'GALICIAN' => 'gl', + 'GERMAN' => 'de', + 'GREEK' => 'el', + 'HEBREW' => 'he', + 'HINDI' => 'hi', + 'HUNGARIAN' => 'hu', + 'ICELANDIC' => 'is', + 'INDONESIAN' => 'id', + 'ITALIAN' => 'it', + 'JAPANESE' => 'ja', + 'KOREAN' => 'kr', + 'KURMANJI' => 'ku', + 'LATVIAN' => 'la', + 'LITHUANIAN' => 'lt', + 'MACEDONIAN' => 'mk', + 'NORWEGIAN' => 'no', + 'PERSIAN_FARSI' => 'fa', + 'POLISH' => 'pl', + 'PORTUGUESE' => 'pt', + 'PORTUGUESE_BRAZIL' => 'pt_br', + 'ROMANIAN' => 'ro', + 'RUSSIAN' => 'ru', + 'SERBIAN' => 'sr', + 'SLOVAK' => 'sk', + 'SLOVENIAN' => 'sl', + 'SPANISH' => 'es', + 'SPANISH_SP' => 'sp', + 'SWEDISH' => 'sv', + 'SWEDISH_SE' => 'se', + 'THAI' => 'th', + 'TURKISH' => 'tr', + 'UKRAINIAN' => 'uk', + 'UKRAINIAN_UA' => 'ua', + 'VIETNAMESE' => 'vi', + 'ZULU' => 'zu', + ], $actual); + } +} diff --git a/tests/Unit/Enum/UnitsTest.php b/tests/Unit/Enum/UnitsTest.php new file mode 100644 index 0000000..1c40081 --- /dev/null +++ b/tests/Unit/Enum/UnitsTest.php @@ -0,0 +1,73 @@ +temperatureUnit()); + self::assertSame($expected[1], $units->speedUnit()); + } + + /** + * @return iterable + */ + public static function unitMappings(): iterable + { + yield 'standard' => [ + Units::STANDARD, + [Unit::KELVIN, Unit::METERS_PER_SECOND], + ]; + yield 'metric' => [ + Units::METRIC, + [Unit::CELSIUS, Unit::METERS_PER_SECOND], + ]; + yield 'imperial' => [ + Units::IMPERIAL, + [Unit::FAHRENHEIT, Unit::MILES_PER_HOUR], + ]; + } + + public function testItUsesOpenWeatherQueryValues(): void + { + self::assertSame('standard', Units::STANDARD->value); + self::assertSame('metric', Units::METRIC->value); + self::assertSame('imperial', Units::IMPERIAL->value); + } + + public function testMeasurementUnitsExposeDisplaySymbols(): void + { + self::assertSame([ + 'KELVIN' => 'K', + 'CELSIUS' => '°C', + 'FAHRENHEIT' => '°F', + 'METERS_PER_SECOND' => 'm/s', + 'MILES_PER_HOUR' => 'mph', + 'HECTOPASCAL' => 'hPa', + 'PERCENT' => '%', + 'METER' => 'm', + 'DEGREE' => '°', + 'MILLIMETER' => 'mm', + 'MILLIMETERS_PER_HOUR' => 'mm/h', + 'MICROGRAMS_PER_CUBIC_METER' => 'µg/m³', + ], array_combine( + array_column(Unit::cases(), 'name'), + array_map( + static fn (Unit $unit): string => $unit->symbol(), + Unit::cases(), + ), + )); + } +} diff --git a/tests/Unit/Value/CoordinatesTest.php b/tests/Unit/Value/CoordinatesTest.php new file mode 100644 index 0000000..428ee67 --- /dev/null +++ b/tests/Unit/Value/CoordinatesTest.php @@ -0,0 +1,78 @@ +latitude()); + self::assertSame(-9.1366, $coordinates->longitude()); + } + + public function testItAcceptsCoordinateBoundaries(): void + { + $minimum = Coordinates::from(latitude: -90, longitude: -180); + $maximum = Coordinates::from(latitude: 90, longitude: 180); + + self::assertSame(-90.0, $minimum->latitude()); + self::assertSame(-180.0, $minimum->longitude()); + self::assertSame(90.0, $maximum->latitude()); + self::assertSame(180.0, $maximum->longitude()); + } + + #[DataProvider('invalidLatitudes')] + public function testItRejectsInvalidLatitudes(float $latitude): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Latitude must be a finite number between -90 and 90.', + ); + + Coordinates::from($latitude, 0); + } + + /** + * @return iterable + */ + public static function invalidLatitudes(): iterable + { + yield 'below minimum' => [-90.0001]; + yield 'above maximum' => [90.0001]; + yield 'negative infinity' => [-INF]; + yield 'positive infinity' => [INF]; + yield 'not a number' => [NAN]; + } + + #[DataProvider('invalidLongitudes')] + public function testItRejectsInvalidLongitudes(float $longitude): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Longitude must be a finite number between -180 and 180.', + ); + + Coordinates::from(0, $longitude); + } + + /** + * @return iterable + */ + public static function invalidLongitudes(): iterable + { + yield 'below minimum' => [-180.0001]; + yield 'above maximum' => [180.0001]; + yield 'negative infinity' => [-INF]; + yield 'positive infinity' => [INF]; + yield 'not a number' => [NAN]; + } +} From 5962ca038c7a2ba8aff8c48a9b3b039f9bc44298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 12:57:12 +0100 Subject: [PATCH 003/113] refactor!: establish clean SDK 3 baseline --- README.md | 51 +-- composer.json | 4 +- docs/01-usage.md | 39 -- docs/02-configuration.md | 160 --------- docs/03-supported-apis.md | 311 ---------------- docs/04-error-handling.md | 62 ---- docs/05-entities.md | 340 ------------------ src/Entity/AirPollution/AirPollution.php | 22 -- .../AirPollution/AirPollutionCollection.php | 38 -- src/Entity/AirPollution/AirPollutionData.php | 93 ----- src/Entity/AirPollution/AirQuality.php | 39 -- src/Entity/Assistant/Answer.php | 40 --- src/Entity/Assistant/WeatherData.php | 62 ---- src/Entity/BaseWeather.php | 102 ------ src/Entity/Condition.php | 92 ----- src/Entity/Coordinate.php | 26 -- src/Entity/Geocoding/ZipLocation.php | 48 --- src/Entity/Icon.php | 26 -- src/Entity/Location.php | 133 ------- src/Entity/OneCall/Alert.php | 58 --- src/Entity/OneCall/DayData.php | 98 ----- src/Entity/OneCall/HourData.php | 46 --- src/Entity/OneCall/MinuteData.php | 29 -- src/Entity/OneCall/MoonPhase.php | 57 --- src/Entity/OneCall/Temperature.php | 58 --- src/Entity/OneCall/Weather.php | 89 ----- src/Entity/OneCall/WeatherData.php | 66 ---- src/Entity/OneCall/WeatherMoment.php | 38 -- src/Entity/OneCall/WeatherOverview.php | 53 --- src/Entity/OneCall/WeatherSummary.php | 107 ------ src/Entity/Timezone.php | 26 -- src/Entity/Weather/Weather.php | 31 -- src/Entity/Weather/WeatherCollection.php | 50 --- src/Entity/Weather/WeatherData.php | 143 -------- src/Entity/Wind.php | 37 -- src/Exception/ApiErrorException.php | 22 -- src/Exception/BadRequestException.php | 5 - src/Exception/NotFoundException.php | 5 - src/Exception/TooManyRequestsException.php | 5 - src/Exception/UnauthorizedException.php | 5 - src/Exception/UnexpectedErrorException.php | 5 - src/Helper/EntityHelper.php | 20 -- src/Helper/ReflectionHelper.php | 18 - src/Language/Language.php | 60 ---- src/OpenWeatherMap.php | 115 ------ src/Resource/AirPollutionResource.php | 77 ---- src/Resource/AssistantResource.php | 49 --- src/Resource/GeocodingResource.php | 73 ---- src/Resource/OneCallResource.php | 101 ------ src/Resource/Resource.php | 13 - src/Resource/Util/CacheTrait.php | 16 - src/Resource/Util/LanguageTrait.php | 16 - src/Resource/Util/UnitSystemTrait.php | 16 - src/Resource/WeatherResource.php | 57 --- src/Test/AbstractTest.php | 27 -- src/Test/MockResponse.php | 27 -- src/Test/Util/TestCollectionResponseTrait.php | 29 -- src/Test/Util/TestItemResponseTrait.php | 29 -- src/UnitSystem/Fahrenheit.php | 16 - src/UnitSystem/Imperial.php | 16 - src/UnitSystem/Standard.php | 16 - src/UnitSystem/UnitSystem.php | 17 - .../Integration/AirPollutionResourceTest.php | 39 -- tests/Integration/AssistantResourceTest.php | 31 -- tests/Integration/CacheTraitTest.php | 40 --- tests/Integration/GeocodingResourceTest.php | 45 --- tests/Integration/LanguageTraitTest.php | 33 -- tests/Integration/OneCallResourceTest.php | 48 --- tests/Integration/OpenWeatherMapTest.php | 20 -- tests/Integration/ResourceTest.php | 56 --- tests/Integration/UnitSystemTraitTest.php | 33 -- tests/Integration/WeatherResourceTest.php | 32 -- .../AirPollutionCollectionTest.php | 43 --- .../AirPollution/AirPollutionDataTest.php | 41 --- tests/Unit/AirPollution/AirPollutionTest.php | 51 --- tests/Unit/AirPollution/AirQualityTest.php | 19 - tests/Unit/Assistant/AnswerTest.php | 48 --- tests/Unit/Assistant/WeatherDataTest.php | 54 --- tests/Unit/ConditionTest.php | 26 -- tests/Unit/CoordinateTest.php | 20 -- tests/Unit/Geocoding/ZipLocationTest.php | 26 -- tests/Unit/IconTest.php | 19 - tests/Unit/LocationTest.php | 45 --- tests/Unit/OneCall/AlertTest.php | 28 -- tests/Unit/OneCall/DayDataTest.php | 79 ---- tests/Unit/OneCall/HourDataTest.php | 55 --- tests/Unit/OneCall/MinuteDataTest.php | 20 -- tests/Unit/OneCall/MoonPhaseTest.php | 20 -- tests/Unit/OneCall/TemperatureTest.php | 28 -- tests/Unit/OneCall/WeatherDataTest.php | 57 --- tests/Unit/OneCall/WeatherMomentTest.php | 69 ---- tests/Unit/OneCall/WeatherOverviewTest.php | 27 -- tests/Unit/OneCall/WeatherSummaryTest.php | 59 --- tests/Unit/OneCall/WeatherTest.php | 147 -------- tests/Unit/TimezoneTest.php | 20 -- tests/Unit/Weather/WeatherCollectionTest.php | 72 ---- tests/Unit/Weather/WeatherDataTest.php | 65 ---- tests/Unit/Weather/WeatherTest.php | 79 ---- tests/Unit/WindTest.php | 22 -- 99 files changed, 6 insertions(+), 5139 deletions(-) delete mode 100644 docs/01-usage.md delete mode 100644 docs/02-configuration.md delete mode 100644 docs/03-supported-apis.md delete mode 100644 docs/04-error-handling.md delete mode 100644 docs/05-entities.md delete mode 100644 src/Entity/AirPollution/AirPollution.php delete mode 100644 src/Entity/AirPollution/AirPollutionCollection.php delete mode 100644 src/Entity/AirPollution/AirPollutionData.php delete mode 100644 src/Entity/AirPollution/AirQuality.php delete mode 100644 src/Entity/Assistant/Answer.php delete mode 100644 src/Entity/Assistant/WeatherData.php delete mode 100644 src/Entity/BaseWeather.php delete mode 100644 src/Entity/Condition.php delete mode 100644 src/Entity/Coordinate.php delete mode 100644 src/Entity/Geocoding/ZipLocation.php delete mode 100644 src/Entity/Icon.php delete mode 100644 src/Entity/Location.php delete mode 100644 src/Entity/OneCall/Alert.php delete mode 100644 src/Entity/OneCall/DayData.php delete mode 100644 src/Entity/OneCall/HourData.php delete mode 100644 src/Entity/OneCall/MinuteData.php delete mode 100644 src/Entity/OneCall/MoonPhase.php delete mode 100644 src/Entity/OneCall/Temperature.php delete mode 100644 src/Entity/OneCall/Weather.php delete mode 100644 src/Entity/OneCall/WeatherData.php delete mode 100644 src/Entity/OneCall/WeatherMoment.php delete mode 100644 src/Entity/OneCall/WeatherOverview.php delete mode 100644 src/Entity/OneCall/WeatherSummary.php delete mode 100644 src/Entity/Timezone.php delete mode 100644 src/Entity/Weather/Weather.php delete mode 100644 src/Entity/Weather/WeatherCollection.php delete mode 100644 src/Entity/Weather/WeatherData.php delete mode 100644 src/Entity/Wind.php delete mode 100644 src/Exception/ApiErrorException.php delete mode 100644 src/Exception/BadRequestException.php delete mode 100644 src/Exception/NotFoundException.php delete mode 100644 src/Exception/TooManyRequestsException.php delete mode 100644 src/Exception/UnauthorizedException.php delete mode 100644 src/Exception/UnexpectedErrorException.php delete mode 100644 src/Helper/EntityHelper.php delete mode 100644 src/Helper/ReflectionHelper.php delete mode 100644 src/Language/Language.php delete mode 100644 src/OpenWeatherMap.php delete mode 100644 src/Resource/AirPollutionResource.php delete mode 100644 src/Resource/AssistantResource.php delete mode 100644 src/Resource/GeocodingResource.php delete mode 100644 src/Resource/OneCallResource.php delete mode 100644 src/Resource/Resource.php delete mode 100644 src/Resource/Util/CacheTrait.php delete mode 100644 src/Resource/Util/LanguageTrait.php delete mode 100644 src/Resource/Util/UnitSystemTrait.php delete mode 100644 src/Resource/WeatherResource.php delete mode 100644 src/Test/AbstractTest.php delete mode 100644 src/Test/MockResponse.php delete mode 100644 src/Test/Util/TestCollectionResponseTrait.php delete mode 100644 src/Test/Util/TestItemResponseTrait.php delete mode 100644 src/UnitSystem/Fahrenheit.php delete mode 100644 src/UnitSystem/Imperial.php delete mode 100644 src/UnitSystem/Standard.php delete mode 100644 src/UnitSystem/UnitSystem.php delete mode 100644 tests/Integration/AirPollutionResourceTest.php delete mode 100644 tests/Integration/AssistantResourceTest.php delete mode 100644 tests/Integration/CacheTraitTest.php delete mode 100644 tests/Integration/GeocodingResourceTest.php delete mode 100644 tests/Integration/LanguageTraitTest.php delete mode 100644 tests/Integration/OneCallResourceTest.php delete mode 100644 tests/Integration/OpenWeatherMapTest.php delete mode 100644 tests/Integration/ResourceTest.php delete mode 100644 tests/Integration/UnitSystemTraitTest.php delete mode 100644 tests/Integration/WeatherResourceTest.php delete mode 100644 tests/Unit/AirPollution/AirPollutionCollectionTest.php delete mode 100644 tests/Unit/AirPollution/AirPollutionDataTest.php delete mode 100644 tests/Unit/AirPollution/AirPollutionTest.php delete mode 100644 tests/Unit/AirPollution/AirQualityTest.php delete mode 100644 tests/Unit/Assistant/AnswerTest.php delete mode 100644 tests/Unit/Assistant/WeatherDataTest.php delete mode 100644 tests/Unit/ConditionTest.php delete mode 100644 tests/Unit/CoordinateTest.php delete mode 100644 tests/Unit/Geocoding/ZipLocationTest.php delete mode 100644 tests/Unit/IconTest.php delete mode 100644 tests/Unit/LocationTest.php delete mode 100644 tests/Unit/OneCall/AlertTest.php delete mode 100644 tests/Unit/OneCall/DayDataTest.php delete mode 100644 tests/Unit/OneCall/HourDataTest.php delete mode 100644 tests/Unit/OneCall/MinuteDataTest.php delete mode 100644 tests/Unit/OneCall/MoonPhaseTest.php delete mode 100644 tests/Unit/OneCall/TemperatureTest.php delete mode 100644 tests/Unit/OneCall/WeatherDataTest.php delete mode 100644 tests/Unit/OneCall/WeatherMomentTest.php delete mode 100644 tests/Unit/OneCall/WeatherOverviewTest.php delete mode 100644 tests/Unit/OneCall/WeatherSummaryTest.php delete mode 100644 tests/Unit/OneCall/WeatherTest.php delete mode 100644 tests/Unit/TimezoneTest.php delete mode 100644 tests/Unit/Weather/WeatherCollectionTest.php delete mode 100644 tests/Unit/Weather/WeatherDataTest.php delete mode 100644 tests/Unit/Weather/WeatherTest.php delete mode 100644 tests/Unit/WindTest.php diff --git a/README.md b/README.md index d54fbd1..757d441 100644 --- a/README.md +++ b/README.md @@ -4,57 +4,16 @@ [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Tests](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml?query=branch%3Amain) -OpenWeatherMap PHP library that provides convenient access to the OpenWeatherMap API. +OpenWeather PHP library built on +[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk). -Supports [PSR-18 HTTP clients](https://www.php-fig.org/psr/psr-18), [PSR-17 HTTP factories](https://www.php-fig.org/psr/psr-17), [PSR-6 caches](https://www.php-fig.org/psr/psr-6) and [PSR-3 logs](https://www.php-fig.org/psr/psr-3). +Version 4 is currently under development as a complete, breaking rewrite. The +legacy API has been removed and the new public API is not ready for use yet. ## Requirements - PHP 8.1 or higher. -## API Key - -A key is required to be able to make requests to the API. -You must sign up for an [OpenWeatherMap account](https://openweathermap.org/appid#signup) to get one. - -## Installation - -Install the library via [Composer](https://getcomposer.org/): - -```bash -composer require programmatordev/openweathermap-php-api -``` - -## Basic Usage - -Simple usage looks like: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -// initialize -$api = new OpenWeatherMap('yourapikey'); - -// get current weather by coordinate (latitude, longitude) -$weather = $api->weather()->getCurrent(50, 50); -// show current temperature -echo $weather->getTemperature(); -``` - -## Documentation - -- [Usage](docs/01-usage.md) -- [Configuration](docs/02-configuration.md) -- [Supported APIs](docs/03-supported-apis.md) -- [Error Handling](docs/04-error-handling.md) -- [Entities](docs/05-entities.md) - -## Contributing - -Any form of contribution to improve this library (including requests) will be welcome and appreciated. -Make sure to open a pull request or issue. - ## License -This project is licensed under the MIT license. -Please see the [LICENSE](LICENSE) file distributed with this source code for further information regarding copyright and licensing. \ No newline at end of file +This project is licensed under the [MIT License](LICENSE). diff --git a/composer.json b/composer.json index 1a8fe88..284b3bb 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,7 @@ ], "require": { "php": ">=8.1", - "myclabs/deep-copy": "^1.13", - "programmatordev/php-api-sdk": "^2.1", - "symfony/options-resolver": "^6.4|^7.4|^8.0" + "programmatordev/php-api-sdk": "^3.0" }, "require-dev": { "monolog/monolog": "^3.10", diff --git a/docs/01-usage.md b/docs/01-usage.md deleted file mode 100644 index 513bacb..0000000 --- a/docs/01-usage.md +++ /dev/null @@ -1,39 +0,0 @@ -# Using OpenWeatherMap PHP API - -- [Requirements](#requirements) -- [API Key](#api-key) -- [Installation](#installation) -- [Basic Usage](#basic-usage) - -## Requirements - -- PHP 8.1 or higher. - -## API Key - -A key is required to be able to make requests to the API. -You must sign up for an [OpenWeatherMap account](https://openweathermap.org/appid#signup) to get one. - -## Installation - -Install the library via [Composer](https://getcomposer.org/): - -```bash -composer require programmatordev/openweathermap-php-api -``` - -## Basic Usage - -Simple usage looks like: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -// initialize -$api = new OpenWeatherMap('yourapikey'); - -// get current weather by coordinate (latitude, longitude) -$weather = $api->weather()->getCurrent(50, 50); -// show current temperature -echo $weather->getTemperature(); -``` \ No newline at end of file diff --git a/docs/02-configuration.md b/docs/02-configuration.md deleted file mode 100644 index 691a74b..0000000 --- a/docs/02-configuration.md +++ /dev/null @@ -1,160 +0,0 @@ -# Configuration - -- [Default Configuration](#default-configuration) -- [Options](#options) - - [unitSystem](#unitsystem) - - [language](#language) -- [Methods](#methods) - - [setClientBuilder](#setclientbuilder) - - [setCacheBuilder](#setcachebuilder) - - [setLoggerBuilder](#setloggerbuilder) - -## Default Configuration - -```php -OpenWeatherMap(string $apiKey, array $options => []); -``` - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'unitSystem' => 'metric', - 'language' => 'en' -]); -``` - -## Options - -### `unitSystem` - -Unit system used when retrieving data. -Affects temperature and speed values. - -Available options: -- `metric` -- `imperial` -- `standard` - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\UnitSystem\UnitSystem; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'unitSystem' => UnitSystem::IMPERIAL -]); -``` - -### `language` - -Language used when retrieving data. -It seems to only affect weather conditions descriptions. - -List of all available languages can be found [here](https://openweathermap.org/api/one-call-3#multi). - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\Language\Language; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey', [ - 'language' => Language::PORTUGUESE -]); -``` - -## Methods - -> [!IMPORTANT] -> The [PHP API SDK](https://github.com/programmatordev/php-api-sdk) library was used to create the OpenWeatherMap PHP API. -> To get to know about all the available methods, make sure to check the documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#documentation). - -The following sections have examples of some of the most important methods, -particularly related to the configuration of the client, cache and logger. - -### `setClientBuilder` - -By default, this library makes use of the [HTTPlug's Discovery](https://github.com/php-http/discovery) library. -This means that it will automatically find and install a well-known PSR-18 client and PSR-17 factory implementation for you -(if they were not found on your project): -- [PSR-18 compatible implementations](https://packagist.org/providers/psr/http-client-implementation) -- [PSR-17 compatible implementations](https://packagist.org/providers/psr/http-factory-implementation) - -If you don't want to rely on the discovery of implementations, you can set the ones you want: - -```php -use Nyholm\Psr7\Factory\Psr17Factory; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -use Symfony\Component\HttpClient\Psr18Client; - -$api = new OpenWeatherMap('yourapikey'); - -$client = new Psr18Client(); -$requestFactory = $streamFactory = new Psr17Factory(); - -$api->setClientBuilder( - new ClientBuilder( - client: $client, - requestFactory: $requestFactory, - streamFactory: $streamFactory - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#http-client-psr-18-and-http-factories-psr-17). - -### `setCacheBuilder` - -This library allows configuring the cache layer of the client for making API requests. -It uses a standard PSR-6 implementation and provides methods to fine-tune how HTTP caching behaves: -- [PSR-6 compatible implementations](https://packagist.org/providers/psr/cache-implementation) - -Example: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -$api = new OpenWeatherMap('yourapikey'); - -$pool = new FilesystemAdapter(); - -// set a file-based cache adapter with a 1-hour default cache lifetime -$api->setCacheBuilder( - new CacheBuilder( - pool: $pool, - ttl: 3600 - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#cache-psr-6). - -### `setLoggerBuilder` - -This library allows configuring a logger to save data for making API requests. -It uses a standard PSR-3 implementation and provides methods to fine-tune how logging behaves: -- [PSR-3 compatible implementations](https://packagist.org/providers/psr/log-implementation) - -Example: - -```php -use Monolog\Logger; -use Monolog\Handler\StreamHandler; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap('yourapikey'); - -$logger = new Logger('api'); -$logger->pushHandler(new StreamHandler('/logs/api.log')); - -$api->setLoggerBuilder( - new LoggerBuilder( - logger: $logger - ) -); -``` - -Check the full documentation [here](https://github.com/programmatordev/php-api-sdk?tab=readme-ov-file#logger-psr-3). \ No newline at end of file diff --git a/docs/03-supported-apis.md b/docs/03-supported-apis.md deleted file mode 100644 index a4de66a..0000000 --- a/docs/03-supported-apis.md +++ /dev/null @@ -1,311 +0,0 @@ -# Supported APIs - -- [APIs](#apis) - - [One Call](#one-call) - - [getWeather](#getweather) - - [getWeatherByDate](#getweatherbydate) - - [getWeatherSummaryByDate](#getweathersummarybydate) - - [getWeatherOverviewByDate](#getweatheroverviewbydate) - - [AI Assistant](#ai-assistant) - - [startSession](#startsession) - - [resumeSession](#resumesession) - - [Weather](#weather) - - [getCurrent](#getcurrent) - - [getForecast](#getforecast) - - [Air Pollution](#air-pollution) - - [getCurrent](#getcurrent-1) - - [getForecast](#getforecast-1) - - [getHistory](#gethistory) - - [Geocoding](#geocoding) - - [getByLocationName](#getbylocationname) - - [getByCoordinate](#getbycoordinate) - - [getByZipCode](#getbyzipcode) -- [Common Methods](#common-methods) - - [withUnitSystem](#withunitsystem) - - [withLanguage](#withlanguage) - - [withCacheTtl](#withcachettl) - -## APIs - -### One Call - -#### `getWeather` - -```php -getWeather(float $latitude, float $longitude): Weather -``` - -Get access to current weather, minute forecast for 1 hour, hourly forecast for 48 hours, -daily forecast for 8 days and government weather alerts. - -Returns a [`Weather`](05-entities.md#weather) object: - -```php -$weather = $api->oneCall()->getWeather(50, 50); -``` - -#### `getWeatherByDate` - -```php -getWeatherByDate(float $latitude, float $longitude, \DateTimeInterface $dateTime): WeatherMoment -``` - -Get access to weather data for any datetime. - -Returns a [`WeatherMoment`](05-entities.md#weathermoment) object: - -```php -$weather = $api->oneCall()->getWeatherByDate(50, 50, new \DateTime('2023-05-13 16:32:00')); -``` - -#### `getWeatherSummaryByDate` - -```php -getWeatherSummaryByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherSummary -``` - -Get access to aggregated weather data for a particular date. - -Returns a [`WeatherSummary`](05-entities.md#weathersummary) object: - -```php -$weatherSummary = $api->oneCall()->getWeatherSummaryByDate(50, 50, new \DateTime('1985-07-19')); -``` - -#### `getWeatherOverviewByDate` - -```php -getWeatherOverviewByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherOverview -``` - -Get the weather overview with a human-readable summary for today and tomorrow's forecast, -using OpenWeather AI. - -Returns a [`WeatherOverview`](05-entities.md#weatheroverview) object: - -```php -$weatherOverview = $api->oneCall()->getWeatherOverviewByDate(50, 50, new \DateTime('today')); -``` - -### AI Assistant - -#### `startSession` - -```php -startSession(string $prompt): Answer -``` - -Start a new session (create a new conversation) with the Weather AI Assistant. - -Returns a [`Answer`](05-entities.md#answer) object: - -```php -$answer = $api->assistant()->startSession('How is the weather today in Lisbon?'); -``` - -#### `resumeSession` - -```php -resumeSession(string $sessionId, string $prompt): Answer -``` - -Resume a session (continue a conversation) with the Weather AI Assistant. - -Returns a [`Answer`](05-entities.md#answer) object: - -```php -$answer = $api->assistant()->resumeSession('session-id', 'Do I need an umbrella?'); -``` - -### Weather - -#### `getCurrent` - -```php -getCurrent(float $latitude, float $longitude): Weather -``` - -Get access to current weather data. - -Returns a [`Weather`](05-entities.md#weather-2) object: - -```php -$currentWeather = $api->weather()->getCurrent(50, 50); -``` - -#### `getForecast` - -```php -getForecast(float $latitude, float $longitude, int $numResults = 40): WeatherCollection -``` - -Get access to 5-day weather forecast data with 3-hour steps. - -Returns a [`WeatherCollection`](05-entities.md#weathercollection) object: - -```php -// Since it returns data in 3-hour steps, -// passing 8 as the numResults means it will return results for the next 24 hours -$weatherForecast = $api->weather()->getForecast(50, 50, 8); -``` - -### Air Pollution - -#### `getCurrent` - -```php -getCurrent(float $latitude, float $longitude): AirPollution -``` - -Get access to current air pollution data. - -Returns a [`AirPollution`](05-entities.md#airpollution) object: - -```php -$currentAirPollution = $api->airPollution()->getCurrent(50, 50); -``` - -#### `getForecast` - -```php -getForecast(float $latitude, float $longitude): AirPollutionCollection -``` - -Get access to air pollution forecast data per hour. - -Returns a [`AirPollutionCollection`](05-entities.md#airpollutioncollection) object: - -```php -$airPollutionForecast = $api->airPollution()->getForecast(50, 50); -``` - -#### `getHistory` - -```php -getHistory(float $latitude, float $longitude, \DateTimeInterface $startDate, \DateTimeInterface $endDate): AirPollutionCollection -``` - -Get access to historical air pollution data per hour between two dates. - -Returns a [`AirPollutionCollection`](05-entities.md#airpollutioncollection) object: - -```php -$startDate = new \DateTime('-1 day'); -$endDate = new \DateTime('now'); - -// returns air pollution data for the last 24 hours -$airPollutionHistory = $api->airPollution()->getHistory(50, 50, $startDate, $endDate); -``` - -### Geocoding - -#### `getByLocationName` - -```php -/** - * @return Location[] - */ -getByLocationName(string $locationName, int $numResults = 5): array -``` - -Get geographical coordinates (latitude, longitude) by using the name of the location (city name or area name). - -Returns an array of [`Location`](05-entities.md#location) objects. - -```php -$locations = $api->geocoding()->getByLocationName('lisbon'); -``` - -#### `getByCoordinate` - -```php -/** - * @return Location[] - */ -getByCoordinate(float $latitude, float $longitude, int $numResults = 5): array -``` - -Get the name of the location (city name or area name) by using geographical coordinates (latitude, longitude). - -Returns an array of [`Location`](05-entities.md#location) objects. - -```php -$locations = $api->geocoding()->getByCoordinate(50, 50); -``` - -#### `getByZipCode` - -```php -getByZipCode(string $zipCode, string $countryCode): ZipLocation -``` - -Get geographical coordinates (latitude, longitude) by using the zip/postal code. - -Returns a [`ZipLocation`](05-entities.md#ziplocation) object. - -```php -$location = $api->geocoding()->getByZipCode('1000-001', 'pt'); -``` - -## Common Methods - -#### `withLanguage` - -```php -withLanguage(string $language): self -``` - -Set the language per request. -Only available for [`OneCall`](#one-call) and [`Weather`](#weather) API requests. - -```php -use ProgrammatorDev\OpenWeatherMap\Language\Language - -// uses the "pt" language for this request alone -$api->weather() - ->withLanguage(Language::PORTUGUESE) - ->getCurrent(50, 50); -``` - -#### `withUnitSystem` - -```php -withUnitSystem(string $unitSystem): self -``` - -Set the unit system per request. -Only available for [`OneCall`](#one-call) and [`Weather`](#weather) API requests. - -```php -use ProgrammatorDev\OpenWeatherMap\UnitSystem\UnitSystem; - -// uses the "imperial" unit system for this request alone -$api->weather() - ->withUnitSystem(UnitSystem::IMPERIAL) - ->getCurrent(50, 50); -``` - -#### `withCacheTtl` - -```php -withCacheTtl(?int $ttl): self -``` - -Makes a request and saves into cache for the provided duration in seconds. - -Semantics of values: -- `0`, the response will not be cached (if the server specifies no `max-age`). -- `null`, the response will be cached for as long as it can (forever). - -> [!NOTE] -> Setting cache to `null` or `0` seconds will **not** invalidate any existing cache. - -Available for all APIs if a cache adapter is set. -Check the following [documentation](02-configuration.md#setcachebuilder) for more information. - -```php -// cache will be saved for 1 hour for this request alone -$api->weather() - ->withCacheTtl(3600) - ->getCurrent(50, 50); -``` \ No newline at end of file diff --git a/docs/04-error-handling.md b/docs/04-error-handling.md deleted file mode 100644 index b50e764..0000000 --- a/docs/04-error-handling.md +++ /dev/null @@ -1,62 +0,0 @@ -# Error Handling - -## API Errors - -To handle API response errors, multiple exceptions are provided. You can see all available in the following example: - -```php -use ProgrammatorDev\OpenWeatherMap\Exception\BadRequestException; -use ProgrammatorDev\OpenWeatherMap\Exception\NotFoundException; -use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; -use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; -use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; - -try { - // ... - - $weather = $api->oneCall()->getWeather($latitude, $longitude); -} -// bad request to the API -catch (BadRequestException $exception) { - echo $exception->getCode(); // 400 - echo $exception->getMessage(); -} -// invalid API key or trying to request an endpoint with no granted access -catch (UnauthorizedException $exception) { - echo $exception->getCode(); // 401 - echo $exception->getMessage(); -} -// resource not found, -// for example, when trying to get a location with a zip code that does not exist -catch (NotFoundException $exception) { - echo $exception->getCode(); // 404 - echo $exception->getMessage(); -} -// API key requests quota exceeded -catch (TooManyRequestsException $exception) { - echo $exception->getCode(); // 429 - echo $exception->getMessage(); -} -// any other error, probably an internal error -catch (UnexpectedErrorException $exception) { - echo $exception->getCode(); // 5xx - echo $exception->getMessage(); -} -``` - -To catch all API errors with a single exception, `ApiErrorException` is available: - -```php -use ProgrammatorDev\OpenWeatherMap\Exception\ApiErrorException; - -try { - // ... - - $weather = $api->oneCall()->getWeather($latitude, $longitude); -} -// catches all API response errors -catch (ApiErrorException $exception) { - echo $exception->getCode(); - echo $exception->getMessage(); -} -``` \ No newline at end of file diff --git a/docs/05-entities.md b/docs/05-entities.md deleted file mode 100644 index e8a691f..0000000 --- a/docs/05-entities.md +++ /dev/null @@ -1,340 +0,0 @@ -# Entities - -- [One Call](#one-call) - - [Weather](#weather) - - [WeatherMoment](#weathermoment) - - [WeatherSummary](#weathersummary) - - [WeatherOverview](#weatheroverview) - - [WeatherData](#weatherdata) - - [MinuteData](#minutedata) - - [HourData](#hourdata) - - [DayData](#daydata) - - [Alert](#alert) - - [MoonPhase](#moonphase) - - [Temperature](#temperature) -- [AI Assistant](#ai-assistant) - - [Answer](#answer) - - [WeatherData](#weatherdata-1) -- [Weather](#weather-1) - - [Weather](#weather-2) - - [WeatherCollection](#weathercollection) - - [WeatherData](#weatherdata-2) -- [Air Pollution](#air-pollution) - - [AirPollution](#airpollution) - - [AirPollutionCollection](#airpollutioncollection) - - [AirPollutionData](#airpollutiondata) - - [AirQuality](#airquality) -- [Geocoding](#geocoding) - - [ZipLocation](#ziplocation) -- [Common](#common) - - [Coordinate](#coordinate) - - [Condition](#condition) - - [Icon](#icon) - - [Location](#location) - - [Timezone](#timezone) - - [Wind](#wind) - -## One Call - -### Weather - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getCurrent()`: [`WeatherData`](#weatherdata) -- `getMinutelyForecast()`: [`?MinuteData[]`](#minutedata) -- `getHourlyForecast()`: [`HourData[]`](#hourdata) -- `getDailyForecast()`: [`DayData[]`](#daydata) -- `getAlerts()`: [`?Alert[]`](#alert) - -### WeatherMoment - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getSummary()`: `?string` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getMoonPhase()`: [`?MoonPhase`](#moonphase) -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` -- `getMoonriseAt()`: `?\DateTimeImmutable` -- `getMoonsetAt()`: `?\DateTimeImmutable` - -### WeatherSummary - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getCloudiness()`: `int` -- `getHumidity()`: `int` -- `getPrecipitation()`: `float` -- `getTemperature()`: [`Temperature`](#temperature) -- `getAtmosphericPressure()`: `int` -- `getWind()`: [`Wind`](#wind) - -### WeatherOverview - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getTimezone()`: [`Timezone`](#timezone) -- `getDateTime()`: `\DateTimeImmutable` -- `getOverview()`: `string` - -### WeatherData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getVisibility()`: `?int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -### MinuteData - -- `getDateTime()`: `\DateTimeImmutable` -- `getPrecipitation()`: `float` - -### HourData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getVisibility()`: `?int` -- `getPrecipitationProbability()`: `int` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -### DayData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: [`Temperature`](#temperature) -- `getTemperatureFeelsLike()`: [`Temperature`](#temperature) -- `getPrecipitationProbability()`: `int` -- `getAtmosphericPressure()`: `int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSummary()`: `string` -- `getMoonPhase()`: [`MoonPhase`](#moonphase) -- `getSunriseAt()`: `\DateTimeImmutable` -- `getSunsetAt()`: `\DateTimeImmutable` -- `getMoonriseAt()`: `\DateTimeImmutable` -- `getMoonsetAt()`: `\DateTimeImmutable` - -### Alert - -- `getSenderName()`: `string` -- `getEventName()`: `string` -- `getStartsAt()`: `\DateTimeImmutable` -- `getEndsAt()`: `\DateTimeImmutable` -- `getDescription()`: `string` -- `getTags()`: `array` - -### MoonPhase - -- `getValue()`: `float` -- `getName()`: `string` -- `getSystemName()`: `string` - -### Temperature - -- `getMorning()`: `float` -- `getDay()`: `float` -- `getEvening()`: `float` -- `getNight()`: `float` -- `getMin()`: `?float` -- `getMax()`: `?float` - -## AI Assistant - -### Answer - -- `getAnswer()`: `string` -- `getSessionId()`: `string` -- `getData()`: [`WeatherData[]`](#weatherdata-1) - -### WeatherData - -- `getLocationName()`: `string` -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getAtmosphericPressure()`: `int` -- `getVisibility()`: `?int` -- `getHumidity()`: `int` -- `getDewPoint()`: `float` -- `getUltraVioletIndex()`: `?float` -- `getCloudiness()`: `int` -- `getWind()`: [`Wind`](#wind) -- `getConditions()`: [`Condition[]`](#condition) -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -## Weather - -### Weather - -- `getLocation()`: [`Location`](#location) -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getMinTemperature()`: `float` -- `getMaxTemperature()`: `float` -- `getHumidity()`: `int` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getAtmosphericPressure()`: `int` -- `getConditions()`: [`Condition[]`](#condition) -- `getWind()`: [`Wind`](#wind) -- `getPrecipitationProbability()`: `?int` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -### WeatherCollection - -- `getNumResults()`: `int` -- `getLocation()`: [`Location`](#location) -- `getData()`: [`WeatherData[]`](#weatherdata-1) - -### WeatherData - -- `getDateTime()`: `\DateTimeImmutable` -- `getTemperature()`: `float` -- `getTemperatureFeelsLike()`: `float` -- `getMinTemperature()`: `float` -- `getMaxTemperature()`: `float` -- `getHumidity()`: `int` -- `getCloudiness()`: `int` -- `getVisibility()`: `?int` -- `getAtmosphericPressure()`: `int` -- `getConditions()`: [`Condition[]`](#condition) -- `getWind()`: [`Wind`](#wind) -- `getPrecipitationProbability()`: `?int` -- `getRainVolume()`: `?float` -- `getSnowVolume()`: `?float` - -## Air Pollution - -### AirPollution - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getDateTime()`: `\DateTimeImmutable` -- `getAirQuality`: [`AirQuality`](#airquality) -- `getCarbonMonoxide()`: `float` -- `getNitrogenMonoxide()`: `float` -- `getNitrogenDioxide()`: `float` -- `getOzone()`: `float` -- `getSulphurDioxide()`: `float` -- `getFineParticulateMatter()`: `float` -- `getCoarseParticulateMatter()`: `float` -- `getAmmonia()`: `float` - -### AirPollutionCollection - -- `getNumResults()`: `int` -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getData()`: [`AirPollutionData[]`](#airpollutiondata) - -### AirPollutionData - -- `getDateTime()`: `\DateTimeImmutable` -- `getAirQuality`: [`AirQuality`](#airquality) -- `getCarbonMonoxide()`: `float` -- `getNitrogenMonoxide()`: `float` -- `getNitrogenDioxide()`: `float` -- `getOzone()`: `float` -- `getSulphurDioxide()`: `float` -- `getFineParticulateMatter()`: `float` -- `getCoarseParticulateMatter()`: `float` -- `getAmmonia()`: `float` - -### AirQuality - -- `getIndex()`: `int` -- `getQualitativeName()`: `string` - -## Geocoding - -### ZipLocation - -- `getZipCode()`: `string` -- `getName()`: `string` -- `getCountryCode()`: `string` -- `getCoordinate()`: [`Coordinate`](#coordinate) - -## Common - -### Coordinate - -- `getLatitude()`: `float` -- `getLongitude()`: `float` - -### Condition - -- `getId()`: `int` -- `getName()`: `string` -- `getDescription()`: `string` -- `getIcon()`: [`Icon`](#icon) -- `getSystemName()`: `string` - -### Icon - -- `getId()`: `string` -- `getUrl()`: `string` - -### Location - -- `getCoordinate()`: [`Coordinate`](#coordinate) -- `getId()`: `?int` -- `getName()`: `?string` -- `getState()`: `?string` -- `getCountryCode()`: `?string` -- `getLocalNames()`: `?array` -- `getLocalName(string $countryCode)`: `?string` -- `getPopulation()`: `?int` -- `getTimezone()`: [`?Timezone`](#timezone) -- `getSunriseAt()`: `?\DateTimeImmutable` -- `getSunsetAt()`: `?\DateTimeImmutable` - -### Timezone - -- `getOffset()`: `int` -- `getIdentifier()`: `?string` - -### Wind - -- `getSpeed()`: `float` -- `getDirection()`: `int` -- `getGust()`: `?float` \ No newline at end of file diff --git a/src/Entity/AirPollution/AirPollution.php b/src/Entity/AirPollution/AirPollution.php deleted file mode 100644 index 9e90556..0000000 --- a/src/Entity/AirPollution/AirPollution.php +++ /dev/null @@ -1,22 +0,0 @@ -coordinate = new Coordinate($data['coord']); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirPollutionCollection.php b/src/Entity/AirPollution/AirPollutionCollection.php deleted file mode 100644 index 192834a..0000000 --- a/src/Entity/AirPollution/AirPollutionCollection.php +++ /dev/null @@ -1,38 +0,0 @@ -numResults = count($data['list']); - $this->coordinate = new Coordinate($data['coord']); - $this->data = EntityHelper::createEntityList(AirPollutionData::class, $data['list']); - } - - public function getNumResults(): int - { - return $this->numResults; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirPollutionData.php b/src/Entity/AirPollution/AirPollutionData.php deleted file mode 100644 index 4b85346..0000000 --- a/src/Entity/AirPollution/AirPollutionData.php +++ /dev/null @@ -1,93 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->airQuality = new AirQuality($data['main']); - $this->carbonMonoxide = $data['components']['co']; - $this->nitrogenMonoxide = $data['components']['no']; - $this->nitrogenDioxide = $data['components']['no2']; - $this->ozone = $data['components']['o3']; - $this->sulphurDioxide = $data['components']['so2']; - $this->fineParticulateMatter = $data['components']['pm2_5']; - $this->coarseParticulateMatter = $data['components']['pm10']; - $this->ammonia = $data['components']['nh3']; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getAirQuality(): AirQuality - { - return $this->airQuality; - } - - public function getCarbonMonoxide(): float - { - return $this->carbonMonoxide; - } - - public function getNitrogenMonoxide(): float - { - return $this->nitrogenMonoxide; - } - - public function getNitrogenDioxide(): float - { - return $this->nitrogenDioxide; - } - - public function getOzone(): float - { - return $this->ozone; - } - - public function getSulphurDioxide(): float - { - return $this->sulphurDioxide; - } - - public function getFineParticulateMatter(): float - { - return $this->fineParticulateMatter; - } - - public function getCoarseParticulateMatter(): float - { - return $this->coarseParticulateMatter; - } - - public function getAmmonia(): float - { - return $this->ammonia; - } -} \ No newline at end of file diff --git a/src/Entity/AirPollution/AirQuality.php b/src/Entity/AirPollution/AirQuality.php deleted file mode 100644 index 8e3c50e..0000000 --- a/src/Entity/AirPollution/AirQuality.php +++ /dev/null @@ -1,39 +0,0 @@ -index = $data['aqi']; - $this->qualitativeName = $this->findQualitativeName($this->index); - } - - public function getIndex(): int - { - return $this->index; - } - - public function getQualitativeName(): string - { - return $this->qualitativeName; - } - - private function findQualitativeName(int $index): string - { - // levels based on https://openweathermap.org/api/air-pollution - return match ($index) { - 1 => 'Good', - 2 => 'Fair', - 3 => 'Moderate', - 4 => 'Poor', - 5 => 'Very Poor', - default => 'Undefined' - }; - } -} \ No newline at end of file diff --git a/src/Entity/Assistant/Answer.php b/src/Entity/Assistant/Answer.php deleted file mode 100644 index 28396e7..0000000 --- a/src/Entity/Assistant/Answer.php +++ /dev/null @@ -1,40 +0,0 @@ -answer = $data['answer']; - $this->sessionId = $data['session_id']; - - if (!empty($data['data'])) { - $this->data = EntityHelper::createEntityKeyList(WeatherData::class, $data['data']); - } - } - - public function getAnswer(): string - { - return $this->answer; - } - - public function getSessionId(): string - { - return $this->sessionId; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/Assistant/WeatherData.php b/src/Entity/Assistant/WeatherData.php deleted file mode 100644 index 97020ea..0000000 --- a/src/Entity/Assistant/WeatherData.php +++ /dev/null @@ -1,62 +0,0 @@ -locationName = $locationName; - $this->temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - $this->sunriseAt = \DateTimeImmutable::createFromFormat('U', $data['sunrise']); - $this->sunsetAt = \DateTimeImmutable::createFromFormat('U', $data['sunset']); - } - - public function getLocationName(): string - { - return $this->locationName; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - public function getSunriseAt(): \DateTimeImmutable - { - return $this->sunriseAt; - } - - public function getSunsetAt(): \DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/BaseWeather.php b/src/Entity/BaseWeather.php deleted file mode 100644 index a7e897f..0000000 --- a/src/Entity/BaseWeather.php +++ /dev/null @@ -1,102 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->atmosphericPressure = $data['pressure']; - $this->humidity = $data['humidity']; - $this->dewPoint = $data['dew_point']; - $this->ultraVioletIndex = $data['uvi'] ?? null; - $this->cloudiness = $data['clouds']; - - $this->wind = new Wind([ - 'speed' => $data['wind_speed'], - 'deg' => $data['wind_deg'], - 'gust' => $data['wind_gust'] ?? null - ]); - - $this->conditions = EntityHelper::createEntityList(Condition::class, $data['weather']); - $this->rainVolume = $data['rain']['1h'] ?? $data['rain']['3h'] ?? $data['rain'] ?? null; - $this->snowVolume = $data['snow']['1h'] ?? $data['snow']['3h'] ?? $data['snow'] ?? null; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getDewPoint(): float - { - return $this->dewPoint; - } - - public function getUltraVioletIndex(): ?float - { - return $this->ultraVioletIndex; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - public function getWind(): Wind - { - return $this->wind; - } - - public function getConditions(): array - { - return $this->conditions; - } - - public function getRainVolume(): ?float - { - return $this->rainVolume; - } - - public function getSnowVolume(): ?float - { - return $this->snowVolume; - } -} \ No newline at end of file diff --git a/src/Entity/Condition.php b/src/Entity/Condition.php deleted file mode 100644 index 8f4e9d5..0000000 --- a/src/Entity/Condition.php +++ /dev/null @@ -1,92 +0,0 @@ -id = $data['id']; - $this->name = $data['main']; - $this->description = $data['description']; - $this->icon = new Icon($data); - $this->systemName = $this->findSystemName($this->id); - } - - public function getId(): int - { - return $this->id; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getIcon(): Icon - { - return $this->icon; - } - - public function getSystemName(): string - { - return $this->systemName; - } - - /** - * Find group based on this table https://openweathermap.org/weather-conditions - */ - private function findSystemName(int $id): string - { - return match ($id) { - 200, 201, 202, 210, 211, 212, 221, 230, 231, 232 => self::THUNDERSTORM, - 300, 301, 302, 310, 311, 312, 313, 314, 321 => self::DRIZZLE, - 500, 501, 502, 503, 504, 511, 520, 521, 522, 531 => self::RAIN, - 600, 601, 602, 611, 612, 613, 615, 616, 620, 621, 622 => self::SNOW, - 701 => self::MIST, - 711 => self::SMOKE, - 721 => self::HAZE, - 731, 761 => self::DUST, - 741 => self::FOG, - 751 => self::SAND, - 762 => self::ASH, - 771 => self::SQUALL, - 781 => self::TORNADO, - 800 => self::CLEAR, - 801, 802, 803, 804 => self::CLOUDS, - default => self::UNDEFINED - }; - } -} \ No newline at end of file diff --git a/src/Entity/Coordinate.php b/src/Entity/Coordinate.php deleted file mode 100644 index 3156c9a..0000000 --- a/src/Entity/Coordinate.php +++ /dev/null @@ -1,26 +0,0 @@ -latitude = $data['lat']; - $this->longitude = $data['lon']; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} \ No newline at end of file diff --git a/src/Entity/Geocoding/ZipLocation.php b/src/Entity/Geocoding/ZipLocation.php deleted file mode 100644 index 5bbe563..0000000 --- a/src/Entity/Geocoding/ZipLocation.php +++ /dev/null @@ -1,48 +0,0 @@ -zipCode = $data['zip']; - $this->name = $data['name']; - $this->countryCode = $data['country']; - - $this->coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - } - - public function getZipCode(): string - { - return $this->zipCode; - } - - public function getName(): string - { - return $this->name; - } - - public function getCountryCode(): string - { - return $this->countryCode; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } -} \ No newline at end of file diff --git a/src/Entity/Icon.php b/src/Entity/Icon.php deleted file mode 100644 index 903399a..0000000 --- a/src/Entity/Icon.php +++ /dev/null @@ -1,26 +0,0 @@ -id = $data['icon']; - $this->url = sprintf('https://openweathermap.org/img/wn/%s@4x.png', $this->id); - } - - public function getId(): string - { - return $this->id; - } - - public function getUrl(): string - { - return $this->url; - } -} \ No newline at end of file diff --git a/src/Entity/Location.php b/src/Entity/Location.php deleted file mode 100644 index 1d5c171..0000000 --- a/src/Entity/Location.php +++ /dev/null @@ -1,133 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - // set no null if it is 0 - $this->id = !empty($data['id']) - ? $data['id'] - : null; - - // set to null if it is an empty string - $this->name = !empty($data['name']) - ? $data['name'] - : null; - - $this->state = $data['state'] ?? null; - - // set to null if it is an empty string - $this->countryCode = !empty($data['country']) - ? $data['country'] - : null; - - $this->localNames = $data['local_names'] ?? null; - - // set to null if it is 0 - $this->population = !empty($data['population']) - ? $data['population'] - : null; - - $this->timezone = isset($data['timezone_offset']) - ? new Timezone(['timezone_offset' => $data['timezone_offset']]) - : null; - - $this->sunriseAt = isset($data['sunrise']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunrise']) - : null; - - $this->sunsetAt = isset($data['sunset']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunset']) - : null; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getId(): ?int - { - return $this->id; - } - - public function getName(): ?string - { - return $this->name; - } - - public function getState(): ?string - { - return $this->state; - } - - public function getCountryCode(): ?string - { - return $this->countryCode; - } - - public function getLocalNames(): ?array - { - return $this->localNames; - } - - public function getLocalName(string $countryCode): ?string - { - $countryCode = strtolower($countryCode); - - return $this->localNames[$countryCode] ?? null; - } - - public function getPopulation(): ?int - { - return $this->population; - } - - public function getTimezone(): ?Timezone - { - return $this->timezone; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): ?\DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): ?\DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Alert.php b/src/Entity/OneCall/Alert.php deleted file mode 100644 index ff15e44..0000000 --- a/src/Entity/OneCall/Alert.php +++ /dev/null @@ -1,58 +0,0 @@ -senderName = $data['sender_name']; - $this->eventName = $data['event']; - $this->startsAt = \DateTimeImmutable::createFromFormat('U', $data['start']); - $this->endsAt = \DateTimeImmutable::createFromFormat('U', $data['end']); - $this->description = $data['description']; - $this->tags = $data['tags']; - } - - public function getSenderName(): string - { - return $this->senderName; - } - - public function getEventName(): string - { - return $this->eventName; - } - - public function getStartsAt(): \DateTimeImmutable - { - return $this->startsAt; - } - - public function getEndsAt(): \DateTimeImmutable - { - return $this->endsAt; - } - - public function getDescription(): string - { - return $this->description; - } - - public function getTags(): array - { - return $this->tags; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/DayData.php b/src/Entity/OneCall/DayData.php deleted file mode 100644 index e15467b..0000000 --- a/src/Entity/OneCall/DayData.php +++ /dev/null @@ -1,98 +0,0 @@ -temperature = new Temperature($data['temp']); - $this->temperatureFeelsLike = new Temperature($data['feels_like']); - $this->precipitationProbability = round($data['pop'] * 100); - $this->summary = $data['summary']; - $this->moonPhase = new MoonPhase($data); - $this->moonriseAt = \DateTimeImmutable::createFromFormat('U', $data['moonrise']); - $this->moonsetAt = \DateTimeImmutable::createFromFormat('U', $data['moonset']); - $this->sunriseAt = \DateTimeImmutable::createFromFormat('U', $data['sunrise']); - $this->sunsetAt = \DateTimeImmutable::createFromFormat('U', $data['sunset']); - } - - public function getTemperature(): Temperature - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): Temperature - { - return $this->temperatureFeelsLike; - } - - public function getPrecipitationProbability(): int - { - return $this->precipitationProbability; - } - - public function getSummary(): string - { - return $this->summary; - } - - public function getMoonPhase(): MoonPhase - { - return $this->moonPhase; - } - - /** - * Moonrise date in UTC - */ - public function getMoonriseAt(): \DateTimeImmutable - { - return $this->moonriseAt; - } - - /** - * Moonset date in UTC - */ - public function getMoonsetAt(): \DateTimeImmutable - { - return $this->moonsetAt; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): \DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): \DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/HourData.php b/src/Entity/OneCall/HourData.php deleted file mode 100644 index 03b21ec..0000000 --- a/src/Entity/OneCall/HourData.php +++ /dev/null @@ -1,46 +0,0 @@ -temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - $this->precipitationProbability = round($data['pop'] * 100); - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - public function getPrecipitationProbability(): int - { - return $this->precipitationProbability; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/MinuteData.php b/src/Entity/OneCall/MinuteData.php deleted file mode 100644 index 3273731..0000000 --- a/src/Entity/OneCall/MinuteData.php +++ /dev/null @@ -1,29 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->precipitation = $data['precipitation']; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getPrecipitation(): float - { - return $this->precipitation; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/MoonPhase.php b/src/Entity/OneCall/MoonPhase.php deleted file mode 100644 index b13828b..0000000 --- a/src/Entity/OneCall/MoonPhase.php +++ /dev/null @@ -1,57 +0,0 @@ -value = $data['moon_phase']; - $this->systemName = $this->findSystemName($this->value); - $this->name = ucwords(strtolower(str_replace('_', ' ', $this->systemName))); - } - - public function getValue(): float - { - return $this->value; - } - - public function getName(): string - { - return $this->name; - } - - public function getSystemName(): string - { - return $this->systemName; - } - - private function findSystemName(float $value): string - { - return match (true) { - $value > 0 && $value < 0.25 => self::WAXING_CRESCENT, - $value === 0.25 => self::FIRST_QUARTER_MOON, - $value > 0.25 && $value < 0.5 => self::WAXING_GIBBOUS, - $value === 0.5 => self::FULL_MOON, - $value > 0.5 && $value < 0.75 => self::WANING_GIBBOUS, - $value === 0.75 => self::LAST_QUARTER_MOON, - $value > 0.75 && $value < 1 => self::WANING_CRESCENT, - default => self::NEW_MOON // 0.0 or 1.0 - }; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Temperature.php b/src/Entity/OneCall/Temperature.php deleted file mode 100644 index 94d13f7..0000000 --- a/src/Entity/OneCall/Temperature.php +++ /dev/null @@ -1,58 +0,0 @@ -morning = $data['morn']; - $this->day = $data['day']; - $this->evening = $data['eve']; - $this->night = $data['night']; - $this->min = $data['min'] ?? null; - $this->max = $data['max'] ?? null; - } - - public function getMorning(): float - { - return $this->morning; - } - - public function getDay(): float - { - return $this->day; - } - - public function getEvening(): float - { - return $this->evening; - } - - public function getNight(): float - { - return $this->night; - } - - public function getMin(): ?float - { - return $this->min; - } - - public function getMax(): ?float - { - return $this->max; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/Weather.php b/src/Entity/OneCall/Weather.php deleted file mode 100644 index 09395e1..0000000 --- a/src/Entity/OneCall/Weather.php +++ /dev/null @@ -1,89 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - $this->timezone = new Timezone([ - 'timezone' => $data['timezone'], - 'timezone_offset' => $data['timezone_offset'] - ]); - - $this->current = new WeatherData($data['current']); - - $this->minutelyForecast = isset($data['minutely']) - ? EntityHelper::createEntityList(MinuteData::class, $data['minutely']) - : null; - - $this->hourlyForecast = EntityHelper::createEntityList(HourData::class, $data['hourly']); - $this->dailyForecast = EntityHelper::createEntityList(DayData::class, $data['daily']); - - $this->alerts = isset($data['alerts']) - ? EntityHelper::createEntityList(Alert::class, $data['alerts']) - : null; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getCurrent(): WeatherData - { - return $this->current; - } - - public function getMinutelyForecast(): ?array - { - return $this->minutelyForecast; - } - - public function getHourlyForecast(): array - { - return $this->hourlyForecast; - } - - public function getDailyForecast(): array - { - return $this->dailyForecast; - } - - public function getAlerts(): ?array - { - return $this->alerts; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherData.php b/src/Entity/OneCall/WeatherData.php deleted file mode 100644 index 0154344..0000000 --- a/src/Entity/OneCall/WeatherData.php +++ /dev/null @@ -1,66 +0,0 @@ -temperature = $data['temp']; - $this->temperatureFeelsLike = $data['feels_like']; - $this->visibility = $data['visibility'] ?? null; - - $this->sunriseAt = isset($data['sunrise']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunrise']) - : null; - - $this->sunsetAt = isset($data['sunset']) - ? \DateTimeImmutable::createFromFormat('U', $data['sunset']) - : null; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getVisibility(): ?int - { - return $this->visibility; - } - - /** - * Sunrise date in UTC - */ - public function getSunriseAt(): ?\DateTimeImmutable - { - return $this->sunriseAt; - } - - /** - * Sunset date in UTC - */ - public function getSunsetAt(): ?\DateTimeImmutable - { - return $this->sunsetAt; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherMoment.php b/src/Entity/OneCall/WeatherMoment.php deleted file mode 100644 index dc3665e..0000000 --- a/src/Entity/OneCall/WeatherMoment.php +++ /dev/null @@ -1,38 +0,0 @@ -coordinate = new Coordinate([ - 'lat' => $data['lat'], - 'lon' => $data['lon'] - ]); - - $this->timezone = new Timezone([ - 'timezone' => $data['timezone'], - 'timezone_offset' => $data['timezone_offset'] - ]); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherOverview.php b/src/Entity/OneCall/WeatherOverview.php deleted file mode 100644 index 6e2fed5..0000000 --- a/src/Entity/OneCall/WeatherOverview.php +++ /dev/null @@ -1,53 +0,0 @@ -coordinate = new Coordinate($data); - - $this->timezone = new Timezone([ - 'timezone_offset' => \DateTimeImmutable::createFromFormat('P', $data['tz'])->getOffset() - ]); - - $this->dateTime = \DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s P', - sprintf('%s 00:00:00 %s', $data['date'], $data['tz']) - ); - - $this->overview = $data['weather_overview']; - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getOverview(): string - { - return $this->overview; - } -} \ No newline at end of file diff --git a/src/Entity/OneCall/WeatherSummary.php b/src/Entity/OneCall/WeatherSummary.php deleted file mode 100644 index 4fb3af0..0000000 --- a/src/Entity/OneCall/WeatherSummary.php +++ /dev/null @@ -1,107 +0,0 @@ -coordinate = new Coordinate($data); - - $this->timezone = new Timezone([ - 'timezone_offset' => \DateTimeImmutable::createFromFormat('P', $data['tz'])->getOffset() - ]); - - $this->dateTime = \DateTimeImmutable::createFromFormat( - 'Y-m-d H:i:s P', - sprintf('%s 00:00:00 %s', $data['date'], $data['tz']) - ); - - $this->cloudiness = \round($data['cloud_cover']['afternoon']); - $this->humidity = \round($data['humidity']['afternoon']); - $this->precipitation = $data['precipitation']['total']; - - $this->temperature = new Temperature([ - 'morn' => $data['temperature']['morning'], - 'day' => $data['temperature']['afternoon'], - 'eve' => $data['temperature']['evening'], - 'night' => $data['temperature']['night'], - 'min' => $data['temperature']['min'], - 'max' => $data['temperature']['max'] - ]); - - $this->atmosphericPressure = round($data['pressure']['afternoon']); - - $this->wind = new Wind([ - 'speed' => $data['wind']['max']['speed'], - 'deg' => round($data['wind']['max']['direction']) - ]); - } - - public function getCoordinate(): Coordinate - { - return $this->coordinate; - } - - public function getTimezone(): Timezone - { - return $this->timezone; - } - - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getPrecipitation(): float - { - return $this->precipitation; - } - - public function getTemperature(): Temperature - { - return $this->temperature; - } - - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getWind(): Wind - { - return $this->wind; - } -} \ No newline at end of file diff --git a/src/Entity/Timezone.php b/src/Entity/Timezone.php deleted file mode 100644 index 95c0485..0000000 --- a/src/Entity/Timezone.php +++ /dev/null @@ -1,26 +0,0 @@ -offset = $data['timezone_offset']; - $this->identifier = $data['timezone'] ?? null; - } - - public function getOffset(): int - { - return $this->offset; - } - - public function getIdentifier(): ?string - { - return $this->identifier; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/Weather.php b/src/Entity/Weather/Weather.php deleted file mode 100644 index 5e43e1b..0000000 --- a/src/Entity/Weather/Weather.php +++ /dev/null @@ -1,31 +0,0 @@ -location = new Location([ - 'lat' => $data['coord']['lat'], - 'lon' => $data['coord']['lon'], - 'id' => $data['id'] ?? null, - 'name' => $data['name'] ?? null, - 'country' => $data['sys']['country'] ?? null, - 'sunrise' => $data['sys']['sunrise'], - 'sunset' => $data['sys']['sunset'], - 'timezone_offset' => $data['timezone'] - ]); - } - - public function getLocation(): Location - { - return $this->location; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/WeatherCollection.php b/src/Entity/Weather/WeatherCollection.php deleted file mode 100644 index dbb7f3d..0000000 --- a/src/Entity/Weather/WeatherCollection.php +++ /dev/null @@ -1,50 +0,0 @@ -numResults = $data['cnt']; - - $this->location = new Location([ - 'lat' => $data['city']['coord']['lat'], - 'lon' => $data['city']['coord']['lon'], - 'id' => $data['city']['id'] ?? null, - 'name' => $data['city']['name'] ?? null, - 'country' => $data['city']['country'] ?? null, - 'population' => $data['city']['population'] ?? null, - 'sunrise' => $data['city']['sunrise'], - 'sunset' => $data['city']['sunset'], - 'timezone_offset' => $data['city']['timezone'] - ]); - - $this->data = EntityHelper::createEntityList(WeatherData::class, $data['list']); - } - - public function getNumResults(): int - { - return $this->numResults; - } - - public function getLocation(): Location - { - return $this->location; - } - - public function getData(): array - { - return $this->data; - } -} \ No newline at end of file diff --git a/src/Entity/Weather/WeatherData.php b/src/Entity/Weather/WeatherData.php deleted file mode 100644 index 4b61522..0000000 --- a/src/Entity/Weather/WeatherData.php +++ /dev/null @@ -1,143 +0,0 @@ -dateTime = \DateTimeImmutable::createFromFormat('U', $data['dt']); - $this->temperature = $data['main']['temp']; - $this->temperatureFeelsLike = $data['main']['feels_like']; - $this->minTemperature = $data['main']['temp_min']; - $this->maxTemperature = $data['main']['temp_max']; - $this->humidity = $data['main']['humidity']; - $this->cloudiness = $data['clouds']['all']; - $this->visibility = $data['visibility'] ?? null; - $this->atmosphericPressure = $data['main']['pressure']; - $this->conditions = EntityHelper::createEntityList(Condition::class, $data['weather']); - $this->wind = new Wind($data['wind']); - $this->precipitationProbability = isset($data['pop']) ? round($data['pop'] * 100) : null; - $this->rainVolume = $data['rain']['1h'] ?? $data['rain']['3h'] ?? null; - $this->snowVolume = $data['snow']['1h'] ?? $data['snow']['3h'] ?? null; - } - - /** - * DateTime in UTC - */ - public function getDateTime(): \DateTimeImmutable - { - return $this->dateTime; - } - - public function getTemperature(): float - { - return $this->temperature; - } - - public function getTemperatureFeelsLike(): float - { - return $this->temperatureFeelsLike; - } - - public function getMinTemperature(): float - { - return $this->minTemperature; - } - - public function getMaxTemperature(): float - { - return $this->maxTemperature; - } - - public function getHumidity(): int - { - return $this->humidity; - } - - public function getCloudiness(): int - { - return $this->cloudiness; - } - - /** - * Visibility, meters - * Maximum value is 10000 - */ - public function getVisibility(): ?int - { - return $this->visibility; - } - - /** - * Atmospheric pressure on the sea level, hPa - */ - public function getAtmosphericPressure(): int - { - return $this->atmosphericPressure; - } - - public function getConditions(): array - { - return $this->conditions; - } - - public function getWind(): Wind - { - return $this->wind; - } - - public function getPrecipitationProbability(): ?int - { - return $this->precipitationProbability; - } - - /** - * Rain volume, mm - */ - public function getRainVolume(): ?float - { - return $this->rainVolume; - } - - /** - * Snow volume, mm - */ - public function getSnowVolume(): ?float - { - return $this->snowVolume; - } -} \ No newline at end of file diff --git a/src/Entity/Wind.php b/src/Entity/Wind.php deleted file mode 100644 index 77ae282..0000000 --- a/src/Entity/Wind.php +++ /dev/null @@ -1,37 +0,0 @@ -speed = $data['speed']; - $this->direction = $data['deg']; - $this->gust = $data['gust'] ?? null; - } - - public function getSpeed(): float - { - return $this->speed; - } - - /** - * Wind direction, degrees - */ - public function getDirection(): int - { - return $this->direction; - } - - public function getGust(): ?float - { - return $this->gust; - } -} \ No newline at end of file diff --git a/src/Exception/ApiErrorException.php b/src/Exception/ApiErrorException.php deleted file mode 100644 index 88209f3..0000000 --- a/src/Exception/ApiErrorException.php +++ /dev/null @@ -1,22 +0,0 @@ -parameters = $error['parameters'] ?? null; - } - - public function getParameters(): ?array - { - return $this->parameters; - } -} \ No newline at end of file diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php deleted file mode 100644 index 6f2affc..0000000 --- a/src/Exception/BadRequestException.php +++ /dev/null @@ -1,5 +0,0 @@ -getConstants(); - - // Sort by alphabetical order - // to be more intuitive when listing values for error messages - asort($constants); - - return $constants; - } -} \ No newline at end of file diff --git a/src/Language/Language.php b/src/Language/Language.php deleted file mode 100644 index 9c914b9..0000000 --- a/src/Language/Language.php +++ /dev/null @@ -1,60 +0,0 @@ -optionsResolver = new OptionsResolver(); - - $this->options = $this->configureOptions($options); - $this->configureApi(); - } - - public function oneCall(): OneCallResource - { - return new OneCallResource($this); - } - - public function assistant(): AssistantResource - { - return new AssistantResource($this); - } - - public function weather(): WeatherResource - { - return new WeatherResource($this); - } - - public function airPollution(): AirPollutionResource - { - return new AirPollutionResource($this); - } - - public function geocoding(): GeocodingResource - { - return new GeocodingResource($this); - } - - private function configureOptions(array $options): array - { - $this->optionsResolver->setDefault('unitSystem', UnitSystem::METRIC); - $this->optionsResolver->setDefault('language', Language::ENGLISH); - - $this->optionsResolver->setAllowedTypes('unitSystem', 'string'); - $this->optionsResolver->setAllowedTypes('language', 'string'); - - $this->optionsResolver->setAllowedValues('unitSystem', UnitSystem::getOptions()); - - return $this->optionsResolver->resolve($options); - } - - private function configureApi(): void - { - $this->setBaseUrl('https://api.openweathermap.org'); - - $this->setAuthentication(new QueryParam(['appid' => $this->apiKey])); - - $this->addQueryDefault('units', $this->options['unitSystem']); - $this->addQueryDefault('lang', $this->options['language']); - - $this->addPostRequestListener(function(PostRequestEvent $event) { - $response = $event->getResponse(); - $statusCode = $response->getStatusCode(); - - // if there was a response with an error status code - if ($statusCode >= 400) { - $error = json_decode($response->getBody()->getContents(), true); - - match ($statusCode) { - 400 => throw new BadRequestException($error), - 401 => throw new UnauthorizedException($error), - 404 => throw new NotFoundException($error), - 429 => throw new TooManyRequestsException($error), - default => throw new UnexpectedErrorException($error) - }; - } - }); - - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // decode json string response into an array - $contents = $event->getContents(); - $contents = json_decode($contents, true); - - $event->setContents($contents); - }); - } -} \ No newline at end of file diff --git a/src/Resource/AirPollutionResource.php b/src/Resource/AirPollutionResource.php deleted file mode 100644 index 48980cc..0000000 --- a/src/Resource/AirPollutionResource.php +++ /dev/null @@ -1,77 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/2.5/air_pollution', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new AirPollution($data); - } - - /** - * Get access to air pollution forecast data per hour - * - * @throws ClientExceptionInterface - */ - public function getForecast(float $latitude, float $longitude): AirPollutionCollection - { - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/air_pollution/forecast', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new AirPollutionCollection($data); - } - - /** - * Get access to historical air pollution data per hour between two dates - * - * @throws ClientExceptionInterface - */ - public function getHistory( - float $latitude, - float $longitude, - \DateTimeInterface $startDate, - \DateTimeInterface $endDate - ): AirPollutionCollection - { - $utcTimezone = new \DateTimeZone('UTC'); - - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/air_pollution/history', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'start' => $startDate->setTimezone($utcTimezone)->getTimestamp(), - 'end' => $endDate->setTimezone($utcTimezone)->getTimestamp() - ] - ); - - return new AirPollutionCollection($data); - } -} \ No newline at end of file diff --git a/src/Resource/AssistantResource.php b/src/Resource/AssistantResource.php deleted file mode 100644 index 7d06c16..0000000 --- a/src/Resource/AssistantResource.php +++ /dev/null @@ -1,49 +0,0 @@ -api->setAuthentication(new Header('X-Api-Key', $this->api->apiKey)); - - $data = $this->api->request( - method: Method::POST, - path: '/assistant/session', - body: json_encode(['prompt' => $prompt]) - ); - - return new Answer($data); - } - - /** - * Resume a session (continue a conversation) with the Weather AI Assistant - * - * @throws ClientExceptionInterface - */ - public function resumeSession(string $sessionId, string $prompt): Answer - { - $this->api->setAuthentication(new Header('X-Api-Key', $this->api->apiKey)); - - $data = $this->api->request( - method: Method::POST, - path: $this->api->buildPath('/assistant/session/{sessionId}', [ - 'sessionId' => $sessionId - ]), - body: json_encode(['prompt' => $prompt]) - ); - - return new Answer($data); - } -} \ No newline at end of file diff --git a/src/Resource/GeocodingResource.php b/src/Resource/GeocodingResource.php deleted file mode 100644 index dfdcd87..0000000 --- a/src/Resource/GeocodingResource.php +++ /dev/null @@ -1,73 +0,0 @@ -api->request( - method: Method::GET, - path: '/geo/1.0/direct', - query: [ - 'q' => $locationName, - 'limit' => $numResults - ] - ); - - return EntityHelper::createEntityList(Location::class, $data); - } - - /** - * Get geographical coordinates (latitude, longitude) by using the zip/postal code - * - * @throws ClientExceptionInterface - */ - public function getByZipCode(string $zipCode, string $countryCode): ZipLocation - { - $data = $this->api->request( - method: Method::GET, - path: '/geo/1.0/zip', - query: [ - 'zip' => \sprintf('%s,%s', $zipCode, $countryCode) - ] - ); - - return new ZipLocation($data); - } - - /** - * Get the name of the location (city name or area name) by using geographical coordinates (latitude, longitude) - * - * @return Location[] - * @throws ClientExceptionInterface - */ - public function getByCoordinate(float $latitude, float $longitude, int $numResults = self::NUM_RESULTS): array - { - $data = $this->api->request( - method: Method::GET, - path: '/geo/1.0/reverse', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'limit' => $numResults - ] - ); - - return EntityHelper::createEntityList(Location::class, $data); - } -} \ No newline at end of file diff --git a/src/Resource/OneCallResource.php b/src/Resource/OneCallResource.php deleted file mode 100644 index 375dede..0000000 --- a/src/Resource/OneCallResource.php +++ /dev/null @@ -1,101 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/3.0/onecall', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new Weather($data); - } - - /** - * Get access to weather data for any datetime - * - * @throws ClientExceptionInterface - */ - public function getWeatherByDate(float $latitude, float $longitude, \DateTimeInterface $dateTime): WeatherMoment - { - $utcTimezone = new \DateTimeZone('UTC'); - - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/timemachine', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'dt' => $dateTime->setTimezone($utcTimezone)->getTimestamp() - ] - ); - - return new WeatherMoment($data); - } - - /** - * Get access to aggregated weather data for a particular date - * - * @throws ClientExceptionInterface - */ - public function getWeatherSummaryByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherSummary - { - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/day_summary', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'date' => $date->format('Y-m-d'), - 'tz' => $date->format('P') - ] - ); - - return new WeatherSummary($data); - } - - /** - * Get the weather overview with a human-readable summary for today and tomorrow's forecast, using OpenWeather AI - * - * @throws ClientExceptionInterface - */ - public function getWeatherOverviewByDate(float $latitude, float $longitude, \DateTimeInterface $date): WeatherOverview - { - $data = $this->api->request( - method: Method::GET, - path: '/data/3.0/onecall/overview', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'date' => $date->format('Y-m-d') - ] - ); - - return new WeatherOverview($data); - } -} \ No newline at end of file diff --git a/src/Resource/Resource.php b/src/Resource/Resource.php deleted file mode 100644 index d17aef8..0000000 --- a/src/Resource/Resource.php +++ /dev/null @@ -1,13 +0,0 @@ -api->getCacheBuilder()?->setTtl($ttl); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/Util/LanguageTrait.php b/src/Resource/Util/LanguageTrait.php deleted file mode 100644 index fecfad7..0000000 --- a/src/Resource/Util/LanguageTrait.php +++ /dev/null @@ -1,16 +0,0 @@ -api->addQueryDefault('lang', $language); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/Util/UnitSystemTrait.php b/src/Resource/Util/UnitSystemTrait.php deleted file mode 100644 index 168a220..0000000 --- a/src/Resource/Util/UnitSystemTrait.php +++ /dev/null @@ -1,16 +0,0 @@ -api->addQueryDefault('units', $unitSystem); - - return $clone; - } -} \ No newline at end of file diff --git a/src/Resource/WeatherResource.php b/src/Resource/WeatherResource.php deleted file mode 100644 index 96a5b06..0000000 --- a/src/Resource/WeatherResource.php +++ /dev/null @@ -1,57 +0,0 @@ -api->request( - method: Method::GET, - path: '/data/2.5/weather', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - ] - ); - - return new Weather($data); - } - - /** - * Get access to 5-day weather forecast data with 3-hour steps - * - * @throws ClientExceptionInterface - */ - public function getForecast(float $latitude, float $longitude, int $numResults = self::NUM_RESULTS): WeatherCollection - { - $data = $this->api->request( - method: Method::GET, - path: '/data/2.5/forecast', - query: [ - 'lat' => $latitude, - 'lon' => $longitude, - 'cnt' => $numResults - ] - ); - - return new WeatherCollection($data); - } -} \ No newline at end of file diff --git a/src/Test/AbstractTest.php b/src/Test/AbstractTest.php deleted file mode 100644 index 66c6a8b..0000000 --- a/src/Test/AbstractTest.php +++ /dev/null @@ -1,27 +0,0 @@ -mockClient = new Client(); - - $this->api = new OpenWeatherMap(self::API_KEY); - $this->api->setClientBuilder(new ClientBuilder($this->mockClient)); - } -} \ No newline at end of file diff --git a/src/Test/MockResponse.php b/src/Test/MockResponse.php deleted file mode 100644 index ffcd0c3..0000000 --- a/src/Test/MockResponse.php +++ /dev/null @@ -1,27 +0,0 @@ -mockClient->addResponse(new Response( - status: 200, - body: $responseBody - )); - - $response = $this->api->$resource()->$method(...$args); - $this->assertContainsOnlyInstancesOf($responseClass, $response); - } - - abstract public static function provideCollectionResponseData(): \Generator; -} \ No newline at end of file diff --git a/src/Test/Util/TestItemResponseTrait.php b/src/Test/Util/TestItemResponseTrait.php deleted file mode 100644 index c12d7e0..0000000 --- a/src/Test/Util/TestItemResponseTrait.php +++ /dev/null @@ -1,29 +0,0 @@ -mockClient->addResponse(new Response( - status: 200, - body: $responseBody - )); - - $response = $this->api->$resource()->$method(...$args); - $this->assertInstanceOf($responseClass, $response); - } - - abstract public static function provideItemResponseData(): \Generator; -} \ No newline at end of file diff --git a/src/UnitSystem/Fahrenheit.php b/src/UnitSystem/Fahrenheit.php deleted file mode 100644 index 441642a..0000000 --- a/src/UnitSystem/Fahrenheit.php +++ /dev/null @@ -1,16 +0,0 @@ - [ - AirPollution::class, - MockResponse::AIR_POLLUTION_CURRENT, - 'airPollution', - 'getCurrent', - [50, 50] - ]; - yield 'get forecast' => [ - AirPollutionCollection::class, - MockResponse::AIR_POLLUTION_FORECAST, - 'airPollution', - 'getForecast', - [50, 50] - ]; - yield 'get history' => [ - AirPollutionCollection::class, - MockResponse::AIR_POLLUTION_HISTORY, - 'airPollution', - 'getHistory', - [50, 50, new \DateTime('-1 day'), new \DateTime('now')] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/AssistantResourceTest.php b/tests/Integration/AssistantResourceTest.php deleted file mode 100644 index 162c3d6..0000000 --- a/tests/Integration/AssistantResourceTest.php +++ /dev/null @@ -1,31 +0,0 @@ - [ - Answer::class, - MockResponse::ASSISTANT_START_SESSION, - 'assistant', - 'startSession', - ['prompt'] - ]; - yield 'resume session' => [ - Answer::class, - MockResponse::ASSISTANT_RESUME_SESSION, - 'assistant', - 'resumeSession', - ['session-id', 'prompt'] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/CacheTraitTest.php b/tests/Integration/CacheTraitTest.php deleted file mode 100644 index 9e8cc73..0000000 --- a/tests/Integration/CacheTraitTest.php +++ /dev/null @@ -1,40 +0,0 @@ -createMock(CacheItemPoolInterface::class); - $cacheBuilder = new CacheBuilder($pool); - - $this->api->setCacheBuilder($cacheBuilder); - - $this->resource = new class($this->api) extends Resource { - use CacheTrait; - - public function getCacheTtl(): ?int - { - return $this->api->getCacheBuilder()?->getTtl(); - } - }; - } - - public function testMethods(): void - { - $this->assertSame(60, $this->resource->getCacheTtl()); - $this->assertSame(600, $this->resource->withCacheTtl(600)->getCacheTtl()); - $this->assertSame(60, $this->resource->getCacheTtl()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/GeocodingResourceTest.php b/tests/Integration/GeocodingResourceTest.php deleted file mode 100644 index 7f8dfe0..0000000 --- a/tests/Integration/GeocodingResourceTest.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - ZipLocation::class, - MockResponse::GEOCODING_ZIP, - 'geocoding', - 'getByZipCode', - ['1000-001', 'pt'] - ]; - } - - public static function provideCollectionResponseData(): \Generator - { - yield 'get by location name' => [ - Location::class, - MockResponse::GEOCODING_DIRECT, - 'geocoding', - 'getByLocationName', - ['test'] - ]; - yield 'get by coordinate' => [ - Location::class, - MockResponse::GEOCODING_REVERSE, - 'geocoding', - 'getByCoordinate', - [50, 50] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/LanguageTraitTest.php b/tests/Integration/LanguageTraitTest.php deleted file mode 100644 index 31cf054..0000000 --- a/tests/Integration/LanguageTraitTest.php +++ /dev/null @@ -1,33 +0,0 @@ -resource = new class($this->api) extends Resource { - use LanguageTrait; - - public function getLanguage(): string - { - return $this->api->getQueryDefault('lang'); - } - }; - } - - public function testMethods(): void - { - $this->assertSame('en', $this->resource->getLanguage()); - $this->assertSame('pt', $this->resource->withLanguage('pt')->getLanguage()); - $this->assertSame('en', $this->resource->getLanguage()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/OneCallResourceTest.php b/tests/Integration/OneCallResourceTest.php deleted file mode 100644 index 2528545..0000000 --- a/tests/Integration/OneCallResourceTest.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - Weather::class, - MockResponse::ONE_CALL_WEATHER, - 'oneCall', - 'getWeather', - [50, 50] - ]; - yield 'get weather by date' => [ - WeatherMoment::class, - MockResponse::ONE_CALL_TIMEMACHINE, - 'oneCall', - 'getWeatherByDate', - [50, 50, new \DateTime()] - ]; - yield 'get weather summary by date' => [ - WeatherSummary::class, - MockResponse::ONE_CALL_DAY_SUMMARY, - 'oneCall', - 'getWeatherSummaryByDate', - [50, 50, new \DateTime()] - ]; - yield 'get weather overview by date' => [ - WeatherOverview::class, - MockResponse::ONE_CALL_OVERVIEW, - 'oneCall', - 'getWeatherOverviewByDate', - [50, 50, new \DateTime()] - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/OpenWeatherMapTest.php b/tests/Integration/OpenWeatherMapTest.php deleted file mode 100644 index 76f4bca..0000000 --- a/tests/Integration/OpenWeatherMapTest.php +++ /dev/null @@ -1,20 +0,0 @@ -assertInstanceOf(OneCallResource::class, $this->api->oneCall()); - $this->assertInstanceOf(WeatherResource::class, $this->api->weather()); - $this->assertInstanceOf(AirPollutionResource::class, $this->api->airPollution()); - $this->assertInstanceOf(GeocodingResource::class, $this->api->geocoding()); - } -} \ No newline at end of file diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php deleted file mode 100644 index 7638911..0000000 --- a/tests/Integration/ResourceTest.php +++ /dev/null @@ -1,56 +0,0 @@ -resource = new class($this->api) extends Resource { - public function request(): void - { - $this->api->request( - method: Method::GET, - path: '/test' - ); - } - }; - } - - #[DataProvider(methodName: 'provideApiErrorData')] - public function testApiError(int $statusCode, string $exception): void - { - $this->mockClient->addResponse(new Response( - status: $statusCode, - body: MockResponse::API_ERROR - )); - - $this->expectException($exception); - $this->resource->request(); - } - - public static function provideApiErrorData(): \Generator - { - yield 'bad request' => [400, BadRequestException::class]; - yield 'unauthorized' => [401, UnauthorizedException::class]; - yield 'not found' => [404, NotFoundException::class]; - yield 'too many requests' => [429, TooManyRequestsException::class]; - yield 'unexpected error' => [500, UnexpectedErrorException::class]; - } -} \ No newline at end of file diff --git a/tests/Integration/UnitSystemTraitTest.php b/tests/Integration/UnitSystemTraitTest.php deleted file mode 100644 index 11e5919..0000000 --- a/tests/Integration/UnitSystemTraitTest.php +++ /dev/null @@ -1,33 +0,0 @@ -resource = new class($this->api) extends Resource { - use UnitSystemTrait; - - public function getUnitSystem(): string - { - return $this->api->getQueryDefault('units'); - } - }; - } - - public function testMethods(): void - { - $this->assertSame('metric', $this->resource->getUnitSystem()); - $this->assertSame('imperial', $this->resource->withUnitSystem('imperial')->getUnitSystem()); - $this->assertSame('metric', $this->resource->getUnitSystem()); // back to default value - } -} \ No newline at end of file diff --git a/tests/Integration/WeatherResourceTest.php b/tests/Integration/WeatherResourceTest.php deleted file mode 100644 index 579aa01..0000000 --- a/tests/Integration/WeatherResourceTest.php +++ /dev/null @@ -1,32 +0,0 @@ - [ - Weather::class, - MockResponse::WEATHER_CURRENT, - 'weather', - 'getCurrent', - [50, 50] - ]; - yield 'get forecast' => [ - WeatherCollection::class, - MockResponse::WEATHER_FORECAST, - 'weather', - 'getForecast', - [50, 50] - ]; - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirPollutionCollectionTest.php b/tests/Unit/AirPollution/AirPollutionCollectionTest.php deleted file mode 100644 index e8ee297..0000000 --- a/tests/Unit/AirPollution/AirPollutionCollectionTest.php +++ /dev/null @@ -1,43 +0,0 @@ - [ - 'lat' => 50, - 'lon' => 50 - ], - 'list' => [ - [ - 'dt' => 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ] - ] - ]); - - $this->assertSame(1, $entity->getNumResults()); - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertContainsOnlyInstancesOf(AirPollutionData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirPollutionDataTest.php b/tests/Unit/AirPollution/AirPollutionDataTest.php deleted file mode 100644 index 68da780..0000000 --- a/tests/Unit/AirPollution/AirPollutionDataTest.php +++ /dev/null @@ -1,41 +0,0 @@ - 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertInstanceOf(AirQuality::class, $entity->getAirQuality()); - $this->assertSame(100.0, $entity->getCarbonMonoxide()); - $this->assertSame(0.0, $entity->getNitrogenMonoxide()); - $this->assertSame(1.0, $entity->getNitrogenDioxide()); - $this->assertSame(100.0, $entity->getOzone()); - $this->assertSame(1.0, $entity->getSulphurDioxide()); - $this->assertSame(1.0, $entity->getFineParticulateMatter()); - $this->assertSame(1.0, $entity->getCoarseParticulateMatter()); - $this->assertSame(1.0, $entity->getAmmonia()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirPollutionTest.php b/tests/Unit/AirPollution/AirPollutionTest.php deleted file mode 100644 index 76902f7..0000000 --- a/tests/Unit/AirPollution/AirPollutionTest.php +++ /dev/null @@ -1,51 +0,0 @@ - [ - 'lat' => 50, - 'lon' => 50 - ], - 'list' => [ - [ - 'dt' => 1715279409, - 'main' => [ - 'aqi' => 1 - ], - 'components' => [ - 'co' => 100, - 'no' => 0, - 'no2' => 1, - 'o3' => 100, - 'so2' => 1, - 'pm2_5' => 1, - 'pm10' => 1, - 'nh3' => 1 - ] - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertInstanceOf(AirQuality::class, $entity->getAirQuality()); - $this->assertSame(100.0, $entity->getCarbonMonoxide()); - $this->assertSame(0.0, $entity->getNitrogenMonoxide()); - $this->assertSame(1.0, $entity->getNitrogenDioxide()); - $this->assertSame(100.0, $entity->getOzone()); - $this->assertSame(1.0, $entity->getSulphurDioxide()); - $this->assertSame(1.0, $entity->getFineParticulateMatter()); - $this->assertSame(1.0, $entity->getCoarseParticulateMatter()); - $this->assertSame(1.0, $entity->getAmmonia()); - } -} \ No newline at end of file diff --git a/tests/Unit/AirPollution/AirQualityTest.php b/tests/Unit/AirPollution/AirQualityTest.php deleted file mode 100644 index cae7f29..0000000 --- a/tests/Unit/AirPollution/AirQualityTest.php +++ /dev/null @@ -1,19 +0,0 @@ - 1 - ]); - - $this->assertSame(1, $entity->getIndex()); - $this->assertSame('Good', $entity->getQualitativeName()); - } -} \ No newline at end of file diff --git a/tests/Unit/Assistant/AnswerTest.php b/tests/Unit/Assistant/AnswerTest.php deleted file mode 100644 index 980c527..0000000 --- a/tests/Unit/Assistant/AnswerTest.php +++ /dev/null @@ -1,48 +0,0 @@ - 'Answer text', - 'data' => [ - 'location' => [ - 'clouds' => 100, - 'dew_point' => 10, - 'dt' => 1762622549, - 'feels_like' => 10, - 'humidity' => 10, - 'pressure' => 1000, - 'sunrise' => 1762602543, - 'sunset' => 1762638067, - 'temp' => 10, - 'uvi' => 1, - 'visibility' => 10000, - 'weather' => [ - [ - 'description' => 'description', - 'icon' => '01d', - 'id' => 200, - 'main' => 'name' - ] - ], - 'wind_deg' => 10, - 'wind_speed' => 10, - 'wind_gust' => 10, - ] - ], - 'session_id' => '777f049a-c96b-423f-baef-ca34ff725fe9' - ]); - - $this->assertSame('Answer text', $entity->getAnswer()); - $this->assertSame('777f049a-c96b-423f-baef-ca34ff725fe9', $entity->getSessionId()); - $this->assertContainsOnlyInstancesOf(WeatherData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/Assistant/WeatherDataTest.php b/tests/Unit/Assistant/WeatherDataTest.php deleted file mode 100644 index 080b3aa..0000000 --- a/tests/Unit/Assistant/WeatherDataTest.php +++ /dev/null @@ -1,54 +0,0 @@ - 100, - 'dew_point' => 10, - 'dt' => 1762622549, - 'feels_like' => 10, - 'humidity' => 10, - 'pressure' => 1000, - 'sunrise' => 1762602543, - 'sunset' => 1762638067, - 'temp' => 10, - 'uvi' => 1, - 'visibility' => 10000, - 'weather' => [ - [ - 'description' => 'description', - 'icon' => '01d', - 'id' => 200, - 'main' => 'name' - ] - ], - 'wind_deg' => 10, - 'wind_speed' => 10, - 'wind_gust' => 10, - ]); - - $this->assertSame('locationName', $entity->getLocationName()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/ConditionTest.php b/tests/Unit/ConditionTest.php deleted file mode 100644 index 2aa2c7f..0000000 --- a/tests/Unit/ConditionTest.php +++ /dev/null @@ -1,26 +0,0 @@ - 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ]); - - $this->assertSame(200, $entity->getId()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('description', $entity->getDescription()); - $this->assertInstanceOf(Icon::class, $entity->getIcon()); - $this->assertSame('THUNDERSTORM', $entity->getSystemName()); - } -} \ No newline at end of file diff --git a/tests/Unit/CoordinateTest.php b/tests/Unit/CoordinateTest.php deleted file mode 100644 index 8ef62b9..0000000 --- a/tests/Unit/CoordinateTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 50, - 'lon' => 50 - ]); - - $this->assertSame(50.0, $entity->getLatitude()); - $this->assertSame(50.0, $entity->getLongitude()); - } -} \ No newline at end of file diff --git a/tests/Unit/Geocoding/ZipLocationTest.php b/tests/Unit/Geocoding/ZipLocationTest.php deleted file mode 100644 index f335f7f..0000000 --- a/tests/Unit/Geocoding/ZipLocationTest.php +++ /dev/null @@ -1,26 +0,0 @@ - '1234-567', - 'name' => 'name', - 'country' => 'PT', - 'lat' => 50, - 'lon' => 50 - ]); - - $this->assertSame('1234-567', $entity->getZipCode()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('PT', $entity->getCountryCode()); - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - } -} \ No newline at end of file diff --git a/tests/Unit/IconTest.php b/tests/Unit/IconTest.php deleted file mode 100644 index 7d0d984..0000000 --- a/tests/Unit/IconTest.php +++ /dev/null @@ -1,19 +0,0 @@ - '01d' - ]); - - $this->assertSame('01d', $entity->getId()); - $this->assertSame('https://openweathermap.org/img/wn/01d@4x.png', $entity->getUrl()); - } -} \ No newline at end of file diff --git a/tests/Unit/LocationTest.php b/tests/Unit/LocationTest.php deleted file mode 100644 index fd1bae2..0000000 --- a/tests/Unit/LocationTest.php +++ /dev/null @@ -1,45 +0,0 @@ - 50, - 'lon' => 50, - 'id' => 100, - 'name' => 'name', - 'state' => 'state', - 'country' => 'PT', - 'local_names' => [ - 'en' => 'local name' - ], - 'population' => 100, - 'timezone_offset' => 0, - 'sunrise' => 1661834187, - 'sunset' => 1661882248 - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertSame(100, $entity->getId()); - $this->assertSame('name', $entity->getName()); - $this->assertSame('state', $entity->getState()); - $this->assertSame('PT', $entity->getCountryCode()); - - $this->assertSame(['en' => 'local name'], $entity->getLocalNames()); - $this->assertSame('local name', $entity->getLocalName('en')); - $this->assertSame(null, $entity->getLocalName('pt')); - - $this->assertSame(100, $entity->getPopulation()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/AlertTest.php b/tests/Unit/OneCall/AlertTest.php deleted file mode 100644 index d143863..0000000 --- a/tests/Unit/OneCall/AlertTest.php +++ /dev/null @@ -1,28 +0,0 @@ - 'sender name', - 'event' => 'event name', - 'start' => 1715561801, - 'end' => 1715616968, - 'description' => 'description', - 'tags' => ['tag1', 'tag2'] - ]); - - $this->assertSame('sender name', $entity->getSenderName()); - $this->assertSame('event name', $entity->getEventName()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getStartsAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getEndsAt()); - $this->assertSame('description', $entity->getDescription()); - $this->assertSame(['tag1', 'tag2'], $entity->getTags()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/DayDataTest.php b/tests/Unit/OneCall/DayDataTest.php deleted file mode 100644 index 9b381b4..0000000 --- a/tests/Unit/OneCall/DayDataTest.php +++ /dev/null @@ -1,79 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ], - 'feels_like' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10 - ], - 'pop' => 1, - 'summary' => 'summary', - 'moon_phase' => 1, - 'moonrise' => 1715561801, - 'moonset' => 1715616968, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperature()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperatureFeelsLike()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame('summary', $entity->getSummary()); - $this->assertInstanceOf(MoonPhase::class, $entity->getMoonPhase()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getMoonriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getMoonsetAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/HourDataTest.php b/tests/Unit/OneCall/HourDataTest.php deleted file mode 100644 index aaef7dc..0000000 --- a/tests/Unit/OneCall/HourDataTest.php +++ /dev/null @@ -1,55 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'pop' => 1 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/MinuteDataTest.php b/tests/Unit/OneCall/MinuteDataTest.php deleted file mode 100644 index 81f1e85..0000000 --- a/tests/Unit/OneCall/MinuteDataTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 1715561801, - 'precipitation' => 1, - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1.0, $entity->getPrecipitation()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/MoonPhaseTest.php b/tests/Unit/OneCall/MoonPhaseTest.php deleted file mode 100644 index e4b1094..0000000 --- a/tests/Unit/OneCall/MoonPhaseTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 1 - ]); - - $this->assertSame(1.0, $entity->getValue()); - $this->assertSame('New Moon', $entity->getName()); - $this->assertSame('NEW_MOON', $entity->getSystemName()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/TemperatureTest.php b/tests/Unit/OneCall/TemperatureTest.php deleted file mode 100644 index 5e5bdd5..0000000 --- a/tests/Unit/OneCall/TemperatureTest.php +++ /dev/null @@ -1,28 +0,0 @@ - 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ]); - - $this->assertSame(10.0, $entity->getMorning()); - $this->assertSame(15.0, $entity->getDay()); - $this->assertSame(15.0, $entity->getEvening()); - $this->assertSame(10.0, $entity->getNight()); - $this->assertSame(10.0, $entity->getMin()); - $this->assertSame(15.0, $entity->getMax()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherDataTest.php b/tests/Unit/OneCall/WeatherDataTest.php deleted file mode 100644 index 9d040f2..0000000 --- a/tests/Unit/OneCall/WeatherDataTest.php +++ /dev/null @@ -1,57 +0,0 @@ - 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherMomentTest.php b/tests/Unit/OneCall/WeatherMomentTest.php deleted file mode 100644 index 19e6f27..0000000 --- a/tests/Unit/OneCall/WeatherMomentTest.php +++ /dev/null @@ -1,69 +0,0 @@ - 50, - 'lon' => 50, - 'timezone' => 'UTC', - 'timezone_offset' => 0, - 'data' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(10.0, $entity->getDewPoint()); - $this->assertSame(1.0, $entity->getUltraVioletIndex()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertSame(1.0, $entity->getRainVolume()); - $this->assertSame(1.0, $entity->getSnowVolume()); - $this->assertSame(10.0, $entity->getTemperature()); - $this->assertSame(10.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunriseAt()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getSunsetAt()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherOverviewTest.php b/tests/Unit/OneCall/WeatherOverviewTest.php deleted file mode 100644 index cae347e..0000000 --- a/tests/Unit/OneCall/WeatherOverviewTest.php +++ /dev/null @@ -1,27 +0,0 @@ - 50, - 'lon' => 50, - 'tz' => '+00:00', - 'date' => '2025-01-01', - 'weather_overview' => 'Weather overview text' - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame('Weather overview text', $entity->getOverview()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherSummaryTest.php b/tests/Unit/OneCall/WeatherSummaryTest.php deleted file mode 100644 index 642474c..0000000 --- a/tests/Unit/OneCall/WeatherSummaryTest.php +++ /dev/null @@ -1,59 +0,0 @@ - 50, - 'lon' => 50, - 'tz' => '+00:00', - 'date' => '2024-01-01', - 'cloud_cover' => [ - 'afternoon' => 10 - ], - 'humidity' => [ - 'afternoon' => 10 - ], - 'precipitation' => [ - 'total' => 1 - ], - 'temperature' => [ - 'morning' => 10, - 'afternoon' => 15, - 'evening' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15 - ], - 'pressure' => [ - 'afternoon' => 1000 - ], - 'wind' => [ - 'max' => [ - 'speed' => 10, - 'direction' => 10 - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(10, $entity->getCloudiness()); - $this->assertSame(10, $entity->getHumidity()); - $this->assertSame(1.0, $entity->getPrecipitation()); - $this->assertInstanceOf(Temperature::class, $entity->getTemperature()); - $this->assertSame(1000, $entity->getAtmosphericPressure()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - } -} \ No newline at end of file diff --git a/tests/Unit/OneCall/WeatherTest.php b/tests/Unit/OneCall/WeatherTest.php deleted file mode 100644 index 27174ae..0000000 --- a/tests/Unit/OneCall/WeatherTest.php +++ /dev/null @@ -1,147 +0,0 @@ - 50, - 'lon' => 50, - 'timezone' => 'UTC', - 'timezone_offset' => 0, - 'current' => [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ], - 'minutely' => [ - [ - 'dt' => 1715561801, - 'precipitation' => 1 - ] - ], - 'hourly' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => 10, - 'feels_like' => 10, - 'visibility' => 10000, - 'pop' => 1 - ] - ], - 'daily' => [ - [ - 'dt' => 1715561801, - 'pressure' => 1000, - 'humidity' => 10, - 'dew_point' => 10, - 'uvi' => 1, - 'clouds' => 10, - 'wind_speed' => 10, - 'wind_deg' => 10, - 'wind_gust' => 10, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'name', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'rain' => 1, - 'snow' => 1, - 'temp' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10, - 'min' => 10, - 'max' => 15, - ], - 'feels_like' => [ - 'morn' => 10, - 'day' => 15, - 'eve' => 15, - 'night' => 10 - ], - 'pop' => 1, - 'summary' => 'summary', - 'moon_phase' => 1, - 'moonrise' => 1715561801, - 'moonset' => 1715616968, - 'sunrise' => 1715561801, - 'sunset' => 1715616968 - ] - ], - 'alerts' => [ - [ - 'sender_name' => 'sender name', - 'event' => 'event name', - 'start' => 1715561801, - 'end' => 1715616968, - 'description' => 'description', - 'tags' => ['tag1', 'tag2'] - ] - ] - ]); - - $this->assertInstanceOf(Coordinate::class, $entity->getCoordinate()); - $this->assertInstanceOf(Timezone::class, $entity->getTimezone()); - $this->assertInstanceOf(WeatherData::class, $entity->getCurrent()); - $this->assertContainsOnlyInstancesOf(MinuteData::class, $entity->getMinutelyForecast()); - $this->assertContainsOnlyInstancesOf(HourData::class, $entity->getHourlyForecast()); - $this->assertContainsOnlyInstancesOf(DayData::class, $entity->getDailyForecast()); - $this->assertContainsOnlyInstancesOf(Alert::class, $entity->getAlerts()); - } -} \ No newline at end of file diff --git a/tests/Unit/TimezoneTest.php b/tests/Unit/TimezoneTest.php deleted file mode 100644 index 84bc444..0000000 --- a/tests/Unit/TimezoneTest.php +++ /dev/null @@ -1,20 +0,0 @@ - 'UTC', - 'timezone_offset' => 0 - ]); - - $this->assertSame('UTC', $entity->getIdentifier()); - $this->assertSame(0, $entity->getOffset()); - } -} \ No newline at end of file diff --git a/tests/Unit/Weather/WeatherCollectionTest.php b/tests/Unit/Weather/WeatherCollectionTest.php deleted file mode 100644 index 0aefbe5..0000000 --- a/tests/Unit/Weather/WeatherCollectionTest.php +++ /dev/null @@ -1,72 +0,0 @@ - 1, - 'city' => [ - 'id' => 100, - 'name' => 'name', - 'coord' => [ - 'lat' => 50, - 'lon' => 50 - ], - 'country' => 'PT', - 'population' => 100, - 'timezone' => 0, - 'sunrise' => 1715130253, - 'sunset' => 1715184525 - ], - 'list' => [ - [ - 'dt' => 1715187406, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '3h' => 10 - ], - 'snow' => [ - '3h' => 10 - ] - ] - ] - ]); - - $this->assertSame(1, $entity->getNumResults()); - $this->assertInstanceOf(Location::class, $entity->getLocation()); - $this->assertContainsOnlyInstancesOf(WeatherData::class, $entity->getData()); - } -} \ No newline at end of file diff --git a/tests/Unit/Weather/WeatherDataTest.php b/tests/Unit/Weather/WeatherDataTest.php deleted file mode 100644 index 5bedf1f..0000000 --- a/tests/Unit/Weather/WeatherDataTest.php +++ /dev/null @@ -1,65 +0,0 @@ - [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '1h' => 10 - ], - 'snow' => [ - '1h' => 10 - ], - 'dt' => 1715187406 - ]); - - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(20.0, $entity->getTemperature()); - $this->assertSame(20.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(15.0, $entity->getMinTemperature()); - $this->assertSame(25.0, $entity->getMaxTemperature()); - $this->assertSame(50, $entity->getHumidity()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(1010, $entity->getAtmosphericPressure()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame(10.0, $entity->getRainVolume()); - $this->assertSame(10.0, $entity->getSnowVolume()); - } -} \ No newline at end of file diff --git a/tests/Unit/Weather/WeatherTest.php b/tests/Unit/Weather/WeatherTest.php deleted file mode 100644 index b7ebec9..0000000 --- a/tests/Unit/Weather/WeatherTest.php +++ /dev/null @@ -1,79 +0,0 @@ - [ - 'lat' => 50, - 'lon' => 50 - ], - 'id' => 100, - 'name' => 'name', - 'sys' => [ - 'country' => 'PT', - 'sunrise' => 1715130253, - 'sunset' => 1715184525 - ], - 'timezone' => 0, - 'weather' => [ - [ - 'id' => 200, - 'main' => 'main', - 'description' => 'description', - 'icon' => '01d' - ] - ], - 'main' => [ - 'temp' => 20, - 'feels_like' => 20, - 'temp_min' => 15, - 'temp_max' => 25, - 'pressure' => 1010, - 'humidity' => 50 - ], - 'visibility' => 10000, - 'wind' => [ - 'speed' => 100, - 'deg' => 100, - 'gust' => 100 - ], - 'clouds' => [ - 'all' => 100 - ], - 'pop' => 1, - 'rain' => [ - '1h' => 10 - ], - 'snow' => [ - '1h' => 10 - ], - 'dt' => 1715187406 - ]); - - $this->assertInstanceOf(Location::class, $entity->getLocation()); - $this->assertInstanceOf(\DateTimeImmutable::class, $entity->getDateTime()); - $this->assertSame(20.0, $entity->getTemperature()); - $this->assertSame(20.0, $entity->getTemperatureFeelsLike()); - $this->assertSame(15.0, $entity->getMinTemperature()); - $this->assertSame(25.0, $entity->getMaxTemperature()); - $this->assertSame(50, $entity->getHumidity()); - $this->assertSame(100, $entity->getCloudiness()); - $this->assertSame(10000, $entity->getVisibility()); - $this->assertSame(1010, $entity->getAtmosphericPressure()); - $this->assertContainsOnlyInstancesOf(Condition::class, $entity->getConditions()); - $this->assertInstanceOf(Wind::class, $entity->getWind()); - $this->assertSame(100, $entity->getPrecipitationProbability()); - $this->assertSame(10.0, $entity->getRainVolume()); - $this->assertSame(10.0, $entity->getSnowVolume()); - } -} \ No newline at end of file diff --git a/tests/Unit/WindTest.php b/tests/Unit/WindTest.php deleted file mode 100644 index 6a487ec..0000000 --- a/tests/Unit/WindTest.php +++ /dev/null @@ -1,22 +0,0 @@ - 100, - 'deg' => 100, - 'gust' => 100 - ]); - - $this->assertSame(100.0, $entity->getSpeed()); - $this->assertSame(100, $entity->getDirection()); - $this->assertSame(100.0, $entity->getGust()); - } -} \ No newline at end of file From 959587d34ed0637195ddda2c1e48f8bc97e2d218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 13:15:47 +0100 Subject: [PATCH 004/113] feat(core): add SDK 3 API facade --- src/OpenWeatherMap.php | 92 +++++++++++++++++++++++++++++++ tests/Unit/OpenWeatherMapTest.php | 84 ++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/OpenWeatherMap.php create mode 100644 tests/Unit/OpenWeatherMapTest.php diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php new file mode 100644 index 0000000..7773244 --- /dev/null +++ b/src/OpenWeatherMap.php @@ -0,0 +1,92 @@ +validateApiKey($apiKey); + $this->validateOptions($options); + + $this->config($options, defaults: [ + self::OPTION_UNITS => Units::METRIC, + self::OPTION_LANGUAGE => Language::ENGLISH, + ]); + + $this->baseUrl(self::BASE_URL); + $this->auth()->query('appid', $apiKey); + $this->responses()->json(); + } + + private function validateApiKey(string $apiKey): void + { + if (trim($apiKey) === '') { + throw new \InvalidArgumentException('The API key must be a non-empty string.'); + } + } + + private function validateOptions(array $options): void + { + $unknownOptions = array_diff( + array_keys($options), + [self::OPTION_LANGUAGE, self::OPTION_UNITS] + ); + + if ($unknownOptions !== []) { + throw new \InvalidArgumentException(sprintf( + 'Unknown OpenWeatherMap option%s: %s.', + count($unknownOptions) === 1 ? '' : 's', + implode(', ', $unknownOptions) + )); + } + + if (array_key_exists(self::OPTION_UNITS, $options)) { + $this->validateUnits($options[self::OPTION_UNITS]); + } + + if (array_key_exists(self::OPTION_LANGUAGE, $options)) { + $this->validateLanguage($options[self::OPTION_LANGUAGE]); + } + } + + private function validateUnits(mixed $units): void + { + if (!$units instanceof Units) { + throw new \InvalidArgumentException(sprintf( + 'The "%s" option must be an instance of %s.', + self::OPTION_UNITS, + Units::class + )); + } + } + + private function validateLanguage(mixed $language): void + { + if (!$language instanceof Language && !is_string($language)) { + throw new \InvalidArgumentException(sprintf( + 'The "%s" option must be an instance of %s or a string.', + self::OPTION_LANGUAGE, + Language::class + )); + } + + if (is_string($language) && trim($language) === '') { + throw new \InvalidArgumentException(sprintf( + 'The "%s" option must not be an empty string.', + self::OPTION_LANGUAGE + )); + } + } +} diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php new file mode 100644 index 0000000..06c0009 --- /dev/null +++ b/tests/Unit/OpenWeatherMapTest.php @@ -0,0 +1,84 @@ +config()->get(OpenWeatherMap::OPTION_UNITS)); + self::assertSame(Language::ENGLISH, $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testAcceptsConfiguredUnitsAndKnownLanguage(): void + { + $api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]); + + self::assertSame(Units::IMPERIAL, $api->config()->get(OpenWeatherMap::OPTION_UNITS)); + self::assertSame(Language::PORTUGUESE, $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testAcceptsAnArbitraryNonEmptyLanguageCode(): void + { + $api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_LANGUAGE => 'future_language', + ]); + + self::assertSame('future_language', $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); + } + + public function testConfiguresBaseUrlQueryAuthenticationAndJsonDecoding(): void + { + $client = new Client(); + $client->addResponse(new Response(body: '{"ok":true}')); + + $api = new OpenWeatherMap('secret'); + $api->setup()->client($client); + + $response = $api->send(Method::GET, '/data/2.5/weather'); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertSame('https://api.openweathermap.org/data/2.5/weather', sprintf( + '%s://%s%s', + $request->getUri()->getScheme(), + $request->getUri()->getHost(), + $request->getUri()->getPath() + )); + self::assertSame(['appid' => 'secret'], $query); + self::assertSame(['ok' => true], $response->data()); + } + + #[DataProvider('invalidConfigurationProvider')] + public function testRejectsInvalidConfiguration(string $apiKey, array $options, string $message): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new OpenWeatherMap($apiKey, $options); + } + + public static function invalidConfigurationProvider(): iterable + { + yield 'blank API key' => [' ', [], 'The API key must be a non-empty string.']; + yield 'unknown option' => ['api-key', ['unsupported' => true], 'Unknown OpenWeatherMap option: unsupported.']; + yield 'invalid units' => ['api-key', [OpenWeatherMap::OPTION_UNITS => 'metric'], 'The "units" option must be an instance of']; + yield 'invalid language type' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => 123], 'The "language" option must be an instance of']; + yield 'blank language' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => ' '], 'The "language" option must not be an empty string.']; + } +} From 074b428125e02ab3dd12f46549c465e978d9f8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 13:31:52 +0100 Subject: [PATCH 005/113] feat(hydration): add typed nullable payload reader --- src/Exception/HydrationException.php | 21 +++ src/Hydration/PayloadReader.php | 141 ++++++++++++++++++ tests/Fixture/Entity/Weather.php | 44 ++++++ .../Hydration/EntityHydrationTest.php | 39 +++++ .../Unit/Exception/HydrationExceptionTest.php | 24 +++ tests/Unit/Hydration/PayloadReaderTest.php | 119 +++++++++++++++ 6 files changed, 388 insertions(+) create mode 100644 src/Exception/HydrationException.php create mode 100644 src/Hydration/PayloadReader.php create mode 100644 tests/Fixture/Entity/Weather.php create mode 100644 tests/Integration/Hydration/EntityHydrationTest.php create mode 100644 tests/Unit/Exception/HydrationExceptionTest.php create mode 100644 tests/Unit/Hydration/PayloadReaderTest.php diff --git a/src/Exception/HydrationException.php b/src/Exception/HydrationException.php new file mode 100644 index 0000000..65ac02a --- /dev/null +++ b/src/Exception/HydrationException.php @@ -0,0 +1,21 @@ +nullableValue( + $path, + 'string', + static fn(mixed $value): bool => is_string($value) + ); + } + + public function nullableInt(string $path): ?int + { + return $this->nullableValue( + $path, + 'int', + static fn(mixed $value): bool => is_int($value) + ); + } + + public function nullableFloat(string $path): ?float + { + // Whole JSON numbers decode as integers even when the field represents + // a measurement that this library exposes as a float. + $value = $this->nullableValue( + $path, + 'int|float', + static fn(mixed $value): bool => is_int($value) || is_float($value) + ); + + return $value === null ? null : (float) $value; + } + + public function nullableBool(string $path): ?bool + { + return $this->nullableValue( + $path, + 'bool', + static fn(mixed $value): bool => is_bool($value) + ); + } + + public function nullableArray(string $path): ?array + { + return $this->nullableValue( + $path, + 'array', + static fn(mixed $value): bool => is_array($value) + ); + } + + public function nullableTimestamp(string $path): ?\DateTimeImmutable + { + $timestamp = $this->nullableInt($path); + + if ($timestamp === null) { + return null; + } + + return (new \DateTimeImmutable(sprintf('@%d', $timestamp))) + ->setTimezone(new \DateTimeZone('UTC')); + } + + /** + * @param \Closure(mixed): bool $accepts + */ + private function nullableValue( + string $path, + string $expectedType, + \Closure $accepts + ): mixed { + $value = $this->value($path); + + if ($value === null) { + return null; + } + + if (!$accepts($value)) { + throw HydrationException::invalidType( + $this->entity, + $path, + $expectedType, + $value + ); + } + + return $value; + } + + private function value(string $path): mixed + { + $value = $this->payload; + $resolvedPath = []; + + // Dot-separated paths allow concise entities to expose values from + // nested API payload structures without mirroring every container. + foreach (explode('.', $path) as $segment) { + if (!is_array($value)) { + throw HydrationException::invalidType( + $this->entity, + implode('.', $resolvedPath), + 'array', + $value + ); + } + + if (!array_key_exists($segment, $value)) { + return null; + } + + $value = $value[$segment]; + $resolvedPath[] = $segment; + + if ($value === null) { + return null; + } + } + + return $value; + } +} diff --git a/tests/Fixture/Entity/Weather.php b/tests/Fixture/Entity/Weather.php new file mode 100644 index 0000000..8ebf7ea --- /dev/null +++ b/tests/Fixture/Entity/Weather.php @@ -0,0 +1,44 @@ +nullableFloat('main.temp'), + observedAt: $payload->nullableTimestamp('dt'), + units: $context?->config()->get(OpenWeatherMap::OPTION_UNITS) ?? Units::METRIC + ); + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function observedAt(): ?\DateTimeImmutable + { + return $this->observedAt; + } + + public function units(): Units + { + return $this->units; + } +} diff --git a/tests/Integration/Hydration/EntityHydrationTest.php b/tests/Integration/Hydration/EntityHydrationTest.php new file mode 100644 index 0000000..f183d7c --- /dev/null +++ b/tests/Integration/Hydration/EntityHydrationTest.php @@ -0,0 +1,39 @@ + Units::IMPERIAL, + ])); + + $response = new Response( + data: [ + 'main' => ['temp' => 72], + 'dt' => 1700000000, + 'unknown' => 'ignored', + ], + rawResponse: new PsrResponse(), + context: $context + ); + + $weather = $response->entity(Weather::class); + + self::assertInstanceOf(Weather::class, $weather); + self::assertSame(72.0, $weather->temperature()); + self::assertSame('2023-11-14T22:13:20+00:00', $weather->observedAt()?->format(\DateTimeInterface::ATOM)); + self::assertSame(Units::IMPERIAL, $weather->units()); + } +} diff --git a/tests/Unit/Exception/HydrationExceptionTest.php b/tests/Unit/Exception/HydrationExceptionTest.php new file mode 100644 index 0000000..6aecb68 --- /dev/null +++ b/tests/Unit/Exception/HydrationExceptionTest.php @@ -0,0 +1,24 @@ +getMessage() + ); + } +} diff --git a/tests/Unit/Hydration/PayloadReaderTest.php b/tests/Unit/Hydration/PayloadReaderTest.php new file mode 100644 index 0000000..0f0eb53 --- /dev/null +++ b/tests/Unit/Hydration/PayloadReaderTest.php @@ -0,0 +1,119 @@ + 'Lisbon', + 'timezone' => 3600, + 'temperature' => 20, + 'cloudiness' => 12.5, + 'daylight' => true, + 'rain' => ['1h' => 0.4], + ], 'Weather'); + + self::assertSame('Lisbon', $reader->nullableString('name')); + self::assertSame(3600, $reader->nullableInt('timezone')); + self::assertSame(20.0, $reader->nullableFloat('temperature')); + self::assertSame(12.5, $reader->nullableFloat('cloudiness')); + self::assertTrue($reader->nullableBool('daylight')); + self::assertSame(['1h' => 0.4], $reader->nullableArray('rain')); + } + + public function testMissingAndNullValuesAreTolerated(): void + { + $reader = PayloadReader::from(['name' => null], 'Weather'); + + self::assertNull($reader->nullableString('missing')); + self::assertNull($reader->nullableString('name')); + self::assertNull($reader->nullableTimestamp('observed_at')); + } + + public function testUnknownFieldsAreIgnored(): void + { + $reader = PayloadReader::from([ + 'name' => 'Lisbon', + 'undocumented' => new \stdClass(), + ], 'Weather'); + + self::assertSame('Lisbon', $reader->nullableString('name')); + } + + public function testItReadsNestedFieldPaths(): void + { + $reader = PayloadReader::from([ + 'main' => [ + 'temp' => 30, + ], + ], 'Weather'); + + self::assertSame(30.0, $reader->nullableFloat('main.temp')); + } + + public function testItRequiresIntermediateValuesToBeArrays(): void + { + $reader = PayloadReader::from(['main' => 'unexpected'], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + 'Cannot hydrate Weather: "main" expected array, string received.' + ); + + $reader->nullableFloat('main.temp'); + } + + public function testItHydratesTimestampsAsImmutableUtcValues(): void + { + $reader = PayloadReader::from(['observed_at' => 1700000000], 'Weather'); + + $timestamp = $reader->nullableTimestamp('observed_at'); + + self::assertInstanceOf(\DateTimeImmutable::class, $timestamp); + self::assertSame('UTC', $timestamp->getTimezone()->getName()); + self::assertSame('2023-11-14T22:13:20+00:00', $timestamp->format(\DateTimeInterface::ATOM)); + } + + #[DataProvider('invalidValueProvider')] + public function testItRejectsKnownFieldsWithInvalidTypes( + string $method, + mixed $value, + string $expectedType, + string $receivedType + ): void { + $reader = PayloadReader::from([ + 'main' => [ + 'value' => $value, + ], + ], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "main.value" expected %s, %s received.', + $expectedType, + $receivedType + )); + + $reader->{$method}('main.value'); + } + + /** + * @return iterable + */ + public static function invalidValueProvider(): iterable + { + yield 'string' => ['nullableString', 1, 'string', 'int']; + yield 'integer' => ['nullableInt', 1.5, 'int', 'float']; + yield 'float' => ['nullableFloat', '20.5', 'int|float', 'string']; + yield 'boolean' => ['nullableBool', 1, 'bool', 'int']; + yield 'array' => ['nullableArray', new \stdClass(), 'array', 'stdClass']; + yield 'timestamp' => ['nullableTimestamp', '1700000000', 'int', 'string']; + } +} From f16351bdca47e393765e460123fef816b629b51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 13:34:37 +0100 Subject: [PATCH 006/113] test(hydration): remove redundant SDK integration test --- tests/Fixture/Entity/Weather.php | 44 ------------------- .../Hydration/EntityHydrationTest.php | 39 ---------------- 2 files changed, 83 deletions(-) delete mode 100644 tests/Fixture/Entity/Weather.php delete mode 100644 tests/Integration/Hydration/EntityHydrationTest.php diff --git a/tests/Fixture/Entity/Weather.php b/tests/Fixture/Entity/Weather.php deleted file mode 100644 index 8ebf7ea..0000000 --- a/tests/Fixture/Entity/Weather.php +++ /dev/null @@ -1,44 +0,0 @@ -nullableFloat('main.temp'), - observedAt: $payload->nullableTimestamp('dt'), - units: $context?->config()->get(OpenWeatherMap::OPTION_UNITS) ?? Units::METRIC - ); - } - - public function temperature(): ?float - { - return $this->temperature; - } - - public function observedAt(): ?\DateTimeImmutable - { - return $this->observedAt; - } - - public function units(): Units - { - return $this->units; - } -} diff --git a/tests/Integration/Hydration/EntityHydrationTest.php b/tests/Integration/Hydration/EntityHydrationTest.php deleted file mode 100644 index f183d7c..0000000 --- a/tests/Integration/Hydration/EntityHydrationTest.php +++ /dev/null @@ -1,39 +0,0 @@ - Units::IMPERIAL, - ])); - - $response = new Response( - data: [ - 'main' => ['temp' => 72], - 'dt' => 1700000000, - 'unknown' => 'ignored', - ], - rawResponse: new PsrResponse(), - context: $context - ); - - $weather = $response->entity(Weather::class); - - self::assertInstanceOf(Weather::class, $weather); - self::assertSame(72.0, $weather->temperature()); - self::assertSame('2023-11-14T22:13:20+00:00', $weather->observedAt()?->format(\DateTimeInterface::ATOM)); - self::assertSame(Units::IMPERIAL, $weather->units()); - } -} From c366a8e871bfff5a189c20f8c9ab94fe260dcc4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 13:39:41 +0100 Subject: [PATCH 007/113] feat(formatting): add measurement formatter --- src/Formatting/MeasurementFormatter.php | 21 ++++++++++ .../Formatting/MeasurementFormatterTest.php | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/Formatting/MeasurementFormatter.php create mode 100644 tests/Unit/Formatting/MeasurementFormatterTest.php diff --git a/src/Formatting/MeasurementFormatter.php b/src/Formatting/MeasurementFormatter.php new file mode 100644 index 0000000..ad03360 --- /dev/null +++ b/src/Formatting/MeasurementFormatter.php @@ -0,0 +1,21 @@ +symbol()); + } +} diff --git a/tests/Unit/Formatting/MeasurementFormatterTest.php b/tests/Unit/Formatting/MeasurementFormatterTest.php new file mode 100644 index 0000000..94f0ff0 --- /dev/null +++ b/tests/Unit/Formatting/MeasurementFormatterTest.php @@ -0,0 +1,38 @@ + + */ + public static function measurements(): iterable + { + yield 'integer-valued float' => [30.0, Unit::CELSIUS, '30 °C']; + yield 'decimal float' => [5.2, Unit::METERS_PER_SECOND, '5.2 m/s']; + yield 'integer' => [1013, Unit::HECTOPASCAL, '1013 hPa']; + yield 'negative value' => [-2.75, Unit::CELSIUS, '-2.75 °C']; + yield 'zero' => [0, Unit::PERCENT, '0 %']; + yield 'full precision' => [5.2345678901234, Unit::MILLIMETER, '5.2345678901234 mm']; + } + + public function testItPreservesAnUnavailableMeasurement(): void + { + self::assertNull(MeasurementFormatter::format(null, Unit::CELSIUS)); + } +} From 2951a891302cfbba725ec02d302108fd222c56cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 14:14:52 +0100 Subject: [PATCH 008/113] feat(errors): add API exception hierarchy --- src/Exception/ApiException.php | 103 +++++++++++++++++ src/Exception/BadRequestException.php | 5 + src/Exception/NotFoundException.php | 5 + src/Exception/TooManyRequestsException.php | 5 + src/Exception/UnauthorizedException.php | 5 + src/Exception/UnexpectedErrorException.php | 5 + src/OpenWeatherMap.php | 20 ++++ tests/Unit/OpenWeatherMapTest.php | 123 +++++++++++++++++++++ 8 files changed, 271 insertions(+) create mode 100644 src/Exception/ApiException.php create mode 100644 src/Exception/BadRequestException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/TooManyRequestsException.php create mode 100644 src/Exception/UnauthorizedException.php create mode 100644 src/Exception/UnexpectedErrorException.php diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php new file mode 100644 index 0000000..4ecf232 --- /dev/null +++ b/src/Exception/ApiException.php @@ -0,0 +1,103 @@ +response()->data(); + $statusCode = $context->statusCode(); + + return new static( + message: self::resolveMessage( + statusCode: $statusCode, + reasonPhrase: $context->response()->raw()->getReasonPhrase(), + data: $data + ), + statusCode: $statusCode, + apiCode: self::resolveApiCode($data), + responseData: $data + ); + } + + public function statusCode(): int + { + return $this->statusCode; + } + + public function apiCode(): ?int + { + return $this->apiCode; + } + + public function responseData(): mixed + { + return $this->responseData; + } + + private static function resolveMessage( + int $statusCode, + string $reasonPhrase, + mixed $data + ): string { + if (is_array($data)) { + $message = $data['message'] ?? null; + + if (is_string($message) && trim($message) !== '') { + return $message; + } + } + + if (is_string($data) && trim($data) !== '') { + return $data; + } + + if (trim($reasonPhrase) !== '') { + return sprintf( + 'OpenWeather API request failed with HTTP %d (%s).', + $statusCode, + $reasonPhrase + ); + } + + return sprintf( + 'OpenWeather API request failed with HTTP %d.', + $statusCode + ); + } + + private static function resolveApiCode(mixed $data): ?int + { + if (!is_array($data)) { + return null; + } + + // OpenWeather APIs use both keys, and some legacy responses encode numeric codes as strings. + // Keep the public API consistently nullable-int. + foreach (['cod', 'code'] as $key) { + $code = $data[$key] ?? null; + + if (is_int($code)) { + return $code; + } + + if (is_string($code) && preg_match('/^\d+$/', $code) === 1) { + return (int) $code; + } + } + + return null; + } +} diff --git a/src/Exception/BadRequestException.php b/src/Exception/BadRequestException.php new file mode 100644 index 0000000..aa1b9c4 --- /dev/null +++ b/src/Exception/BadRequestException.php @@ -0,0 +1,5 @@ +baseUrl(self::BASE_URL); $this->auth()->query('appid', $apiKey); $this->responses()->json(); + + // Exact status handlers run first. + // SDK conditional handlers run for every response, + // so null explicitly means that no API error matched. + $this->errors()->statuses([ + 400 => static fn (ErrorContext $context): BadRequestException => BadRequestException::fromContext($context), + 401 => static fn (ErrorContext $context): UnauthorizedException => UnauthorizedException::fromContext($context), + 404 => static fn (ErrorContext $context): NotFoundException => NotFoundException::fromContext($context), + 429 => static fn (ErrorContext $context): TooManyRequestsException => TooManyRequestsException::fromContext($context), + ])->when(static fn (ErrorContext $context): ?ApiException => match (true) { + $context->statusCode() >= 400 && $context->statusCode() <= 599 => UnexpectedErrorException::fromContext($context), + default => null, + }); } private function validateApiKey(string $apiKey): void diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php index 06c0009..1fef368 100644 --- a/tests/Unit/OpenWeatherMapTest.php +++ b/tests/Unit/OpenWeatherMapTest.php @@ -9,6 +9,12 @@ use ProgrammatorDev\Api\Http\Method; use ProgrammatorDev\OpenWeatherMap\Enum\Language; use ProgrammatorDev\OpenWeatherMap\Enum\Units; +use ProgrammatorDev\OpenWeatherMap\Exception\ApiException; +use ProgrammatorDev\OpenWeatherMap\Exception\BadRequestException; +use ProgrammatorDev\OpenWeatherMap\Exception\NotFoundException; +use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; +use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; +use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; class OpenWeatherMapTest extends TestCase @@ -64,6 +70,123 @@ public function testConfiguresBaseUrlQueryAuthenticationAndJsonDecoding(): void self::assertSame(['ok' => true], $response->data()); } + #[DataProvider('httpErrors')] + public function testMapsHttpErrorsToApiException( + int $statusCode, + array $data, + string $expectedClass, + ?int $expectedApiCode, + string $expectedMessage + ): void { + $client = new Client(); + $client->addResponse(new Response( + status: $statusCode, + body: json_encode($data, JSON_THROW_ON_ERROR) + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send(Method::GET, '/data/2.5/weather'); + } catch (ApiException $exception) { + self::assertSame($expectedClass, $exception::class); + self::assertSame($expectedMessage, $exception->getMessage()); + self::assertSame($statusCode, $exception->statusCode()); + self::assertSame($expectedApiCode, $exception->apiCode()); + self::assertSame($data, $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', ApiException::class)); + } + + /** + * @return iterable, + * class-string, + * int|null, + * string + * }> + */ + public static function httpErrors(): iterable + { + yield 'bad request using code' => [ + 400, + ['code' => 400000, 'message' => 'Invalid parameter format'], + BadRequestException::class, + 400000, + 'Invalid parameter format', + ]; + yield 'unauthorized using numeric string cod' => [ + 401, + ['cod' => '401', 'message' => 'Invalid API key'], + UnauthorizedException::class, + 401, + 'Invalid API key', + ]; + yield 'not found' => [ + 404, + ['cod' => 404, 'message' => 'Data not found'], + NotFoundException::class, + 404, + 'Data not found', + ]; + yield 'unmapped client error' => [ + 418, + ['code' => 'unexpected', 'message' => 'Unexpected client error'], + UnexpectedErrorException::class, + null, + 'Unexpected client error', + ]; + yield 'too many requests' => [ + 429, + ['cod' => 429, 'message' => 'Too many requests'], + TooManyRequestsException::class, + 429, + 'Too many requests', + ]; + yield 'unexpected error' => [ + 503, + ['code' => 503, 'message' => 'Unexpected error'], + UnexpectedErrorException::class, + 503, + 'Unexpected error', + ]; + } + + public function testFallsBackToTheHttpErrorWhenPayloadHasNoMessage(): void + { + $client = new Client(); + $client->addResponse(new Response( + status: 503, + headers: ['Content-Type' => 'application/json'], + body: '{"unavailable":true}' + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send(Method::GET, '/data/2.5/weather'); + } catch (ApiException $exception) { + self::assertInstanceOf(UnexpectedErrorException::class, $exception); + self::assertSame( + 'OpenWeather API request failed with HTTP 503 (Service Unavailable).', + $exception->getMessage() + ); + self::assertSame(503, $exception->statusCode()); + self::assertNull($exception->apiCode()); + self::assertSame(['unavailable' => true], $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', ApiException::class)); + } + #[DataProvider('invalidConfigurationProvider')] public function testRejectsInvalidConfiguration(string $apiKey, array $options, string $message): void { From be54354624d3f4dd838afc6a19e260f5e5719656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 14:25:02 +0100 Subject: [PATCH 009/113] feat(resources): add immutable unit and language overrides --- src/Resource/Concern/WithLanguage.php | 29 ++++++++ src/Resource/Concern/WithUnits.php | 24 +++++++ .../Concern/FluentConfigurationTest.php | 69 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 src/Resource/Concern/WithLanguage.php create mode 100644 src/Resource/Concern/WithUnits.php create mode 100644 tests/Unit/Resource/Concern/FluentConfigurationTest.php diff --git a/src/Resource/Concern/WithLanguage.php b/src/Resource/Concern/WithLanguage.php new file mode 100644 index 0000000..6b599a7 --- /dev/null +++ b/src/Resource/Concern/WithLanguage.php @@ -0,0 +1,29 @@ +languageOverride = $language; + + return $clone; + } + + protected function languageOverride(): Language|string|null + { + return $this->languageOverride; + } +} diff --git a/src/Resource/Concern/WithUnits.php b/src/Resource/Concern/WithUnits.php new file mode 100644 index 0000000..0437139 --- /dev/null +++ b/src/Resource/Concern/WithUnits.php @@ -0,0 +1,24 @@ +unitsOverride = $units; + + return $clone; + } + + protected function unitsOverride(): ?Units + { + return $this->unitsOverride; + } +} diff --git a/tests/Unit/Resource/Concern/FluentConfigurationTest.php b/tests/Unit/Resource/Concern/FluentConfigurationTest.php new file mode 100644 index 0000000..0d96688 --- /dev/null +++ b/tests/Unit/Resource/Concern/FluentConfigurationTest.php @@ -0,0 +1,69 @@ +resource = new class { + use WithLanguage; + use WithUnits; + + public function configuredLanguage(): Language|string|null + { + return $this->languageOverride(); + } + + public function configuredUnits(): ?Units + { + return $this->unitsOverride(); + } + }; + } + + public function testConfigurationIsImmutableAndChainable(): void + { + $configured = $this->resource + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE); + + self::assertNotSame($this->resource, $configured); + self::assertNull($this->resource->configuredUnits()); + self::assertNull($this->resource->configuredLanguage()); + self::assertSame(Units::IMPERIAL, $configured->configuredUnits()); + self::assertSame(Language::PORTUGUESE, $configured->configuredLanguage()); + } + + public function testLaterOverridesDoNotMutateEarlierClones(): void + { + $metric = $this->resource->withUnits(Units::METRIC); + $imperial = $metric->withUnits(Units::IMPERIAL); + + self::assertSame(Units::METRIC, $metric->configuredUnits()); + self::assertSame(Units::IMPERIAL, $imperial->configuredUnits()); + } + + public function testItAcceptsAnArbitraryLanguageCode(): void + { + $configured = $this->resource->withLanguage('future_language'); + + self::assertSame('future_language', $configured->configuredLanguage()); + } + + public function testItRejectsABlankLanguageCode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The language must not be an empty string.'); + + $this->resource->withLanguage(' '); + } +} From 9a7a92f392315630f3bad5be8405f82377c67a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 14:55:18 +0100 Subject: [PATCH 010/113] feat(resources): resolve unit and language overrides --- src/Resource/Concern/WithLanguage.php | 7 +- src/Resource/Concern/WithUnits.php | 5 +- .../Concern/FluentConfigurationTest.php | 79 +++++++++++++------ 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/Resource/Concern/WithLanguage.php b/src/Resource/Concern/WithLanguage.php index 6b599a7..331a153 100644 --- a/src/Resource/Concern/WithLanguage.php +++ b/src/Resource/Concern/WithLanguage.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource\Concern; use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; trait WithLanguage { @@ -22,8 +23,10 @@ public function withLanguage(Language|string $language): static return $clone; } - protected function languageOverride(): Language|string|null + protected function resolvedLanguage(): string { - return $this->languageOverride; + $language = $this->languageOverride ?? $this->runtime->config()->get(OpenWeatherMap::OPTION_LANGUAGE); + + return $language instanceof Language ? $language->value : $language; } } diff --git a/src/Resource/Concern/WithUnits.php b/src/Resource/Concern/WithUnits.php index 0437139..901ce9e 100644 --- a/src/Resource/Concern/WithUnits.php +++ b/src/Resource/Concern/WithUnits.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource\Concern; use ProgrammatorDev\OpenWeatherMap\Enum\Units; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; trait WithUnits { @@ -17,8 +18,8 @@ public function withUnits(Units $units): static return $clone; } - protected function unitsOverride(): ?Units + protected function resolvedUnits(): Units { - return $this->unitsOverride; + return $this->unitsOverride ?? $this->runtime->config()->get(OpenWeatherMap::OPTION_UNITS); } } diff --git a/tests/Unit/Resource/Concern/FluentConfigurationTest.php b/tests/Unit/Resource/Concern/FluentConfigurationTest.php index 0d96688..a7f69af 100644 --- a/tests/Unit/Resource/Concern/FluentConfigurationTest.php +++ b/tests/Unit/Resource/Concern/FluentConfigurationTest.php @@ -3,31 +3,20 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Resource\Concern; use PHPUnit\Framework\TestCase; +use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Enum\Language; use ProgrammatorDev\OpenWeatherMap\Enum\Units; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; final class FluentConfigurationTest extends TestCase { - private object $resource; + private ConfigurableResource $resource; protected function setUp(): void { - $this->resource = new class { - use WithLanguage; - use WithUnits; - - public function configuredLanguage(): Language|string|null - { - return $this->languageOverride(); - } - - public function configuredUnits(): ?Units - { - return $this->unitsOverride(); - } - }; + $this->resource = (new TestableOpenWeatherMap('api-key'))->configurableResource(); } public function testConfigurationIsImmutableAndChainable(): void @@ -37,10 +26,10 @@ public function testConfigurationIsImmutableAndChainable(): void ->withLanguage(Language::PORTUGUESE); self::assertNotSame($this->resource, $configured); - self::assertNull($this->resource->configuredUnits()); - self::assertNull($this->resource->configuredLanguage()); - self::assertSame(Units::IMPERIAL, $configured->configuredUnits()); - self::assertSame(Language::PORTUGUESE, $configured->configuredLanguage()); + self::assertSame(Units::METRIC, $this->resource->resolvedUnitsValue()); + self::assertSame('en', $this->resource->resolvedLanguageValue()); + self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); + self::assertSame('pt', $configured->resolvedLanguageValue()); } public function testLaterOverridesDoNotMutateEarlierClones(): void @@ -48,15 +37,15 @@ public function testLaterOverridesDoNotMutateEarlierClones(): void $metric = $this->resource->withUnits(Units::METRIC); $imperial = $metric->withUnits(Units::IMPERIAL); - self::assertSame(Units::METRIC, $metric->configuredUnits()); - self::assertSame(Units::IMPERIAL, $imperial->configuredUnits()); + self::assertSame(Units::METRIC, $metric->resolvedUnitsValue()); + self::assertSame(Units::IMPERIAL, $imperial->resolvedUnitsValue()); } public function testItAcceptsAnArbitraryLanguageCode(): void { $configured = $this->resource->withLanguage('future_language'); - self::assertSame('future_language', $configured->configuredLanguage()); + self::assertSame('future_language', $configured->resolvedLanguageValue()); } public function testItRejectsABlankLanguageCode(): void @@ -66,4 +55,50 @@ public function testItRejectsABlankLanguageCode(): void $this->resource->withLanguage(' '); } + + public function testItResolvesApiConfigurationWithoutOverrides(): void + { + $resource = (new TestableOpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::STANDARD, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]))->configurableResource(); + + self::assertSame(Units::STANDARD, $resource->resolvedUnitsValue()); + self::assertSame('pt', $resource->resolvedLanguageValue()); + } + + public function testOverridesTakePrecedenceWhenResolvingConfiguration(): void + { + $configured = $this->resource + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE); + + self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); + self::assertSame('pt', $configured->resolvedLanguageValue()); + } +} + +final class TestableOpenWeatherMap extends OpenWeatherMap +{ + public function configurableResource(): ConfigurableResource + { + /** @var ConfigurableResource */ + return $this->resource(ConfigurableResource::class); + } +} + +final class ConfigurableResource extends Resource +{ + use WithLanguage; + use WithUnits; + + public function resolvedLanguageValue(): string + { + return $this->resolvedLanguage(); + } + + public function resolvedUnitsValue(): Units + { + return $this->resolvedUnits(); + } } From 1b654be16b1320f216ea73de30ad9ad70a4db92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 15:02:12 +0100 Subject: [PATCH 011/113] docs(tests): document response fixture conventions --- tests/Fixtures/README.md | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/Fixtures/README.md diff --git a/tests/Fixtures/README.md b/tests/Fixtures/README.md new file mode 100644 index 0000000..ef4148b --- /dev/null +++ b/tests/Fixtures/README.md @@ -0,0 +1,75 @@ +# Response Fixtures + +Automated tests use committed JSON fixtures derived from representative real +OpenWeather responses. Tests must never make live OpenWeather requests. + +## Naming + +Store fixtures by product, endpoint, and scenario: + +```text +tests/Fixtures///.json +tests/Fixtures///.meta.json +``` + +For example: + +```text +tests/Fixtures/geocoding/direct/success.json +tests/Fixtures/geocoding/direct/success.meta.json +``` + +Use stable endpoint and scenario names such as `success`, `empty`, +`missing-optional-fields`, or `invalid-request`. Do not include a captured +location name in a filename because the returned name may change or be absent. + +## Metadata + +Every response fixture must have a sidecar with the same basename. A captured +response sidecar follows this shape: + +```json +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T12:00:00Z", + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "Lisbon,PT", + "limit": 5 + } + }, + "sanitization": [] +} +``` + +Record non-secret request parameters, including coordinates when applicable. +Never include an API key. Synthetic fixtures use `"provenance": "synthetic"` +and describe why they were created in a `notes` field. + +## Sanitization + +Keep captured payloads as close to the real response as possible. Record every +redaction or replacement in `sanitization`, including its JSON path and the +action performed: + +```json +{ + "path": "$.station.id", + "action": "replaced private identifier with station-example" +} +``` + +- Remove API keys from URLs and pagination links. +- Replace private station identifiers, names, and coordinates. +- Public test locations and coordinates may remain unchanged. +- Do not change ordinary weather or geocoding values merely to make assertions + easier. + +Capture responses manually outside PHPUnit and CI. Once committed, treat a +fixture as immutable; add a new scenario or capture rather than silently +rewriting its provenance. From af922b6ab7b6f04514e1c56ba8d548c3a2d32885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 18:23:08 +0100 Subject: [PATCH 012/113] test(geocoding): add response fixtures --- tests/Fixtures/README.md | 1 + tests/Fixtures/geocoding/direct/empty.json | 1 + .../Fixtures/geocoding/direct/empty.meta.json | 17 +++++++++++++++++ tests/Fixtures/geocoding/direct/success.json | 1 + .../geocoding/direct/success.meta.json | 17 +++++++++++++++++ .../geocoding/errors/invalid-coordinates.json | 1 + .../errors/invalid-coordinates.meta.json | 17 +++++++++++++++++ .../geocoding/errors/missing-query.json | 1 + .../geocoding/errors/missing-query.meta.json | 16 ++++++++++++++++ tests/Fixtures/geocoding/reverse/success.json | 1 + .../geocoding/reverse/success.meta.json | 18 ++++++++++++++++++ tests/Fixtures/geocoding/zip/success.json | 1 + tests/Fixtures/geocoding/zip/success.meta.json | 16 ++++++++++++++++ 13 files changed, 108 insertions(+) create mode 100644 tests/Fixtures/geocoding/direct/empty.json create mode 100644 tests/Fixtures/geocoding/direct/empty.meta.json create mode 100644 tests/Fixtures/geocoding/direct/success.json create mode 100644 tests/Fixtures/geocoding/direct/success.meta.json create mode 100644 tests/Fixtures/geocoding/errors/invalid-coordinates.json create mode 100644 tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json create mode 100644 tests/Fixtures/geocoding/errors/missing-query.json create mode 100644 tests/Fixtures/geocoding/errors/missing-query.meta.json create mode 100644 tests/Fixtures/geocoding/reverse/success.json create mode 100644 tests/Fixtures/geocoding/reverse/success.meta.json create mode 100644 tests/Fixtures/geocoding/zip/success.json create mode 100644 tests/Fixtures/geocoding/zip/success.meta.json diff --git a/tests/Fixtures/README.md b/tests/Fixtures/README.md index ef4148b..d07393b 100644 --- a/tests/Fixtures/README.md +++ b/tests/Fixtures/README.md @@ -35,6 +35,7 @@ response sidecar follows this shape: "endpoint": "Direct geocoding", "apiVersion": "1.0", "capturedAt": "2026-07-31T12:00:00Z", + "httpStatus": 200, "request": { "method": "GET", "path": "/geo/1.0/direct", diff --git a/tests/Fixtures/geocoding/direct/empty.json b/tests/Fixtures/geocoding/direct/empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/geocoding/direct/empty.meta.json b/tests/Fixtures/geocoding/direct/empty.meta.json new file mode 100644 index 0000000..fc5f096 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/empty.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "ThisPlaceShouldNotExistOpenWeatherFixture987654321", + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/direct/success.json b/tests/Fixtures/geocoding/direct/success.json new file mode 100644 index 0000000..f0a32eb --- /dev/null +++ b/tests/Fixtures/geocoding/direct/success.json @@ -0,0 +1 @@ +[{"name":"Springfield","local_names":{"ta":"ஸ்பிரிங்ஃபீல்ட்","uk":"Спрингфілд","en":"Springfield","lt":"Springfildas","pl":"Springfield","ru":"Спрингфилд"},"lat":39.7990175,"lon":-89.6439575,"country":"US","state":"Illinois"},{"name":"Springfield","local_names":{"en":"Springfield","ru":"Спрингфилд"},"lat":42.1018764,"lon":-72.5886727,"country":"US","state":"Massachusetts"},{"name":"Springfield","local_names":{"en":"Springfield","lt":"Springfildas","ru":"Спрингфилд","uk":"Спрингфілд"},"lat":37.1968298,"lon":-93.2946576,"country":"US","state":"Missouri"},{"name":"Springfield","local_names":{"en":"Springfield","lt":"Springfildas"},"lat":39.9234046,"lon":-83.810138,"country":"US","state":"Ohio"},{"name":"Springfield","local_names":{"ja":"スプリングフィールド","en":"Springfield"},"lat":44.0462362,"lon":-123.0220289,"country":"US","state":"Oregon"}] diff --git a/tests/Fixtures/geocoding/direct/success.meta.json b/tests/Fixtures/geocoding/direct/success.meta.json new file mode 100644 index 0000000..5e2ec63 --- /dev/null +++ b/tests/Fixtures/geocoding/direct/success.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:20:54Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "q": "Springfield,US", + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/errors/invalid-coordinates.json b/tests/Fixtures/geocoding/errors/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/geocoding/errors/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json b/tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json new file mode 100644 index 0000000..790fb28 --- /dev/null +++ b/tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Reverse geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/geo/1.0/reverse", + "query": { + "lat": 91, + "lon": 0 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/errors/missing-query.json b/tests/Fixtures/geocoding/errors/missing-query.json new file mode 100644 index 0000000..31e836f --- /dev/null +++ b/tests/Fixtures/geocoding/errors/missing-query.json @@ -0,0 +1 @@ +{"cod":"400","message":"Nothing to geocode"} diff --git a/tests/Fixtures/geocoding/errors/missing-query.meta.json b/tests/Fixtures/geocoding/errors/missing-query.meta.json new file mode 100644 index 0000000..54e02d2 --- /dev/null +++ b/tests/Fixtures/geocoding/errors/missing-query.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Direct geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/geo/1.0/direct", + "query": { + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/reverse/success.json b/tests/Fixtures/geocoding/reverse/success.json new file mode 100644 index 0000000..0664d40 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/success.json @@ -0,0 +1 @@ +[{"name":"New York County","local_names":{"be":"Нью-Ёрк","hi":"न्यूयॊर्क्","uk":"Нью-Йорк","zh":"纽约/紐約","pl":"Nowy Jork","he":"ניו יורק","vi":"New York","kn":"ನ್ಯೂಯೊರ್ಕ್","de":"New York","pt":"Nova Iorque","eo":"Novjorko","it":"New York","fr":"New York","cy":"Efrog Newydd","ca":"Nova York","ko":"뉴욕","oc":"Nòva York","en":"New York","ja":"ニューヨーク","es":"Nueva York","is":"Nýja Jórvík","te":"న్యూయొర్క్","fa":"نیویورک","ru":"Нью-Йорк","ar":"نيويورك","el":"Νέα Υόρκη","gl":"Nova York","cs":"New York"},"lat":40.7127281,"lon":-74.0060152,"country":"US","state":"New York"}] diff --git a/tests/Fixtures/geocoding/reverse/success.meta.json b/tests/Fixtures/geocoding/reverse/success.meta.json new file mode 100644 index 0000000..5eccaf9 --- /dev/null +++ b/tests/Fixtures/geocoding/reverse/success.meta.json @@ -0,0 +1,18 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "Reverse geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:20:54Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/reverse", + "query": { + "lat": 40.7128, + "lon": -74.006, + "limit": 5 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/geocoding/zip/success.json b/tests/Fixtures/geocoding/zip/success.json new file mode 100644 index 0000000..018d83f --- /dev/null +++ b/tests/Fixtures/geocoding/zip/success.json @@ -0,0 +1 @@ +{"zip":"1000-001","name":"Lisbon","lat":38.7167,"lon":-9.1333,"country":"PT"} diff --git a/tests/Fixtures/geocoding/zip/success.meta.json b/tests/Fixtures/geocoding/zip/success.meta.json new file mode 100644 index 0000000..c2d2fc0 --- /dev/null +++ b/tests/Fixtures/geocoding/zip/success.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Geocoding API", + "endpoint": "ZIP/postcode geocoding", + "apiVersion": "1.0", + "capturedAt": "2026-07-31T17:19:23Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/geo/1.0/zip", + "query": { + "zip": "1000-001,PT" + } + }, + "sanitization": [] +} From 8c7edc4a6874fe7a29a995f0fcda8052dac6f2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 18:37:30 +0100 Subject: [PATCH 013/113] feat(geocoding): add location entities --- src/Entity/Geocoding/Location.php | 85 ++++++++++++++++++ src/Entity/Geocoding/PostalLocation.php | 54 ++++++++++++ tests/Support/Fixture.php | 26 ++++++ tests/Unit/Entity/Geocoding/LocationTest.php | 86 +++++++++++++++++++ .../Entity/Geocoding/PostalLocationTest.php | 66 ++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 src/Entity/Geocoding/Location.php create mode 100644 src/Entity/Geocoding/PostalLocation.php create mode 100644 tests/Support/Fixture.php create mode 100644 tests/Unit/Entity/Geocoding/LocationTest.php create mode 100644 tests/Unit/Entity/Geocoding/PostalLocationTest.php diff --git a/src/Entity/Geocoding/Location.php b/src/Entity/Geocoding/Location.php new file mode 100644 index 0000000..48cdb2e --- /dev/null +++ b/src/Entity/Geocoding/Location.php @@ -0,0 +1,85 @@ + $localNames + */ + private function __construct( + private readonly ?string $name, + private readonly array $localNames, + private readonly ?Coordinates $coordinates, + private readonly ?string $countryCode, + private readonly ?string $state, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $latitude = $reader->nullableFloat('lat'); + $longitude = $reader->nullableFloat('lon'); + $localNames = $reader->nullableArray('local_names') ?? []; + + foreach ($localNames as $language => $name) { + if (!is_string($name)) { + throw HydrationException::invalidType( + self::class, + sprintf('local_names.%s', $language), + 'string', + $name, + ); + } + } + + return new self( + name: $reader->nullableString('name'), + localNames: $localNames, + coordinates: $latitude === null || $longitude === null + ? null + : Coordinates::from($latitude, $longitude), + countryCode: $reader->nullableString('country'), + state: $reader->nullableString('state'), + ); + } + + public function name(): ?string + { + return $this->name; + } + + /** + * @return array + */ + public function localNames(): array + { + return $this->localNames; + } + + public function localName(string $languageCode): ?string + { + return $this->localNames[$languageCode] ?? null; + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function state(): ?string + { + return $this->state; + } +} diff --git a/src/Entity/Geocoding/PostalLocation.php b/src/Entity/Geocoding/PostalLocation.php new file mode 100644 index 0000000..23ab8fa --- /dev/null +++ b/src/Entity/Geocoding/PostalLocation.php @@ -0,0 +1,54 @@ +nullableFloat('lat'); + $longitude = $reader->nullableFloat('lon'); + + return new self( + postalCode: $reader->nullableString('zip'), + name: $reader->nullableString('name'), + coordinates: $latitude === null || $longitude === null + ? null + : Coordinates::from($latitude, $longitude), + countryCode: $reader->nullableString('country'), + ); + } + + public function postalCode(): ?string + { + return $this->postalCode; + } + + public function name(): ?string + { + return $this->name; + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } +} diff --git a/tests/Support/Fixture.php b/tests/Support/Fixture.php new file mode 100644 index 0000000..106e856 --- /dev/null +++ b/tests/Support/Fixture.php @@ -0,0 +1,26 @@ +name()); + self::assertSame('Springfield', $location->localName('en')); + self::assertSame('ஸ்பிரிங்ஃபீல்ட்', $location->localName('ta')); + self::assertNull($location->localName('pt')); + self::assertSame(6, count($location->localNames())); + self::assertSame(39.7990175, $location->coordinates()?->latitude()); + self::assertSame(-89.6439575, $location->coordinates()?->longitude()); + self::assertSame('US', $location->countryCode()); + self::assertSame('Illinois', $location->state()); + } + + public function testHydratesCapturedReverseLocation(): void + { + $data = Fixture::json('geocoding/reverse/success.json'); + $location = Location::fromArray($data[0]); + + self::assertSame('New York County', $location->name()); + self::assertSame('Nova Iorque', $location->localName('pt')); + self::assertSame(40.7127281, $location->coordinates()?->latitude()); + self::assertSame(-74.0060152, $location->coordinates()?->longitude()); + self::assertSame('US', $location->countryCode()); + self::assertSame('New York', $location->state()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $location = Location::fromArray([ + 'name' => null, + 'local_names' => null, + 'lat' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($location->name()); + self::assertSame([], $location->localNames()); + self::assertNull($location->coordinates()); + self::assertNull($location->countryCode()); + self::assertNull($location->state()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Location::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'name' => [['name' => 1], 'name', 'string', 'int']; + yield 'local names' => [['local_names' => 'English'], 'local_names', 'array', 'string']; + yield 'local name' => [['local_names' => ['en' => 1]], 'local_names.en', 'string', 'int']; + yield 'latitude' => [['lat' => '39.7'], 'lat', 'int|float', 'string']; + yield 'longitude' => [['lon' => '-89.6'], 'lon', 'int|float', 'string']; + yield 'country' => [['country' => 1], 'country', 'string', 'int']; + yield 'state' => [['state' => 1], 'state', 'string', 'int']; + } +} diff --git a/tests/Unit/Entity/Geocoding/PostalLocationTest.php b/tests/Unit/Entity/Geocoding/PostalLocationTest.php new file mode 100644 index 0000000..2a1c449 --- /dev/null +++ b/tests/Unit/Entity/Geocoding/PostalLocationTest.php @@ -0,0 +1,66 @@ +postalCode()); + self::assertSame('Lisbon', $location->name()); + self::assertSame(38.7167, $location->coordinates()?->latitude()); + self::assertSame(-9.1333, $location->coordinates()?->longitude()); + self::assertSame('PT', $location->countryCode()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $location = PostalLocation::fromArray([ + 'zip' => null, + 'name' => null, + 'lat' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($location->postalCode()); + self::assertNull($location->name()); + self::assertNull($location->coordinates()); + self::assertNull($location->countryCode()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + PostalLocation::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'postal code' => [['zip' => 1000], 'zip', 'string', 'int']; + yield 'name' => [['name' => 1], 'name', 'string', 'int']; + yield 'latitude' => [['lat' => '38.7'], 'lat', 'int|float', 'string']; + yield 'longitude' => [['lon' => '-9.1'], 'lon', 'int|float', 'string']; + yield 'country' => [['country' => 1], 'country', 'string', 'int']; + } +} From 03d7825fb670ed1ca46df8da06076e86ed338717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 18:47:00 +0100 Subject: [PATCH 014/113] feat(geocoding): add location name lookup --- README.md | 19 ++++++ src/OpenWeatherMap.php | 6 ++ src/Resource/Geocoding.php | 35 +++++++++++ tests/Unit/Resource/GeocodingTest.php | 88 +++++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 src/Resource/Geocoding.php create mode 100644 tests/Unit/Resource/GeocodingTest.php diff --git a/README.md b/README.md index 757d441..defb7da 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ legacy API has been removed and the new public API is not ready for use yet. - PHP 8.1 or higher. +## Geocoding + +Direct geocoding accepts a location name and an optional result limit from one +to five: + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$locations = $api->geocoding()->byName('Springfield,US', limit: 5); + +foreach ($locations as $location) { + echo $location->name(); + echo $location->state(); + echo $location->countryCode(); +} +``` + ## License This project is licensed under the [MIT License](LICENSE). diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index cbeb4cd..10e0965 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -12,6 +12,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; +use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; class OpenWeatherMap extends Api { @@ -50,6 +51,11 @@ public function __construct(string $apiKey, array $options = []) }); } + public function geocoding(): Geocoding + { + return $this->resource(Geocoding::class); + } + private function validateApiKey(string $apiKey): void { if (trim($apiKey) === '') { diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php new file mode 100644 index 0000000..b8497be --- /dev/null +++ b/src/Resource/Geocoding.php @@ -0,0 +1,35 @@ + + */ + public function byName(string $name, ?int $limit = null): array + { + if (trim($name) === '') { + throw new \InvalidArgumentException('The location name must be a non-empty string.'); + } + + if ($limit !== null && ($limit < 1 || $limit > 5)) { + throw new \InvalidArgumentException('The result limit must be between 1 and 5.'); + } + + $query = ['q' => $name]; + + if ($limit !== null) { + $query['limit'] = $limit; + } + + return $this + ->endpoint() + ->queries($query) + ->get('/geo/1.0/direct') + ->collection(Location::class); + } +} diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php new file mode 100644 index 0000000..62f6430 --- /dev/null +++ b/tests/Unit/Resource/GeocodingTest.php @@ -0,0 +1,88 @@ +addResponse(new Response( + body: json_encode( + Fixture::json('geocoding/direct/success.json'), + JSON_THROW_ON_ERROR, + ), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $locations = $api->geocoding()->byName('Springfield,US', limit: 5); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertCount(5, $locations); + self::assertContainsOnlyInstancesOf(Location::class, $locations); + self::assertSame('Illinois', $locations[0]->state()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/direct', $request->getUri()->getPath()); + self::assertSame([ + 'q' => 'Springfield,US', + 'limit' => '5', + 'appid' => 'api-key', + ], $query); + } + + public function testAllowsAnOmittedLimitAndHydratesAnEmptyResult(): void + { + $client = new Client(); + $client->addResponse(new Response( + body: json_encode( + Fixture::json('geocoding/direct/empty.json'), + JSON_THROW_ON_ERROR, + ), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $locations = $api->geocoding()->byName('Unknown location'); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertSame([], $locations); + self::assertSame([ + 'q' => 'Unknown location', + 'appid' => 'api-key', + ], $query); + } + + #[DataProvider('invalidArguments')] + public function testRejectsInvalidArguments( + string $name, + ?int $limit, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + (new OpenWeatherMap('api-key'))->geocoding()->byName($name, $limit); + } + + public static function invalidArguments(): iterable + { + yield 'blank name' => [' ', null, 'The location name must be a non-empty string.']; + yield 'limit below minimum' => ['Lisbon', 0, 'The result limit must be between 1 and 5.']; + yield 'limit above maximum' => ['Lisbon', 6, 'The result limit must be between 1 and 5.']; + } +} From 9ee9658207f642795f802bab83e42f1d6a8ba571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 18:52:24 +0100 Subject: [PATCH 015/113] docs: organize geocoding documentation --- README.md | 23 ++++------------------- docs/geocoding.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 docs/geocoding.md diff --git a/README.md b/README.md index defb7da..5d5d30f 100644 --- a/README.md +++ b/README.md @@ -7,31 +7,16 @@ OpenWeather PHP library built on [`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk). -Version 4 is currently under development as a complete, breaking rewrite. The -legacy API has been removed and the new public API is not ready for use yet. +The library is currently under development and the public API is not ready for +use yet. ## Requirements - PHP 8.1 or higher. -## Geocoding +## Documentation -Direct geocoding accepts a location name and an optional result limit from one -to five: - -```php -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; - -$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); - -$locations = $api->geocoding()->byName('Springfield,US', limit: 5); - -foreach ($locations as $location) { - echo $location->name(); - echo $location->state(); - echo $location->countryCode(); -} -``` +- [Geocoding](docs/geocoding.md) ## License diff --git a/docs/geocoding.md b/docs/geocoding.md new file mode 100644 index 0000000..478a876 --- /dev/null +++ b/docs/geocoding.md @@ -0,0 +1,36 @@ +# Geocoding + +The Geocoding API is available on OpenWeather's standard free and paid +subscriptions. See the +[official Geocoding API documentation](https://openweathermap.org/api/geocoding-api) +for the upstream endpoint contract. + +## Lookup By Name + +Use `byName()` with OpenWeather's comma-separated location query. The optional +result limit must be between one and five; omit it to use the API default. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$locations = $api->geocoding()->byName('Springfield,US', limit: 5); +``` + +The method returns an array of `Location` entities and returns an empty array +when no locations match. Every response property may be absent or `null`. + +```php +foreach ($locations as $location) { + echo $location->name(); + echo $location->localName('en'); + echo $location->coordinates()?->latitude(); + echo $location->coordinates()?->longitude(); + echo $location->countryCode(); + echo $location->state(); +} +``` + +`localNames()` returns all available localized names as an associative array. +The available language codes depend on the returned location. From cd737b27ae3cc48194bbfa690dea756bd8dbc639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 18:57:21 +0100 Subject: [PATCH 016/113] feat(geocoding): add postal code lookup --- docs/geocoding.md | 21 +++++++++ src/Resource/Geocoding.php | 24 ++++++++++ tests/Unit/Resource/GeocodingTest.php | 68 +++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/docs/geocoding.md b/docs/geocoding.md index 478a876..a97f2e8 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -34,3 +34,24 @@ foreach ($locations as $location) { `localNames()` returns all available localized names as an associative array. The available language codes depend on the returned location. + +## Lookup By Postal Code + +Use `byPostalCode()` with a postal code and a two-letter ISO 3166 country code. +Country codes are case-insensitive. + +```php +$location = $api->geocoding()->byPostalCode( + postalCode: '1000-001', + countryCode: 'PT', +); + +echo $location->postalCode(); +echo $location->name(); +echo $location->coordinates()?->latitude(); +echo $location->coordinates()?->longitude(); +echo $location->countryCode(); +``` + +The method returns a `PostalLocation`. Every response property may be absent or +`null`. diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index b8497be..296a9f9 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\Location; +use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\PostalLocation; final class Geocoding extends Resource { @@ -32,4 +33,27 @@ public function byName(string $name, ?int $limit = null): array ->get('/geo/1.0/direct') ->collection(Location::class); } + + public function byPostalCode(string $postalCode, string $countryCode): PostalLocation + { + $postalCode = trim($postalCode); + + if ($postalCode === '') { + throw new \InvalidArgumentException('The postal code must be a non-empty string.'); + } + + $countryCode = strtoupper(trim($countryCode)); + + if (preg_match('/^[A-Z]{2}$/D', $countryCode) !== 1) { + throw new \InvalidArgumentException( + 'The country code must contain exactly two ASCII letters.', + ); + } + + return $this + ->endpoint() + ->query('zip', sprintf('%s,%s', $postalCode, $countryCode)) + ->get('/geo/1.0/zip') + ->entity(PostalLocation::class); + } } diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php index 62f6430..be2222c 100644 --- a/tests/Unit/Resource/GeocodingTest.php +++ b/tests/Unit/Resource/GeocodingTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\Location; +use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\PostalLocation; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; @@ -67,6 +68,35 @@ public function testAllowsAnOmittedLimitAndHydratesAnEmptyResult(): void ], $query); } + public function testLooksUpALocationByPostalCode(): void + { + $client = new Client(); + $client->addResponse(new Response( + body: json_encode( + Fixture::json('geocoding/zip/success.json'), + JSON_THROW_ON_ERROR, + ), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $location = $api->geocoding()->byPostalCode(' 1000-001 ', 'pt'); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertInstanceOf(PostalLocation::class, $location); + self::assertSame('1000-001', $location->postalCode()); + self::assertSame('Lisbon', $location->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/zip', $request->getUri()->getPath()); + self::assertSame([ + 'zip' => '1000-001,PT', + 'appid' => 'api-key', + ], $query); + } + #[DataProvider('invalidArguments')] public function testRejectsInvalidArguments( string $name, @@ -85,4 +115,42 @@ public static function invalidArguments(): iterable yield 'limit below minimum' => ['Lisbon', 0, 'The result limit must be between 1 and 5.']; yield 'limit above maximum' => ['Lisbon', 6, 'The result limit must be between 1 and 5.']; } + + #[DataProvider('invalidPostalCodeArguments')] + public function testRejectsInvalidPostalCodeArguments( + string $postalCode, + string $countryCode, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + (new OpenWeatherMap('api-key')) + ->geocoding() + ->byPostalCode($postalCode, $countryCode); + } + + public static function invalidPostalCodeArguments(): iterable + { + yield 'blank postal code' => [ + ' ', + 'PT', + 'The postal code must be a non-empty string.', + ]; + yield 'blank country code' => [ + '1000-001', + ' ', + 'The country code must contain exactly two ASCII letters.', + ]; + yield 'short country code' => [ + '1000-001', + 'P', + 'The country code must contain exactly two ASCII letters.', + ]; + yield 'non-letter country code' => [ + '1000-001', + 'P1', + 'The country code must contain exactly two ASCII letters.', + ]; + } } From 70bfcbf472b35b9d5578f8ccaeca327d4b4db65e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 19:15:15 +0100 Subject: [PATCH 017/113] feat(geocoding): add reverse lookup --- docs/geocoding.md | 15 ++++++ src/Resource/Geocoding.php | 40 +++++++++++++- tests/Unit/Resource/GeocodingTest.php | 77 +++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/docs/geocoding.md b/docs/geocoding.md index a97f2e8..1745be0 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -55,3 +55,18 @@ echo $location->countryCode(); The method returns a `PostalLocation`. Every response property may be absent or `null`. + +## Lookup By Coordinates + +Use `byCoordinates()` for reverse geocoding. The optional result limit must be +at least one; omit it to use the API default. + +```php +$locations = $api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 5, +); +``` + +The method returns an array of `Location` entities. diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index 296a9f9..de3d9aa 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\Location; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\PostalLocation; +use ProgrammatorDev\OpenWeatherMap\Value\Coordinates; final class Geocoding extends Resource { @@ -17,6 +18,8 @@ public function byName(string $name, ?int $limit = null): array throw new \InvalidArgumentException('The location name must be a non-empty string.'); } + // The direct endpoint documents a maximum of five results. + // https://openweathermap.org/api/geocoding-api?collection=other if ($limit !== null && ($limit < 1 || $limit > 5)) { throw new \InvalidArgumentException('The result limit must be between 1 and 5.'); } @@ -50,10 +53,45 @@ public function byPostalCode(string $postalCode, string $countryCode): PostalLoc ); } - return $this + /** @var PostalLocation $location */ + $location = $this ->endpoint() ->query('zip', sprintf('%s,%s', $postalCode, $countryCode)) ->get('/geo/1.0/zip') ->entity(PostalLocation::class); + + return $location; + } + + /** + * @return list + */ + public function byCoordinates( + float $latitude, + float $longitude, + ?int $limit = null, + ): array { + $coordinates = Coordinates::from($latitude, $longitude); + + // The reverse endpoint documents no maximum result limit. + // https://openweathermap.org/api/geocoding-api?collection=other + if ($limit !== null && $limit < 1) { + throw new \InvalidArgumentException('The result limit must be at least 1.'); + } + + $query = [ + 'lat' => $coordinates->latitude(), + 'lon' => $coordinates->longitude(), + ]; + + if ($limit !== null) { + $query['limit'] = $limit; + } + + return $this + ->endpoint() + ->queries($query) + ->get('/geo/1.0/reverse') + ->collection(Location::class); } } diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php index be2222c..ab8cb5c 100644 --- a/tests/Unit/Resource/GeocodingTest.php +++ b/tests/Unit/Resource/GeocodingTest.php @@ -97,6 +97,69 @@ public function testLooksUpALocationByPostalCode(): void ], $query); } + public function testLooksUpLocationsByCoordinates(): void + { + $client = new Client(); + $client->addResponse(new Response( + body: json_encode( + Fixture::json('geocoding/reverse/success.json'), + JSON_THROW_ON_ERROR, + ), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $locations = $api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 5, + ); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertCount(1, $locations); + self::assertContainsOnlyInstancesOf(Location::class, $locations); + self::assertSame('New York County', $locations[0]->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/geo/1.0/reverse', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '40.7128', + 'lon' => '-74.006', + 'limit' => '5', + 'appid' => 'api-key', + ], $query); + } + + public function testAllowsAnOmittedReverseLimit(): void + { + $client = new Client(); + $client->addResponse(new Response( + body: json_encode( + Fixture::json('geocoding/reverse/success.json'), + JSON_THROW_ON_ERROR, + ), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $api->geocoding()->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + ); + $request = $client->getLastRequest(); + + parse_str($request->getUri()->getQuery(), $query); + + self::assertSame([ + 'lat' => '40.7128', + 'lon' => '-74.006', + 'appid' => 'api-key', + ], $query); + } + #[DataProvider('invalidArguments')] public function testRejectsInvalidArguments( string $name, @@ -153,4 +216,18 @@ public static function invalidPostalCodeArguments(): iterable 'The country code must contain exactly two ASCII letters.', ]; } + + public function testRejectsAReverseLimitBelowOne(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The result limit must be at least 1.'); + + (new OpenWeatherMap('api-key')) + ->geocoding() + ->byCoordinates( + latitude: 40.7128, + longitude: -74.006, + limit: 0, + ); + } } From 0fff97668ef4a04ce6d7f1ca49d1b0957434db15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 31 Jul 2026 19:24:55 +0100 Subject: [PATCH 018/113] refactor(geocoding): streamline requests and test setup --- src/Resource/Geocoding.php | 4 +- tests/Support/ApiTestCase.php | 37 ++++++++ tests/Support/Fixture.php | 9 +- tests/Unit/Resource/GeocodingTest.php | 121 +++++++------------------- 4 files changed, 78 insertions(+), 93 deletions(-) create mode 100644 tests/Support/ApiTestCase.php diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index de3d9aa..233a4a9 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -14,7 +14,9 @@ final class Geocoding extends Resource */ public function byName(string $name, ?int $limit = null): array { - if (trim($name) === '') { + $name = trim($name); + + if ($name === '') { throw new \InvalidArgumentException('The location name must be a non-empty string.'); } diff --git a/tests/Support/ApiTestCase.php b/tests/Support/ApiTestCase.php new file mode 100644 index 0000000..43b2c15 --- /dev/null +++ b/tests/Support/ApiTestCase.php @@ -0,0 +1,37 @@ +client = new Client(); + $this->api = new OpenWeatherMap('api-key'); + $this->api->setup()->client($this->client); + } + + protected function respondWithFixture(string $path): void + { + $this->client->addResponse(new Response(body: Fixture::contents($path))); + } + + protected function query(RequestInterface $request): array + { + parse_str($request->getUri()->getQuery(), $query); + + return $query; + } +} diff --git a/tests/Support/Fixture.php b/tests/Support/Fixture.php index 106e856..66cb250 100644 --- a/tests/Support/Fixture.php +++ b/tests/Support/Fixture.php @@ -4,7 +4,7 @@ final class Fixture { - public static function json(string $path): array + public static function contents(string $path): string { $contents = file_get_contents(sprintf('%s/../Fixtures/%s', __DIR__, $path)); @@ -12,6 +12,13 @@ public static function json(string $path): array throw new \RuntimeException(sprintf('Unable to read fixture "%s".', $path)); } + return $contents; + } + + public static function json(string $path): array + { + $contents = self::contents($path); + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); if (!is_array($data)) { diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php index ab8cb5c..14a7ec9 100644 --- a/tests/Unit/Resource/GeocodingTest.php +++ b/tests/Unit/Resource/GeocodingTest.php @@ -2,34 +2,19 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Resource; -use Http\Mock\Client; -use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\Location; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\PostalLocation; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; -use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; +use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; -final class GeocodingTest extends TestCase +final class GeocodingTest extends ApiTestCase { public function testLooksUpLocationsByName(): void { - $client = new Client(); - $client->addResponse(new Response( - body: json_encode( - Fixture::json('geocoding/direct/success.json'), - JSON_THROW_ON_ERROR, - ), - )); + $this->respondWithFixture('geocoding/direct/success.json'); - $api = new OpenWeatherMap('api-key'); - $api->setup()->client($client); - - $locations = $api->geocoding()->byName('Springfield,US', limit: 5); - $request = $client->getLastRequest(); - - parse_str($request->getUri()->getQuery(), $query); + $locations = $this->api->geocoding()->byName(' Springfield,US ', limit: 5); + $request = $this->client->getLastRequest(); self::assertCount(5, $locations); self::assertContainsOnlyInstancesOf(Location::class, $locations); @@ -40,51 +25,29 @@ public function testLooksUpLocationsByName(): void 'q' => 'Springfield,US', 'limit' => '5', 'appid' => 'api-key', - ], $query); + ], $this->query($request)); } public function testAllowsAnOmittedLimitAndHydratesAnEmptyResult(): void { - $client = new Client(); - $client->addResponse(new Response( - body: json_encode( - Fixture::json('geocoding/direct/empty.json'), - JSON_THROW_ON_ERROR, - ), - )); - - $api = new OpenWeatherMap('api-key'); - $api->setup()->client($client); + $this->respondWithFixture('geocoding/direct/empty.json'); - $locations = $api->geocoding()->byName('Unknown location'); - $request = $client->getLastRequest(); - - parse_str($request->getUri()->getQuery(), $query); + $locations = $this->api->geocoding()->byName('Unknown location'); + $request = $this->client->getLastRequest(); self::assertSame([], $locations); self::assertSame([ 'q' => 'Unknown location', 'appid' => 'api-key', - ], $query); + ], $this->query($request)); } public function testLooksUpALocationByPostalCode(): void { - $client = new Client(); - $client->addResponse(new Response( - body: json_encode( - Fixture::json('geocoding/zip/success.json'), - JSON_THROW_ON_ERROR, - ), - )); - - $api = new OpenWeatherMap('api-key'); - $api->setup()->client($client); + $this->respondWithFixture('geocoding/zip/success.json'); - $location = $api->geocoding()->byPostalCode(' 1000-001 ', 'pt'); - $request = $client->getLastRequest(); - - parse_str($request->getUri()->getQuery(), $query); + $location = $this->api->geocoding()->byPostalCode(' 1000-001 ', 'pt'); + $request = $this->client->getLastRequest(); self::assertInstanceOf(PostalLocation::class, $location); self::assertSame('1000-001', $location->postalCode()); @@ -94,30 +57,19 @@ public function testLooksUpALocationByPostalCode(): void self::assertSame([ 'zip' => '1000-001,PT', 'appid' => 'api-key', - ], $query); + ], $this->query($request)); } public function testLooksUpLocationsByCoordinates(): void { - $client = new Client(); - $client->addResponse(new Response( - body: json_encode( - Fixture::json('geocoding/reverse/success.json'), - JSON_THROW_ON_ERROR, - ), - )); - - $api = new OpenWeatherMap('api-key'); - $api->setup()->client($client); - - $locations = $api->geocoding()->byCoordinates( + $this->respondWithFixture('geocoding/reverse/success.json'); + + $locations = $this->api->geocoding()->byCoordinates( latitude: 40.7128, longitude: -74.006, limit: 5, ); - $request = $client->getLastRequest(); - - parse_str($request->getUri()->getQuery(), $query); + $request = $this->client->getLastRequest(); self::assertCount(1, $locations); self::assertContainsOnlyInstancesOf(Location::class, $locations); @@ -129,39 +81,28 @@ public function testLooksUpLocationsByCoordinates(): void 'lon' => '-74.006', 'limit' => '5', 'appid' => 'api-key', - ], $query); + ], $this->query($request)); } public function testAllowsAnOmittedReverseLimit(): void { - $client = new Client(); - $client->addResponse(new Response( - body: json_encode( - Fixture::json('geocoding/reverse/success.json'), - JSON_THROW_ON_ERROR, - ), - )); - - $api = new OpenWeatherMap('api-key'); - $api->setup()->client($client); - - $api->geocoding()->byCoordinates( + $this->respondWithFixture('geocoding/reverse/success.json'); + + $this->api->geocoding()->byCoordinates( latitude: 40.7128, longitude: -74.006, ); - $request = $client->getLastRequest(); - - parse_str($request->getUri()->getQuery(), $query); + $request = $this->client->getLastRequest(); self::assertSame([ 'lat' => '40.7128', 'lon' => '-74.006', 'appid' => 'api-key', - ], $query); + ], $this->query($request)); } - #[DataProvider('invalidArguments')] - public function testRejectsInvalidArguments( + #[DataProvider('invalidNameArguments')] + public function testRejectsInvalidNameArguments( string $name, ?int $limit, string $message, @@ -169,10 +110,10 @@ public function testRejectsInvalidArguments( $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - (new OpenWeatherMap('api-key'))->geocoding()->byName($name, $limit); + $this->api->geocoding()->byName($name, $limit); } - public static function invalidArguments(): iterable + public static function invalidNameArguments(): iterable { yield 'blank name' => [' ', null, 'The location name must be a non-empty string.']; yield 'limit below minimum' => ['Lisbon', 0, 'The result limit must be between 1 and 5.']; @@ -188,8 +129,7 @@ public function testRejectsInvalidPostalCodeArguments( $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - (new OpenWeatherMap('api-key')) - ->geocoding() + $this->api->geocoding() ->byPostalCode($postalCode, $countryCode); } @@ -222,8 +162,7 @@ public function testRejectsAReverseLimitBelowOne(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The result limit must be at least 1.'); - (new OpenWeatherMap('api-key')) - ->geocoding() + $this->api->geocoding() ->byCoordinates( latitude: 40.7128, longitude: -74.006, From f1566df858b62c1f6a9a41d580cc120a405f10dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 09:20:42 +0100 Subject: [PATCH 019/113] refactor(errors): consolidate status handling --- src/OpenWeatherMap.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 10e0965..9d2a291 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -37,15 +37,11 @@ public function __construct(string $apiKey, array $options = []) $this->auth()->query('appid', $apiKey); $this->responses()->json(); - // Exact status handlers run first. - // SDK conditional handlers run for every response, - // so null explicitly means that no API error matched. - $this->errors()->statuses([ - 400 => static fn (ErrorContext $context): BadRequestException => BadRequestException::fromContext($context), - 401 => static fn (ErrorContext $context): UnauthorizedException => UnauthorizedException::fromContext($context), - 404 => static fn (ErrorContext $context): NotFoundException => NotFoundException::fromContext($context), - 429 => static fn (ErrorContext $context): TooManyRequestsException => TooManyRequestsException::fromContext($context), - ])->when(static fn (ErrorContext $context): ?ApiException => match (true) { + $this->errors()->when(static fn (ErrorContext $context): ?ApiException => match (true) { + $context->statusCode() === 400 => BadRequestException::fromContext($context), + $context->statusCode() === 401 => UnauthorizedException::fromContext($context), + $context->statusCode() === 404 => NotFoundException::fromContext($context), + $context->statusCode() === 429 => TooManyRequestsException::fromContext($context), $context->statusCode() >= 400 && $context->statusCode() <= 599 => UnexpectedErrorException::fromContext($context), default => null, }); From a63bdde65a7c37706cd3cb0e5be6ebb69aee7b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 09:36:05 +0100 Subject: [PATCH 020/113] refactor(geocoding): expose coordinates and share validation --- docs/geocoding.md | 8 +- src/Entity/Geocoding/Location.php | 20 ++--- src/Entity/Geocoding/PostalLocation.php | 21 ++--- src/OpenWeatherMap.php | 30 +++---- src/Resource/Concern/WithLanguage.php | 5 +- src/Resource/Geocoding.php | 29 +++---- src/Validation/Assert.php | 74 ++++++++++++++++++ src/Value/Coordinates.php | 38 --------- tests/Unit/Entity/Geocoding/LocationTest.php | 12 +-- .../Entity/Geocoding/PostalLocationTest.php | 8 +- tests/Unit/OpenWeatherMapTest.php | 6 +- .../Concern/FluentConfigurationTest.php | 4 +- tests/Unit/Resource/GeocodingTest.php | 29 +++++++ tests/Unit/Value/CoordinatesTest.php | 78 ------------------- 14 files changed, 175 insertions(+), 187 deletions(-) create mode 100644 src/Validation/Assert.php delete mode 100644 src/Value/Coordinates.php delete mode 100644 tests/Unit/Value/CoordinatesTest.php diff --git a/docs/geocoding.md b/docs/geocoding.md index 1745be0..3c7a1ab 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -25,8 +25,8 @@ when no locations match. Every response property may be absent or `null`. foreach ($locations as $location) { echo $location->name(); echo $location->localName('en'); - echo $location->coordinates()?->latitude(); - echo $location->coordinates()?->longitude(); + echo $location->latitude(); + echo $location->longitude(); echo $location->countryCode(); echo $location->state(); } @@ -48,8 +48,8 @@ $location = $api->geocoding()->byPostalCode( echo $location->postalCode(); echo $location->name(); -echo $location->coordinates()?->latitude(); -echo $location->coordinates()?->longitude(); +echo $location->latitude(); +echo $location->longitude(); echo $location->countryCode(); ``` diff --git a/src/Entity/Geocoding/Location.php b/src/Entity/Geocoding/Location.php index 48cdb2e..8e245fc 100644 --- a/src/Entity/Geocoding/Location.php +++ b/src/Entity/Geocoding/Location.php @@ -6,7 +6,6 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; -use ProgrammatorDev\OpenWeatherMap\Value\Coordinates; final class Location implements EntityInterface { @@ -16,7 +15,8 @@ final class Location implements EntityInterface private function __construct( private readonly ?string $name, private readonly array $localNames, - private readonly ?Coordinates $coordinates, + private readonly ?float $latitude, + private readonly ?float $longitude, private readonly ?string $countryCode, private readonly ?string $state, ) {} @@ -24,8 +24,6 @@ private function __construct( public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $latitude = $reader->nullableFloat('lat'); - $longitude = $reader->nullableFloat('lon'); $localNames = $reader->nullableArray('local_names') ?? []; foreach ($localNames as $language => $name) { @@ -42,9 +40,8 @@ public static function fromArray(array $data, ?Context $context = null): static return new self( name: $reader->nullableString('name'), localNames: $localNames, - coordinates: $latitude === null || $longitude === null - ? null - : Coordinates::from($latitude, $longitude), + latitude: $reader->nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), countryCode: $reader->nullableString('country'), state: $reader->nullableString('state'), ); @@ -68,9 +65,14 @@ public function localName(string $languageCode): ?string return $this->localNames[$languageCode] ?? null; } - public function coordinates(): ?Coordinates + public function latitude(): ?float { - return $this->coordinates; + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; } public function countryCode(): ?string diff --git a/src/Entity/Geocoding/PostalLocation.php b/src/Entity/Geocoding/PostalLocation.php index 23ab8fa..e83e569 100644 --- a/src/Entity/Geocoding/PostalLocation.php +++ b/src/Entity/Geocoding/PostalLocation.php @@ -5,29 +5,25 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; -use ProgrammatorDev\OpenWeatherMap\Value\Coordinates; final class PostalLocation implements EntityInterface { private function __construct( private readonly ?string $postalCode, private readonly ?string $name, - private readonly ?Coordinates $coordinates, + private readonly ?float $latitude, + private readonly ?float $longitude, private readonly ?string $countryCode, ) {} public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $latitude = $reader->nullableFloat('lat'); - $longitude = $reader->nullableFloat('lon'); - return new self( postalCode: $reader->nullableString('zip'), name: $reader->nullableString('name'), - coordinates: $latitude === null || $longitude === null - ? null - : Coordinates::from($latitude, $longitude), + latitude: $reader->nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), countryCode: $reader->nullableString('country'), ); } @@ -42,9 +38,14 @@ public function name(): ?string return $this->name; } - public function coordinates(): ?Coordinates + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float { - return $this->coordinates; + return $this->longitude; } public function countryCode(): ?string diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 9d2a291..03f2e5a 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -13,6 +13,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; +use ProgrammatorDev\OpenWeatherMap\Validation\Assert; class OpenWeatherMap extends Api { @@ -25,8 +26,8 @@ public function __construct(string $apiKey, array $options = []) { parent::__construct(); - $this->validateApiKey($apiKey); - $this->validateOptions($options); + $apiKey = $this->validateApiKey($apiKey); + $options = $this->validateOptions($options); $this->config($options, defaults: [ self::OPTION_UNITS => Units::METRIC, @@ -52,14 +53,12 @@ public function geocoding(): Geocoding return $this->resource(Geocoding::class); } - private function validateApiKey(string $apiKey): void + private function validateApiKey(string $apiKey): string { - if (trim($apiKey) === '') { - throw new \InvalidArgumentException('The API key must be a non-empty string.'); - } + return Assert::notBlank($apiKey, 'API key'); } - private function validateOptions(array $options): void + private function validateOptions(array $options): array { $unknownOptions = array_diff( array_keys($options), @@ -79,8 +78,12 @@ private function validateOptions(array $options): void } if (array_key_exists(self::OPTION_LANGUAGE, $options)) { - $this->validateLanguage($options[self::OPTION_LANGUAGE]); + $options[self::OPTION_LANGUAGE] = $this->validateLanguage( + $options[self::OPTION_LANGUAGE], + ); } + + return $options; } private function validateUnits(mixed $units): void @@ -94,7 +97,7 @@ private function validateUnits(mixed $units): void } } - private function validateLanguage(mixed $language): void + private function validateLanguage(mixed $language): Language|string { if (!$language instanceof Language && !is_string($language)) { throw new \InvalidArgumentException(sprintf( @@ -104,11 +107,8 @@ private function validateLanguage(mixed $language): void )); } - if (is_string($language) && trim($language) === '') { - throw new \InvalidArgumentException(sprintf( - 'The "%s" option must not be an empty string.', - self::OPTION_LANGUAGE - )); - } + return is_string($language) + ? Assert::notBlank($language, sprintf('"%s" option', self::OPTION_LANGUAGE)) + : $language; } } diff --git a/src/Resource/Concern/WithLanguage.php b/src/Resource/Concern/WithLanguage.php index 331a153..e8b056c 100644 --- a/src/Resource/Concern/WithLanguage.php +++ b/src/Resource/Concern/WithLanguage.php @@ -4,6 +4,7 @@ use ProgrammatorDev\OpenWeatherMap\Enum\Language; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; +use ProgrammatorDev\OpenWeatherMap\Validation\Assert; trait WithLanguage { @@ -13,8 +14,8 @@ trait WithLanguage public function withLanguage(Language|string $language): static { // Raw strings allow new OpenWeather language codes without an enum release. - if (is_string($language) && trim($language) === '') { - throw new \InvalidArgumentException('The language must not be an empty string.'); + if (is_string($language)) { + $language = Assert::notBlank($language, 'language'); } $clone = clone $this; diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index 233a4a9..8576280 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -5,7 +5,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\Location; use ProgrammatorDev\OpenWeatherMap\Entity\Geocoding\PostalLocation; -use ProgrammatorDev\OpenWeatherMap\Value\Coordinates; +use ProgrammatorDev\OpenWeatherMap\Validation\Assert; final class Geocoding extends Resource { @@ -14,16 +14,12 @@ final class Geocoding extends Resource */ public function byName(string $name, ?int $limit = null): array { - $name = trim($name); - - if ($name === '') { - throw new \InvalidArgumentException('The location name must be a non-empty string.'); - } + $name = Assert::notBlank($name, 'location name'); // The direct endpoint documents a maximum of five results. // https://openweathermap.org/api/geocoding-api?collection=other - if ($limit !== null && ($limit < 1 || $limit > 5)) { - throw new \InvalidArgumentException('The result limit must be between 1 and 5.'); + if ($limit !== null) { + $limit = Assert::integerBetween($limit, 1, 5, 'result limit'); } $query = ['q' => $name]; @@ -41,11 +37,7 @@ public function byName(string $name, ?int $limit = null): array public function byPostalCode(string $postalCode, string $countryCode): PostalLocation { - $postalCode = trim($postalCode); - - if ($postalCode === '') { - throw new \InvalidArgumentException('The postal code must be a non-empty string.'); - } + $postalCode = Assert::notBlank($postalCode, 'postal code'); $countryCode = strtoupper(trim($countryCode)); @@ -73,17 +65,18 @@ public function byCoordinates( float $longitude, ?int $limit = null, ): array { - $coordinates = Coordinates::from($latitude, $longitude); + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); // The reverse endpoint documents no maximum result limit. // https://openweathermap.org/api/geocoding-api?collection=other - if ($limit !== null && $limit < 1) { - throw new \InvalidArgumentException('The result limit must be at least 1.'); + if ($limit !== null) { + $limit = Assert::positiveInteger($limit, 'result limit'); } $query = [ - 'lat' => $coordinates->latitude(), - 'lon' => $coordinates->longitude(), + 'lat' => $latitude, + 'lon' => $longitude, ]; if ($limit !== null) { diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php new file mode 100644 index 0000000..536a6f8 --- /dev/null +++ b/src/Validation/Assert.php @@ -0,0 +1,74 @@ + 90) { + throw new \InvalidArgumentException( + 'Latitude must be a finite number between -90 and 90.', + ); + } + + return $latitude; + } + + public static function longitude(float $longitude): float + { + if (!is_finite($longitude) || $longitude < -180 || $longitude > 180) { + throw new \InvalidArgumentException( + 'Longitude must be a finite number between -180 and 180.', + ); + } + + return $longitude; + } + + public static function positiveInteger(int $value, string $name): int + { + if ($value < 1) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be at least 1.', + $name, + )); + } + + return $value; + } + + public static function integerBetween( + int $value, + int $minimum, + int $maximum, + string $name, + ): int { + if ($value < $minimum || $value > $maximum) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be between %d and %d.', + $name, + $minimum, + $maximum, + )); + } + + return $value; + } +} diff --git a/src/Value/Coordinates.php b/src/Value/Coordinates.php deleted file mode 100644 index b666f5d..0000000 --- a/src/Value/Coordinates.php +++ /dev/null @@ -1,38 +0,0 @@ - 90) { - throw new \InvalidArgumentException( - 'Latitude must be a finite number between -90 and 90.', - ); - } - - if (!is_finite($longitude) || $longitude < -180 || $longitude > 180) { - throw new \InvalidArgumentException( - 'Longitude must be a finite number between -180 and 180.', - ); - } - } - - public static function from(float $latitude, float $longitude): self - { - return new self($latitude, $longitude); - } - - public function latitude(): float - { - return $this->latitude; - } - - public function longitude(): float - { - return $this->longitude; - } -} diff --git a/tests/Unit/Entity/Geocoding/LocationTest.php b/tests/Unit/Entity/Geocoding/LocationTest.php index aa24f7e..e7ea4e4 100644 --- a/tests/Unit/Entity/Geocoding/LocationTest.php +++ b/tests/Unit/Entity/Geocoding/LocationTest.php @@ -20,8 +20,8 @@ public function testHydratesCapturedDirectLocation(): void self::assertSame('ஸ்பிரிங்ஃபீல்ட்', $location->localName('ta')); self::assertNull($location->localName('pt')); self::assertSame(6, count($location->localNames())); - self::assertSame(39.7990175, $location->coordinates()?->latitude()); - self::assertSame(-89.6439575, $location->coordinates()?->longitude()); + self::assertSame(39.7990175, $location->latitude()); + self::assertSame(-89.6439575, $location->longitude()); self::assertSame('US', $location->countryCode()); self::assertSame('Illinois', $location->state()); } @@ -33,8 +33,8 @@ public function testHydratesCapturedReverseLocation(): void self::assertSame('New York County', $location->name()); self::assertSame('Nova Iorque', $location->localName('pt')); - self::assertSame(40.7127281, $location->coordinates()?->latitude()); - self::assertSame(-74.0060152, $location->coordinates()?->longitude()); + self::assertSame(40.7127281, $location->latitude()); + self::assertSame(-74.0060152, $location->longitude()); self::assertSame('US', $location->countryCode()); self::assertSame('New York', $location->state()); } @@ -45,12 +45,14 @@ public function testToleratesMissingNullAndUnknownFields(): void 'name' => null, 'local_names' => null, 'lat' => null, + 'lon' => -9.1, 'unknown' => new \stdClass(), ]); self::assertNull($location->name()); self::assertSame([], $location->localNames()); - self::assertNull($location->coordinates()); + self::assertNull($location->latitude()); + self::assertSame(-9.1, $location->longitude()); self::assertNull($location->countryCode()); self::assertNull($location->state()); } diff --git a/tests/Unit/Entity/Geocoding/PostalLocationTest.php b/tests/Unit/Entity/Geocoding/PostalLocationTest.php index 2a1c449..ca29506 100644 --- a/tests/Unit/Entity/Geocoding/PostalLocationTest.php +++ b/tests/Unit/Entity/Geocoding/PostalLocationTest.php @@ -17,8 +17,8 @@ public function testHydratesCapturedPostalLocation(): void self::assertSame('1000-001', $location->postalCode()); self::assertSame('Lisbon', $location->name()); - self::assertSame(38.7167, $location->coordinates()?->latitude()); - self::assertSame(-9.1333, $location->coordinates()?->longitude()); + self::assertSame(38.7167, $location->latitude()); + self::assertSame(-9.1333, $location->longitude()); self::assertSame('PT', $location->countryCode()); } @@ -28,12 +28,14 @@ public function testToleratesMissingNullAndUnknownFields(): void 'zip' => null, 'name' => null, 'lat' => null, + 'lon' => -9.1, 'unknown' => new \stdClass(), ]); self::assertNull($location->postalCode()); self::assertNull($location->name()); - self::assertNull($location->coordinates()); + self::assertNull($location->latitude()); + self::assertSame(-9.1, $location->longitude()); self::assertNull($location->countryCode()); } diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php index 1fef368..7c02c06 100644 --- a/tests/Unit/OpenWeatherMapTest.php +++ b/tests/Unit/OpenWeatherMapTest.php @@ -41,7 +41,7 @@ public function testAcceptsConfiguredUnitsAndKnownLanguage(): void public function testAcceptsAnArbitraryNonEmptyLanguageCode(): void { $api = new OpenWeatherMap('api-key', [ - OpenWeatherMap::OPTION_LANGUAGE => 'future_language', + OpenWeatherMap::OPTION_LANGUAGE => ' future_language ', ]); self::assertSame('future_language', $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); @@ -52,7 +52,7 @@ public function testConfiguresBaseUrlQueryAuthenticationAndJsonDecoding(): void $client = new Client(); $client->addResponse(new Response(body: '{"ok":true}')); - $api = new OpenWeatherMap('secret'); + $api = new OpenWeatherMap(' secret '); $api->setup()->client($client); $response = $api->send(Method::GET, '/data/2.5/weather'); @@ -202,6 +202,6 @@ public static function invalidConfigurationProvider(): iterable yield 'unknown option' => ['api-key', ['unsupported' => true], 'Unknown OpenWeatherMap option: unsupported.']; yield 'invalid units' => ['api-key', [OpenWeatherMap::OPTION_UNITS => 'metric'], 'The "units" option must be an instance of']; yield 'invalid language type' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => 123], 'The "language" option must be an instance of']; - yield 'blank language' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => ' '], 'The "language" option must not be an empty string.']; + yield 'blank language' => ['api-key', [OpenWeatherMap::OPTION_LANGUAGE => ' '], 'The "language" option must be a non-empty string.']; } } diff --git a/tests/Unit/Resource/Concern/FluentConfigurationTest.php b/tests/Unit/Resource/Concern/FluentConfigurationTest.php index a7f69af..b320ea6 100644 --- a/tests/Unit/Resource/Concern/FluentConfigurationTest.php +++ b/tests/Unit/Resource/Concern/FluentConfigurationTest.php @@ -43,7 +43,7 @@ public function testLaterOverridesDoNotMutateEarlierClones(): void public function testItAcceptsAnArbitraryLanguageCode(): void { - $configured = $this->resource->withLanguage('future_language'); + $configured = $this->resource->withLanguage(' future_language '); self::assertSame('future_language', $configured->resolvedLanguageValue()); } @@ -51,7 +51,7 @@ public function testItAcceptsAnArbitraryLanguageCode(): void public function testItRejectsABlankLanguageCode(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The language must not be an empty string.'); + $this->expectExceptionMessage('The language must be a non-empty string.'); $this->resource->withLanguage(' '); } diff --git a/tests/Unit/Resource/GeocodingTest.php b/tests/Unit/Resource/GeocodingTest.php index 14a7ec9..18c111b 100644 --- a/tests/Unit/Resource/GeocodingTest.php +++ b/tests/Unit/Resource/GeocodingTest.php @@ -169,4 +169,33 @@ public function testRejectsAReverseLimitBelowOne(): void limit: 0, ); } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->geocoding()->byCoordinates($latitude, $longitude); + } + + public static function invalidCoordinates(): iterable + { + $latitudeMessage = 'Latitude must be a finite number between -90 and 90.'; + $longitudeMessage = 'Longitude must be a finite number between -180 and 180.'; + + yield 'latitude below minimum' => [-90.0001, 0, $latitudeMessage]; + yield 'latitude above maximum' => [90.0001, 0, $latitudeMessage]; + yield 'latitude is negative infinity' => [-INF, 0, $latitudeMessage]; + yield 'latitude is positive infinity' => [INF, 0, $latitudeMessage]; + yield 'latitude is not a number' => [NAN, 0, $latitudeMessage]; + yield 'longitude below minimum' => [0, -180.0001, $longitudeMessage]; + yield 'longitude above maximum' => [0, 180.0001, $longitudeMessage]; + yield 'longitude is negative infinity' => [0, -INF, $longitudeMessage]; + yield 'longitude is positive infinity' => [0, INF, $longitudeMessage]; + yield 'longitude is not a number' => [0, NAN, $longitudeMessage]; + } } diff --git a/tests/Unit/Value/CoordinatesTest.php b/tests/Unit/Value/CoordinatesTest.php deleted file mode 100644 index 428ee67..0000000 --- a/tests/Unit/Value/CoordinatesTest.php +++ /dev/null @@ -1,78 +0,0 @@ -latitude()); - self::assertSame(-9.1366, $coordinates->longitude()); - } - - public function testItAcceptsCoordinateBoundaries(): void - { - $minimum = Coordinates::from(latitude: -90, longitude: -180); - $maximum = Coordinates::from(latitude: 90, longitude: 180); - - self::assertSame(-90.0, $minimum->latitude()); - self::assertSame(-180.0, $minimum->longitude()); - self::assertSame(90.0, $maximum->latitude()); - self::assertSame(180.0, $maximum->longitude()); - } - - #[DataProvider('invalidLatitudes')] - public function testItRejectsInvalidLatitudes(float $latitude): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Latitude must be a finite number between -90 and 90.', - ); - - Coordinates::from($latitude, 0); - } - - /** - * @return iterable - */ - public static function invalidLatitudes(): iterable - { - yield 'below minimum' => [-90.0001]; - yield 'above maximum' => [90.0001]; - yield 'negative infinity' => [-INF]; - yield 'positive infinity' => [INF]; - yield 'not a number' => [NAN]; - } - - #[DataProvider('invalidLongitudes')] - public function testItRejectsInvalidLongitudes(float $longitude): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Longitude must be a finite number between -180 and 180.', - ); - - Coordinates::from(0, $longitude); - } - - /** - * @return iterable - */ - public static function invalidLongitudes(): iterable - { - yield 'below minimum' => [-180.0001]; - yield 'above maximum' => [180.0001]; - yield 'negative infinity' => [-INF]; - yield 'positive infinity' => [INF]; - yield 'not a number' => [NAN]; - } -} From e514576dd2839dd1def89a4b17c19e4f0005ce45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 09:39:20 +0100 Subject: [PATCH 021/113] refactor(geocoding): simplify optional queries --- src/Resource/Geocoding.php | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index 8576280..b25a3e3 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -22,15 +22,12 @@ public function byName(string $name, ?int $limit = null): array $limit = Assert::integerBetween($limit, 1, 5, 'result limit'); } - $query = ['q' => $name]; - - if ($limit !== null) { - $query['limit'] = $limit; - } - return $this ->endpoint() - ->queries($query) + ->queries([ + 'q' => $name, + 'limit' => $limit, + ]) ->get('/geo/1.0/direct') ->collection(Location::class); } @@ -74,18 +71,13 @@ public function byCoordinates( $limit = Assert::positiveInteger($limit, 'result limit'); } - $query = [ - 'lat' => $latitude, - 'lon' => $longitude, - ]; - - if ($limit !== null) { - $query['limit'] = $limit; - } - return $this ->endpoint() - ->queries($query) + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'limit' => $limit, + ]) ->get('/geo/1.0/reverse') ->collection(Location::class); } From 62c7bb6b142d150712b01636e089ac8d6628f7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 09:44:00 +0100 Subject: [PATCH 022/113] refactor(validation): centralize country code assertion --- src/Resource/Geocoding.php | 9 +-------- src/Validation/Assert.php | 13 +++++++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Resource/Geocoding.php b/src/Resource/Geocoding.php index b25a3e3..3fcf6ec 100644 --- a/src/Resource/Geocoding.php +++ b/src/Resource/Geocoding.php @@ -35,14 +35,7 @@ public function byName(string $name, ?int $limit = null): array public function byPostalCode(string $postalCode, string $countryCode): PostalLocation { $postalCode = Assert::notBlank($postalCode, 'postal code'); - - $countryCode = strtoupper(trim($countryCode)); - - if (preg_match('/^[A-Z]{2}$/D', $countryCode) !== 1) { - throw new \InvalidArgumentException( - 'The country code must contain exactly two ASCII letters.', - ); - } + $countryCode = Assert::countryCode($countryCode); /** @var PostalLocation $location */ $location = $this diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index 536a6f8..31f824f 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -42,6 +42,19 @@ public static function longitude(float $longitude): float return $longitude; } + public static function countryCode(string $countryCode): string + { + $countryCode = strtoupper(trim($countryCode)); + + if (preg_match('/^[A-Z]{2}$/D', $countryCode) !== 1) { + throw new \InvalidArgumentException( + 'The country code must contain exactly two ASCII letters.', + ); + } + + return $countryCode; + } + public static function positiveInteger(int $value, string $name): int { if ($value < 1) { From af53b16394caa4622e7f4682b0dbc5cb13b8338d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 10:09:17 +0100 Subject: [PATCH 023/113] test(weather): add captured current and forecast fixtures --- .../weather/current/invalid-coordinates.json | 1 + .../current/invalid-coordinates.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/current/rain.json | 1 + tests/Fixtures/weather/current/rain.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/current/snow.json | 1 + tests/Fixtures/weather/current/snow.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/current/success.json | 1 + .../weather/current/success.meta.json | 19 +++++++++++++++++++ .../weather/forecast/invalid-coordinates.json | 1 + .../forecast/invalid-coordinates.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/forecast/rain.json | 1 + .../Fixtures/weather/forecast/rain.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/forecast/snow.json | 1 + .../Fixtures/weather/forecast/snow.meta.json | 19 +++++++++++++++++++ tests/Fixtures/weather/forecast/success.json | 1 + .../weather/forecast/success.meta.json | 19 +++++++++++++++++++ 16 files changed, 160 insertions(+) create mode 100644 tests/Fixtures/weather/current/invalid-coordinates.json create mode 100644 tests/Fixtures/weather/current/invalid-coordinates.meta.json create mode 100644 tests/Fixtures/weather/current/rain.json create mode 100644 tests/Fixtures/weather/current/rain.meta.json create mode 100644 tests/Fixtures/weather/current/snow.json create mode 100644 tests/Fixtures/weather/current/snow.meta.json create mode 100644 tests/Fixtures/weather/current/success.json create mode 100644 tests/Fixtures/weather/current/success.meta.json create mode 100644 tests/Fixtures/weather/forecast/invalid-coordinates.json create mode 100644 tests/Fixtures/weather/forecast/invalid-coordinates.meta.json create mode 100644 tests/Fixtures/weather/forecast/rain.json create mode 100644 tests/Fixtures/weather/forecast/rain.meta.json create mode 100644 tests/Fixtures/weather/forecast/snow.json create mode 100644 tests/Fixtures/weather/forecast/snow.meta.json create mode 100644 tests/Fixtures/weather/forecast/success.json create mode 100644 tests/Fixtures/weather/forecast/success.meta.json diff --git a/tests/Fixtures/weather/current/invalid-coordinates.json b/tests/Fixtures/weather/current/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/weather/current/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/weather/current/invalid-coordinates.meta.json b/tests/Fixtures/weather/current/invalid-coordinates.meta.json new file mode 100644 index 0000000..6b270f9 --- /dev/null +++ b/tests/Fixtures/weather/current/invalid-coordinates.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:50:07Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 91, + "lon": 0, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/rain.json b/tests/Fixtures/weather/current/rain.json new file mode 100644 index 0000000..4b00710 --- /dev/null +++ b/tests/Fixtures/weather/current/rain.json @@ -0,0 +1 @@ +{"coord":{"lon":120.9842,"lat":14.5995},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"base":"stations","main":{"temp":29.5,"feels_like":36.42,"temp_min":26.73,"temp_max":29.51,"pressure":1007,"humidity":81,"sea_level":1007,"grnd_level":1010},"visibility":9420,"wind":{"speed":1.98,"deg":255,"gust":2.31},"rain":{"1h":2.47},"clouds":{"all":100},"dt":1785574377,"sys":{"type":2,"id":2008256,"country":"PH","sunrise":1785533950,"sunset":1785579932},"timezone":28800,"id":1692184,"name":"Quiapo District","cod":200} diff --git a/tests/Fixtures/weather/current/rain.meta.json b/tests/Fixtures/weather/current/rain.meta.json new file mode 100644 index 0000000..edc36cf --- /dev/null +++ b/tests/Fixtures/weather/current/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:53:44Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/snow.json b/tests/Fixtures/weather/current/snow.json new file mode 100644 index 0000000..27a8957 --- /dev/null +++ b/tests/Fixtures/weather/current/snow.json @@ -0,0 +1 @@ +{"coord":{"lon":-71.58,"lat":-38.4},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"base":"stations","main":{"temp":-1.44,"feels_like":-6.29,"temp_min":-1.44,"temp_max":-1.44,"pressure":1014,"humidity":99,"sea_level":1014,"grnd_level":858},"wind":{"speed":4.33,"deg":312,"gust":14.6},"snow":{"1h":1.37},"clouds":{"all":100},"dt":1785574532,"sys":{"country":"CL","sunrise":1785584858,"sunset":1785621452},"timezone":-14400,"id":3892935,"name":"Curacautín","cod":200} diff --git a/tests/Fixtures/weather/current/snow.meta.json b/tests/Fixtures/weather/current/snow.meta.json new file mode 100644 index 0000000..2203a55 --- /dev/null +++ b/tests/Fixtures/weather/current/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:55:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/current/success.json b/tests/Fixtures/weather/current/success.json new file mode 100644 index 0000000..b2b538e --- /dev/null +++ b/tests/Fixtures/weather/current/success.json @@ -0,0 +1 @@ +{"coord":{"lon":-9.1393,"lat":38.7223},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"base":"stations","main":{"temp":22.55,"feels_like":22.87,"temp_min":21.48,"temp_max":23.94,"pressure":1016,"humidity":77,"sea_level":1016,"grnd_level":1006},"visibility":10000,"wind":{"speed":4.47,"deg":190,"gust":7.6},"clouds":{"all":48},"dt":1785573885,"sys":{"type":2,"id":2016751,"country":"PT","sunrise":1785562660,"sunset":1785613679},"timezone":3600,"id":8012502,"name":"Socorro","cod":200} diff --git a/tests/Fixtures/weather/current/success.meta.json b/tests/Fixtures/weather/current/success.meta.json new file mode 100644 index 0000000..60bc6c8 --- /dev/null +++ b/tests/Fixtures/weather/current/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Current Weather API", + "endpoint": "Current weather by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T08:49:58Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/weather", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/invalid-coordinates.json b/tests/Fixtures/weather/forecast/invalid-coordinates.json new file mode 100644 index 0000000..a53e941 --- /dev/null +++ b/tests/Fixtures/weather/forecast/invalid-coordinates.json @@ -0,0 +1 @@ +{"cod":"400","message":"wrong latitude"} diff --git a/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json b/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json new file mode 100644 index 0000000..ce9a5dc --- /dev/null +++ b/tests/Fixtures/weather/forecast/invalid-coordinates.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:00:36Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 91, + "lon": 0, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/rain.json b/tests/Fixtures/weather/forecast/rain.json new file mode 100644 index 0000000..49275c4 --- /dev/null +++ b/tests/Fixtures/weather/forecast/rain.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":29.51,"feels_like":36.45,"temp_min":28.55,"temp_max":29.51,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":81,"temp_kf":0.96,"dew_point":25.9},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":1.98,"deg":255,"gust":2.31},"visibility":9420,"pop":1,"rain":{"3h":5.49},"sys":{"pod":"d"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":29.19,"feels_like":35.28,"temp_min":28.54,"temp_max":29.19,"pressure":1008,"sea_level":1008,"grnd_level":1012,"humidity":80,"temp_kf":0.65,"dew_point":25.38},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":99},"wind":{"speed":0.88,"deg":219,"gust":1.63},"visibility":10000,"pop":1,"rain":{"3h":3.16},"sys":{"pod":"n"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":28.16,"feels_like":32.5,"temp_min":27.48,"temp_max":28.16,"pressure":1008,"sea_level":1008,"grnd_level":1012,"humidity":80,"temp_kf":0.68,"dew_point":24.38},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":98},"wind":{"speed":1.46,"deg":15,"gust":1.52},"visibility":10000,"pop":0.94,"rain":{"3h":1.61},"sys":{"pod":"n"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":27.1,"feels_like":30.17,"temp_min":27.1,"temp_max":27.1,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":23.93},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":97},"wind":{"speed":2.48,"deg":9,"gust":3.43},"visibility":10000,"pop":0.9,"rain":{"3h":0.3},"sys":{"pod":"n"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":26.2,"feels_like":26.2,"temp_min":26.2,"temp_max":26.2,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":86,"temp_kf":0,"dew_point":23.84},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":3.95,"deg":6,"gust":6.39},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":26.9,"feels_like":29.88,"temp_min":26.9,"temp_max":26.9,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":84,"temp_kf":0,"dew_point":24.04},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":3.25,"deg":347,"gust":5.04},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":28.02,"feels_like":31.84,"temp_min":28.02,"temp_max":28.02,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":78,"temp_kf":0,"dew_point":23.92},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":1.99,"deg":316,"gust":2.93},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":28.23,"feels_like":32.03,"temp_min":28.23,"temp_max":28.23,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":76,"temp_kf":0,"dew_point":23.88},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":0.46,"deg":216,"gust":0.62},"visibility":10000,"pop":0.04,"sys":{"pod":"d"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":28.24,"feels_like":32.21,"temp_min":28.24,"temp_max":28.24,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":23.98},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.32,"deg":206,"gust":2.92},"visibility":10000,"pop":1,"rain":{"3h":2.62},"sys":{"pod":"d"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":26.45,"feels_like":26.45,"temp_min":26.45,"temp_max":26.45,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":87,"temp_kf":0,"dew_point":24.29},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.52,"deg":309,"gust":4.23},"visibility":5934,"pop":1,"rain":{"3h":6.62},"sys":{"pod":"n"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":25.29,"feels_like":26.3,"temp_min":25.29,"temp_max":25.29,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":93,"temp_kf":0,"dew_point":24.19},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.98,"deg":294,"gust":3.22},"visibility":4067,"pop":1,"rain":{"3h":9.02},"sys":{"pod":"n"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":24.91,"feels_like":25.81,"temp_min":24.91,"temp_max":24.91,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":90,"temp_kf":0,"dew_point":23.23},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.24,"deg":340,"gust":6.23},"visibility":10000,"pop":1,"rain":{"3h":4.96},"sys":{"pod":"n"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":24.75,"feels_like":25.63,"temp_min":24.75,"temp_max":24.75,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":90,"temp_kf":0,"dew_point":23.07},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.28,"deg":5,"gust":4.31},"visibility":10000,"pop":0.82,"rain":{"3h":0.58},"sys":{"pod":"n"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":24.86,"feels_like":25.67,"temp_min":24.86,"temp_max":24.86,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":87,"temp_kf":0,"dew_point":22.65},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":3.53,"deg":13,"gust":4.43},"visibility":10000,"pop":0.65,"rain":{"3h":0.23},"sys":{"pod":"d"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":25.79,"feels_like":26.54,"temp_min":25.79,"temp_max":25.79,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":81,"temp_kf":0,"dew_point":22.38},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":99},"wind":{"speed":3.01,"deg":358,"gust":2.96},"visibility":10000,"pop":0.03,"sys":{"pod":"d"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":26.66,"feels_like":26.66,"temp_min":26.66,"temp_max":26.66,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":79,"temp_kf":0,"dew_point":22.91},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":99},"wind":{"speed":2.07,"deg":310,"gust":2.42},"visibility":10000,"pop":0.29,"rain":{"3h":0.22},"sys":{"pod":"d"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":27.32,"feels_like":30.13,"temp_min":27.32,"temp_max":27.32,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":23.07},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":99},"wind":{"speed":1.8,"deg":252,"gust":2.15},"visibility":10000,"pop":0.9,"rain":{"3h":1.34},"sys":{"pod":"d"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":26.76,"feels_like":29.46,"temp_min":26.76,"temp_max":26.76,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":83,"temp_kf":0,"dew_point":23.88},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":1.98,"deg":295,"gust":2.87},"visibility":10000,"pop":1,"rain":{"3h":2.08},"sys":{"pod":"n"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":25.7,"feels_like":26.62,"temp_min":25.7,"temp_max":25.7,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":88,"temp_kf":0,"dew_point":23.67},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4.14,"deg":336,"gust":5.28},"visibility":10000,"pop":1,"rain":{"3h":2.44},"sys":{"pod":"n"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":25.5,"feels_like":26.4,"temp_min":25.5,"temp_max":25.5,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":88,"temp_kf":0,"dew_point":23.53},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.69,"deg":286,"gust":4.55},"visibility":10000,"pop":1,"rain":{"3h":2.03},"sys":{"pod":"n"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":26.07,"feels_like":26.07,"temp_min":26.07,"temp_max":26.07,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":22.98},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.74,"deg":281,"gust":5.41},"visibility":10000,"pop":1,"rain":{"3h":1.27},"sys":{"pod":"n"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":25.79,"feels_like":26.62,"temp_min":25.79,"temp_max":25.79,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":84,"temp_kf":0,"dew_point":23.14},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.28,"deg":272,"gust":6.28},"visibility":10000,"pop":0.99,"rain":{"3h":0.87},"sys":{"pod":"d"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":27.26,"feels_like":29.9,"temp_min":27.26,"temp_max":27.26,"pressure":1009,"sea_level":1009,"grnd_level":1012,"humidity":76,"temp_kf":0,"dew_point":22.94},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.14,"deg":270,"gust":6.22},"visibility":10000,"pop":0.95,"rain":{"3h":1.19},"sys":{"pod":"d"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":26.99,"feels_like":29.26,"temp_min":26.99,"temp_max":26.99,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":75,"temp_kf":0,"dew_point":22.29},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.91,"deg":274,"gust":6.25},"visibility":10000,"pop":0.87,"rain":{"3h":0.11},"sys":{"pod":"d"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":26.89,"feels_like":29.24,"temp_min":26.89,"temp_max":26.89,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":77,"temp_kf":0,"dew_point":22.66},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":4.05,"deg":267,"gust":5.47},"visibility":10000,"pop":0.02,"sys":{"pod":"d"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":26.6,"feels_like":26.6,"temp_min":26.6,"temp_max":26.6,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":80,"temp_kf":0,"dew_point":23.17},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":2.77,"deg":262,"gust":3.49},"visibility":10000,"pop":0.67,"rain":{"3h":0.75},"sys":{"pod":"n"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":26.64,"feels_like":26.64,"temp_min":26.64,"temp_max":26.64,"pressure":1008,"sea_level":1008,"grnd_level":1011,"humidity":80,"temp_kf":0,"dew_point":23.19},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.21,"deg":277,"gust":4.39},"visibility":10000,"pop":0.99,"rain":{"3h":1.27},"sys":{"pod":"n"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":26.04,"feels_like":26.04,"temp_min":26.04,"temp_max":26.04,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":22.89},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":3.82,"deg":274,"gust":5.61},"visibility":10000,"pop":1,"rain":{"3h":1.51},"sys":{"pod":"n"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":25.81,"feels_like":26.67,"temp_min":25.81,"temp_max":25.81,"pressure":1006,"sea_level":1006,"grnd_level":1009,"humidity":85,"temp_kf":0,"dew_point":23.27},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4,"deg":249,"gust":5.3},"visibility":10000,"pop":1,"rain":{"3h":1.78},"sys":{"pod":"n"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":26.48,"feels_like":26.48,"temp_min":26.48,"temp_max":26.48,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":85,"temp_kf":0,"dew_point":23.89},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":4.82,"deg":248,"gust":6.15},"visibility":10000,"pop":1,"rain":{"3h":1.75},"sys":{"pod":"d"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":25.99,"feels_like":25.99,"temp_min":25.99,"temp_max":25.99,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":90,"temp_kf":0,"dew_point":24.34},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.26,"deg":232,"gust":7.64},"visibility":8381,"pop":1,"rain":{"3h":3.59},"sys":{"pod":"d"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":26.15,"feels_like":26.15,"temp_min":26.15,"temp_max":26.15,"pressure":1005,"sea_level":1005,"grnd_level":1008,"humidity":87,"temp_kf":0,"dew_point":24.05},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.54,"deg":251,"gust":7.2},"visibility":6415,"pop":1,"rain":{"3h":2.74},"sys":{"pod":"d"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":25.75,"feels_like":26.7,"temp_min":25.75,"temp_max":25.75,"pressure":1005,"sea_level":1005,"grnd_level":1008,"humidity":89,"temp_kf":0,"dew_point":23.98},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":6.14,"deg":244,"gust":9.24},"visibility":5191,"pop":1,"rain":{"3h":8.95},"sys":{"pod":"d"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":26.54,"feels_like":26.54,"temp_min":26.54,"temp_max":26.54,"pressure":1007,"sea_level":1007,"grnd_level":1010,"humidity":82,"temp_kf":0,"dew_point":23.32},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":5.86,"deg":245,"gust":8.39},"visibility":10000,"pop":1,"rain":{"3h":1.36},"sys":{"pod":"n"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":26.75,"feels_like":29.27,"temp_min":26.75,"temp_max":26.75,"pressure":1006,"sea_level":1006,"grnd_level":1009,"humidity":81,"temp_kf":0,"dew_point":23.39},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":5.72,"deg":244,"gust":7.93},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":26.5,"feels_like":26.5,"temp_min":26.5,"temp_max":26.5,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":85,"temp_kf":0,"dew_point":23.81},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":5.46,"deg":256,"gust":7.23},"visibility":10000,"pop":0.86,"rain":{"3h":0.94},"sys":{"pod":"n"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":26.06,"feels_like":26.06,"temp_min":26.06,"temp_max":26.06,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":91,"temp_kf":0,"dew_point":24.53},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":4.44,"deg":274,"gust":6.56},"visibility":10000,"pop":1,"rain":{"3h":4.21},"sys":{"pod":"n"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":27.32,"feels_like":30.69,"temp_min":27.32,"temp_max":27.32,"pressure":1005,"sea_level":1005,"grnd_level":1007,"humidity":82,"temp_kf":0,"dew_point":24.18},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":5.3,"deg":248,"gust":7.86},"visibility":10000,"pop":1,"rain":{"3h":0.7},"sys":{"pod":"d"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":28.65,"feels_like":33.59,"temp_min":28.65,"temp_max":28.65,"pressure":1004,"sea_level":1004,"grnd_level":1007,"humidity":79,"temp_kf":0,"dew_point":24.84},"weather":[{"id":500,"main":"Rain","description":"light rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":7.76,"deg":240,"gust":10.35},"visibility":10000,"pop":1,"rain":{"3h":1.41},"sys":{"pod":"d"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":28.93,"feels_like":33.92,"temp_min":28.93,"temp_max":28.93,"pressure":1003,"sea_level":1003,"grnd_level":1006,"humidity":77,"temp_kf":0,"dew_point":24.61},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":8.75,"deg":240,"gust":12.22},"visibility":10000,"pop":1,"rain":{"3h":3.99},"sys":{"pod":"d"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":1701668,"name":"Manila","coord":{"lat":14.5995,"lon":120.9842},"country":"PH","population":15000,"timezone":28800,"sunrise":1785533950,"sunset":1785579932}} diff --git a/tests/Fixtures/weather/forecast/rain.meta.json b/tests/Fixtures/weather/forecast/rain.meta.json new file mode 100644 index 0000000..f7e0891 --- /dev/null +++ b/tests/Fixtures/weather/forecast/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:02:25Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/snow.json b/tests/Fixtures/weather/forecast/snow.json new file mode 100644 index 0000000..2b8f485 --- /dev/null +++ b/tests/Fixtures/weather/forecast/snow.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":-1.44,"feels_like":-6.29,"temp_min":-1.44,"temp_max":-1.44,"pressure":1014,"sea_level":1014,"grnd_level":858,"humidity":99,"temp_kf":0,"dew_point":-1.56},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.33,"deg":312,"gust":14.6},"pop":1,"snow":{"3h":3},"sys":{"pod":"n"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":-1.69,"feels_like":-5.32,"temp_min":-2.2,"temp_max":-1.69,"pressure":1014,"sea_level":1014,"grnd_level":856,"humidity":100,"temp_kf":0.51,"dew_point":-1.69},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.78,"deg":315,"gust":10.81},"pop":1,"snow":{"3h":6.3},"sys":{"pod":"d"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":-1.69,"feels_like":-5.41,"temp_min":-1.81,"temp_max":-1.69,"pressure":1012,"sea_level":1012,"grnd_level":855,"humidity":100,"temp_kf":0.12,"dew_point":-1.69},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.87,"deg":335,"gust":13.57},"pop":1,"snow":{"3h":5.45},"sys":{"pod":"d"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":-1.97,"feels_like":-6.76,"temp_min":-1.97,"temp_max":-1.97,"pressure":1009,"sea_level":1009,"grnd_level":853,"humidity":99,"temp_kf":0,"dew_point":0.09},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":4.08,"deg":328,"gust":15.97},"pop":1,"snow":{"3h":5.1},"sys":{"pod":"d"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":-2.69,"feels_like":-6.21,"temp_min":-2.69,"temp_max":-2.69,"pressure":1007,"sea_level":1007,"grnd_level":851,"humidity":99,"temp_kf":0,"dew_point":-0.6},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.5,"deg":356,"gust":8.75},"visibility":401,"pop":1,"snow":{"3h":8.51},"sys":{"pod":"d"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":-2.87,"feels_like":-5.79,"temp_min":-2.87,"temp_max":-2.87,"pressure":1007,"sea_level":1007,"grnd_level":851,"humidity":100,"temp_kf":0,"dew_point":-0.66},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.99,"deg":313,"gust":7.6},"visibility":193,"pop":1,"snow":{"3h":9.97},"sys":{"pod":"n"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":-1.83,"feels_like":-7.01,"temp_min":-1.83,"temp_max":-1.83,"pressure":1008,"sea_level":1008,"grnd_level":852,"humidity":98,"temp_kf":0,"dew_point":0.16},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.69,"deg":326,"gust":18.12},"visibility":1150,"pop":1,"snow":{"3h":12.53},"sys":{"pod":"n"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":-2.42,"feels_like":-7.2,"temp_min":-2.42,"temp_max":-2.42,"pressure":1010,"sea_level":1010,"grnd_level":854,"humidity":96,"temp_kf":0,"dew_point":-0.8},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":3.92,"deg":317,"gust":15.2},"visibility":7229,"pop":1,"snow":{"3h":2.37},"sys":{"pod":"n"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":-1.94,"feels_like":-6.92,"temp_min":-1.94,"temp_max":-1.94,"pressure":1011,"sea_level":1011,"grnd_level":854,"humidity":100,"temp_kf":0,"dew_point":0.3},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":4.35,"deg":304,"gust":15.5},"pop":1,"snow":{"3h":4.45},"sys":{"pod":"n"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":-2.02,"feels_like":-6.63,"temp_min":-2.02,"temp_max":-2.02,"pressure":1013,"sea_level":1013,"grnd_level":856,"humidity":100,"temp_kf":0,"dew_point":0.14},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":3.82,"deg":304,"gust":14.04},"pop":1,"snow":{"3h":1.57},"sys":{"pod":"d"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":-2.02,"feels_like":-6.49,"temp_min":-2.02,"temp_max":-2.02,"pressure":1015,"sea_level":1015,"grnd_level":858,"humidity":100,"temp_kf":0,"dew_point":0.21},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":3.64,"deg":298,"gust":13.56},"pop":1,"snow":{"3h":2.8},"sys":{"pod":"d"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":-1.63,"feels_like":-5.34,"temp_min":-1.63,"temp_max":-1.63,"pressure":1014,"sea_level":1014,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":0.6},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.88,"deg":299,"gust":13.04},"pop":1,"snow":{"3h":3.27},"sys":{"pod":"d"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":-2.21,"feels_like":-5.6,"temp_min":-2.21,"temp_max":-2.21,"pressure":1016,"sea_level":1016,"grnd_level":858,"humidity":100,"temp_kf":0,"dew_point":0.03},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.46,"deg":291,"gust":6.52},"pop":1,"snow":{"3h":12.13},"sys":{"pod":"d"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":-3.21,"feels_like":-5.34,"temp_min":-3.21,"temp_max":-3.21,"pressure":1017,"sea_level":1017,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-1.03},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":318,"gust":2.85},"pop":1,"snow":{"3h":16.09},"sys":{"pod":"n"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":-3.52,"feels_like":-5.55,"temp_min":-3.52,"temp_max":-3.52,"pressure":1018,"sea_level":1018,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-1.3},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.38,"deg":342,"gust":3.84},"pop":1,"snow":{"3h":7.04},"sys":{"pod":"n"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":-3.18,"feels_like":-5.68,"temp_min":-3.18,"temp_max":-3.18,"pressure":1018,"sea_level":1018,"grnd_level":860,"humidity":100,"temp_kf":0,"dew_point":-1.04},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.68,"deg":276,"gust":4.02},"pop":1,"snow":{"3h":7.42},"sys":{"pod":"n"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":-3.1,"feels_like":-5.01,"temp_min":-3.1,"temp_max":-3.1,"pressure":1019,"sea_level":1019,"grnd_level":860,"humidity":100,"temp_kf":0,"dew_point":-0.87},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.35,"deg":278,"gust":2.55},"pop":1,"snow":{"3h":5.76},"sys":{"pod":"n"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":-2.51,"feels_like":-2.51,"temp_min":-2.51,"temp_max":-2.51,"pressure":1021,"sea_level":1021,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":-0.31},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.96,"deg":263,"gust":1.44},"pop":1,"snow":{"3h":0.59},"sys":{"pod":"d"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":-1.68,"feels_like":-1.68,"temp_min":-1.68,"temp_max":-1.68,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":99,"temp_kf":0,"dew_point":0.41},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":98},"wind":{"speed":0.46,"deg":234,"gust":0.77},"visibility":395,"pop":0.39,"snow":{"3h":0.18},"sys":{"pod":"d"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":-1.77,"feels_like":-1.77,"temp_min":-1.77,"temp_max":-1.77,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":101,"temp_kf":0,"dew_point":0.54},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":99},"wind":{"speed":0.68,"deg":317,"gust":1.08},"pop":0.41,"snow":{"3h":0.34},"sys":{"pod":"d"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":-1.57,"feels_like":-1.57,"temp_min":-1.57,"temp_max":-1.57,"pressure":1021,"sea_level":1021,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":0.6},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.43,"deg":269,"gust":0.6},"pop":0.2,"snow":{"3h":0.12},"sys":{"pod":"d"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":-2.24,"feels_like":-2.24,"temp_min":-2.24,"temp_max":-2.24,"pressure":1022,"sea_level":1022,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":-0.03},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.6,"deg":60,"gust":1.07},"visibility":348,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":-2.43,"feels_like":-2.43,"temp_min":-2.43,"temp_max":-2.43,"pressure":1022,"sea_level":1022,"grnd_level":864,"humidity":100,"temp_kf":0,"dew_point":-0.26},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.83,"deg":40,"gust":1.13},"visibility":239,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":-2.14,"feels_like":-2.14,"temp_min":-2.14,"temp_max":-2.14,"pressure":1021,"sea_level":1021,"grnd_level":863,"humidity":100,"temp_kf":0,"dew_point":0.07},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":0.92,"deg":280,"gust":0.99},"visibility":96,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":-1.99,"feels_like":-1.99,"temp_min":-1.99,"temp_max":-1.99,"pressure":1020,"sea_level":1020,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":0.2},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":1.33,"deg":272,"gust":1.23},"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":-1.99,"feels_like":-1.99,"temp_min":-1.99,"temp_max":-1.99,"pressure":1021,"sea_level":1021,"grnd_level":863,"humidity":100,"temp_kf":0,"dew_point":0.23},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":0.84,"deg":279,"gust":1.15},"pop":0.2,"snow":{"3h":0.1},"sys":{"pod":"d"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":-0.87,"feels_like":-2.7,"temp_min":-0.87,"temp_max":-0.87,"pressure":1019,"sea_level":1019,"grnd_level":863,"humidity":99,"temp_kf":0,"dew_point":1.25},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":1.47,"deg":298,"gust":2.38},"pop":0.2,"snow":{"3h":0.19},"sys":{"pod":"d"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":-0.21,"feels_like":-2.99,"temp_min":-0.21,"temp_max":-0.21,"pressure":1018,"sea_level":1018,"grnd_level":862,"humidity":98,"temp_kf":0,"dew_point":1.71},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.24,"deg":292,"gust":4.18},"visibility":20,"pop":0.32,"snow":{"3h":0.26},"sys":{"pod":"d"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":-1.61,"feels_like":-4.57,"temp_min":-1.61,"temp_max":-1.61,"pressure":1019,"sea_level":1019,"grnd_level":862,"humidity":98,"temp_kf":0,"dew_point":0.38},"weather":[{"id":600,"main":"Snow","description":"light snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.19,"deg":310,"gust":5.53},"visibility":1042,"pop":1,"snow":{"3h":0.53},"sys":{"pod":"d"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":-2.46,"feels_like":-4.53,"temp_min":-2.46,"temp_max":-2.46,"pressure":1020,"sea_level":1020,"grnd_level":862,"humidity":100,"temp_kf":0,"dew_point":-0.22},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.48,"deg":296,"gust":3.18},"pop":1,"snow":{"3h":3.29},"sys":{"pod":"n"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":-2.74,"feels_like":-2.74,"temp_min":-2.74,"temp_max":-2.74,"pressure":1019,"sea_level":1019,"grnd_level":861,"humidity":100,"temp_kf":0,"dew_point":-0.52},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.12,"deg":16,"gust":3.29},"pop":1,"snow":{"3h":7.21},"sys":{"pod":"n"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":-2.28,"feels_like":-2.28,"temp_min":-2.28,"temp_max":-2.28,"pressure":1016,"sea_level":1016,"grnd_level":859,"humidity":100,"temp_kf":0,"dew_point":-0.1},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.02,"deg":37,"gust":2.9},"visibility":155,"pop":1,"snow":{"3h":6.52},"sys":{"pod":"n"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":-1.2,"feels_like":-3.06,"temp_min":-1.2,"temp_max":-1.2,"pressure":1013,"sea_level":1013,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":1.04},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":344,"gust":2.46},"pop":1,"snow":{"3h":5.89},"sys":{"pod":"n"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":-0.99,"feels_like":-4.02,"temp_min":-0.99,"temp_max":-0.99,"pressure":1012,"sea_level":1012,"grnd_level":857,"humidity":100,"temp_kf":0,"dew_point":1.18},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.34,"deg":337,"gust":5.99},"visibility":89,"pop":1,"snow":{"3h":7.81},"sys":{"pod":"d"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":-0.17,"feels_like":-3.05,"temp_min":-0.17,"temp_max":-0.17,"pressure":1011,"sea_level":1011,"grnd_level":856,"humidity":100,"temp_kf":0,"dew_point":1.97},"weather":[{"id":601,"main":"Snow","description":"snow","icon":"13d"}],"clouds":{"all":100},"wind":{"speed":2.34,"deg":320,"gust":5.78},"pop":1,"snow":{"3h":8.38},"sys":{"pod":"d"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":0.16,"feels_like":-3.06,"temp_min":0.16,"temp_max":0.16,"pressure":1008,"sea_level":1008,"grnd_level":854,"humidity":99,"temp_kf":0,"dew_point":2.25},"weather":[{"id":501,"main":"Rain","description":"moderate rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.73,"deg":322,"gust":6.81},"visibility":154,"pop":1,"rain":{"3h":11.1},"sys":{"pod":"d"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":1.25,"feels_like":-1.79,"temp_min":1.25,"temp_max":1.25,"pressure":1005,"sea_level":1005,"grnd_level":852,"humidity":99,"temp_kf":0,"dew_point":3.31},"weather":[{"id":502,"main":"Rain","description":"heavy intensity rain","icon":"10d"}],"clouds":{"all":100},"wind":{"speed":2.76,"deg":329,"gust":6.98},"visibility":138,"pop":1,"rain":{"3h":12.91},"sys":{"pod":"d"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":0.8,"feels_like":-0.58,"temp_min":0.8,"temp_max":0.8,"pressure":1006,"sea_level":1006,"grnd_level":853,"humidity":99,"temp_kf":0,"dew_point":3.03},"weather":[{"id":502,"main":"Rain","description":"heavy intensity rain","icon":"10n"}],"clouds":{"all":100},"wind":{"speed":1.34,"deg":352,"gust":5.43},"visibility":151,"pop":1,"rain":{"3h":17.87},"sys":{"pod":"n"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":-1.61,"feels_like":-3.49,"temp_min":-1.61,"temp_max":-1.61,"pressure":1009,"sea_level":1009,"grnd_level":853,"humidity":100,"temp_kf":0,"dew_point":0.62},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.44,"deg":62,"gust":3.78},"visibility":185,"pop":1,"snow":{"3h":22.11},"sys":{"pod":"n"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":-2.43,"feels_like":-4.46,"temp_min":-2.43,"temp_max":-2.43,"pressure":1008,"sea_level":1008,"grnd_level":852,"humidity":100,"temp_kf":0,"dew_point":-0.25},"weather":[{"id":602,"main":"Snow","description":"heavy snow","icon":"13n"}],"clouds":{"all":100},"wind":{"speed":1.46,"deg":121,"gust":3.17},"visibility":223,"pop":1,"snow":{"3h":15.87},"sys":{"pod":"n"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":3892935,"name":"Curacautín","coord":{"lat":-38.4,"lon":-71.58},"country":"CL","population":0,"timezone":-14400,"sunrise":1785584858,"sunset":1785621452}} diff --git a/tests/Fixtures/weather/forecast/snow.meta.json b/tests/Fixtures/weather/forecast/snow.meta.json new file mode 100644 index 0000000..c7fc28d --- /dev/null +++ b/tests/Fixtures/weather/forecast/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:00:36Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather/forecast/success.json b/tests/Fixtures/weather/forecast/success.json new file mode 100644 index 0000000..7340994 --- /dev/null +++ b/tests/Fixtures/weather/forecast/success.json @@ -0,0 +1 @@ +{"cod":"200","message":0,"cnt":40,"list":[{"dt":1785574800,"main":{"temp":22.54,"feels_like":22.83,"temp_min":22.54,"temp_max":23.52,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":76,"temp_kf":-0.98,"dew_point":18.1},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":48},"wind":{"speed":3.36,"deg":349,"gust":4.91},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 09:00:00"},{"dt":1785585600,"main":{"temp":24.26,"feels_like":24.46,"temp_min":24.26,"temp_max":27.71,"pressure":1016,"sea_level":1016,"grnd_level":1005,"humidity":66,"temp_kf":-3.45,"dew_point":17.51},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":44},"wind":{"speed":3.74,"deg":320,"gust":3.14},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 12:00:00"},{"dt":1785596400,"main":{"temp":26.61,"feels_like":26.61,"temp_min":26.61,"temp_max":28.64,"pressure":1015,"sea_level":1015,"grnd_level":1004,"humidity":50,"temp_kf":-2.03,"dew_point":15.34},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":27},"wind":{"speed":6.12,"deg":315,"gust":5.97},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 15:00:00"},{"dt":1785607200,"main":{"temp":22.82,"feels_like":22.67,"temp_min":22.82,"temp_max":22.82,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":58,"temp_kf":0,"dew_point":15.43},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":13},"wind":{"speed":7.17,"deg":323,"gust":10.24},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-01 18:00:00"},{"dt":1785618000,"main":{"temp":21.69,"feels_like":22.03,"temp_min":21.69,"temp_max":21.69,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":81,"temp_kf":0,"dew_point":17.25},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":17},"wind":{"speed":4.91,"deg":334,"gust":10.61},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-01 21:00:00"},{"dt":1785628800,"main":{"temp":20.92,"feels_like":21.26,"temp_min":20.92,"temp_max":20.92,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":84,"temp_kf":0,"dew_point":17.02},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":18},"wind":{"speed":3.01,"deg":331,"gust":7.2},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 00:00:00"},{"dt":1785639600,"main":{"temp":20.66,"feels_like":21.05,"temp_min":20.66,"temp_max":20.66,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":87,"temp_kf":0,"dew_point":17.11},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":19},"wind":{"speed":3.42,"deg":321,"gust":7.58},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 03:00:00"},{"dt":1785650400,"main":{"temp":20.92,"feels_like":21.34,"temp_min":20.92,"temp_max":20.92,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":87,"temp_kf":0,"dew_point":16.92},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":19},"wind":{"speed":3.27,"deg":323,"gust":7.66},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 06:00:00"},{"dt":1785661200,"main":{"temp":23.67,"feels_like":23.71,"temp_min":23.67,"temp_max":23.67,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":62,"temp_kf":0,"dew_point":15.46},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":9},"wind":{"speed":4.2,"deg":324,"gust":5.87},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 09:00:00"},{"dt":1785672000,"main":{"temp":25.42,"feels_like":25.14,"temp_min":25.42,"temp_max":25.42,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":43,"temp_kf":0,"dew_point":12.93},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":51},"wind":{"speed":4.95,"deg":312,"gust":5.46},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 12:00:00"},{"dt":1785682800,"main":{"temp":26.33,"feels_like":26.33,"temp_min":26.33,"temp_max":26.33,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":41,"temp_kf":0,"dew_point":12.46},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":100},"wind":{"speed":5.9,"deg":309,"gust":6.73},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 15:00:00"},{"dt":1785693600,"main":{"temp":22.93,"feels_like":22.66,"temp_min":22.93,"temp_max":22.93,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":53,"temp_kf":0,"dew_point":13.82},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04d"}],"clouds":{"all":99},"wind":{"speed":5.3,"deg":311,"gust":7.14},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-02 18:00:00"},{"dt":1785704400,"main":{"temp":23.24,"feels_like":23.47,"temp_min":23.24,"temp_max":23.24,"pressure":1015,"sea_level":1015,"grnd_level":1006,"humidity":71,"temp_kf":0,"dew_point":15.58},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":100},"wind":{"speed":3.67,"deg":317,"gust":6.84},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-02 21:00:00"},{"dt":1785715200,"main":{"temp":22.27,"feels_like":22.51,"temp_min":22.27,"temp_max":22.27,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":75,"temp_kf":0,"dew_point":16.08},"weather":[{"id":804,"main":"Clouds","description":"overcast clouds","icon":"04n"}],"clouds":{"all":96},"wind":{"speed":2.77,"deg":316,"gust":5.57},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 00:00:00"},{"dt":1785726000,"main":{"temp":20.65,"feels_like":20.78,"temp_min":20.65,"temp_max":20.65,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":77,"temp_kf":0,"dew_point":16.47},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04n"}],"clouds":{"all":78},"wind":{"speed":1.97,"deg":292,"gust":3.94},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 03:00:00"},{"dt":1785736800,"main":{"temp":20.15,"feels_like":20.34,"temp_min":20.15,"temp_max":20.15,"pressure":1014,"sea_level":1014,"grnd_level":1005,"humidity":81,"temp_kf":0,"dew_point":16.83},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":67},"wind":{"speed":1.13,"deg":280,"gust":2.07},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 06:00:00"},{"dt":1785747600,"main":{"temp":23.24,"feels_like":23.37,"temp_min":23.24,"temp_max":23.24,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":67,"temp_kf":0,"dew_point":16.74},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":19},"wind":{"speed":2.74,"deg":240,"gust":3.26},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 09:00:00"},{"dt":1785758400,"main":{"temp":25.53,"feels_like":25.68,"temp_min":25.53,"temp_max":25.53,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":59,"temp_kf":0,"dew_point":17.06},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02d"}],"clouds":{"all":11},"wind":{"speed":4.93,"deg":236,"gust":5.28},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 12:00:00"},{"dt":1785769200,"main":{"temp":25.43,"feels_like":25.75,"temp_min":25.43,"temp_max":25.43,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":66,"temp_kf":0,"dew_point":18.66},"weather":[{"id":802,"main":"Clouds","description":"scattered clouds","icon":"03d"}],"clouds":{"all":36},"wind":{"speed":5.79,"deg":246,"gust":6.97},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 15:00:00"},{"dt":1785780000,"main":{"temp":24.21,"feels_like":24.59,"temp_min":24.21,"temp_max":24.21,"pressure":1015,"sea_level":1015,"grnd_level":1005,"humidity":73,"temp_kf":0,"dew_point":19.08},"weather":[{"id":803,"main":"Clouds","description":"broken clouds","icon":"04d"}],"clouds":{"all":57},"wind":{"speed":4.79,"deg":263,"gust":6.7},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-03 18:00:00"},{"dt":1785790800,"main":{"temp":21.81,"feels_like":22.06,"temp_min":21.81,"temp_max":21.81,"pressure":1016,"sea_level":1016,"grnd_level":1007,"humidity":77,"temp_kf":0,"dew_point":17.43},"weather":[{"id":801,"main":"Clouds","description":"few clouds","icon":"02n"}],"clouds":{"all":11},"wind":{"speed":3.28,"deg":310,"gust":5.34},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-03 21:00:00"},{"dt":1785801600,"main":{"temp":20.74,"feels_like":20.98,"temp_min":20.74,"temp_max":20.74,"pressure":1016,"sea_level":1016,"grnd_level":1007,"humidity":81,"temp_kf":0,"dew_point":17.26},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":5},"wind":{"speed":2.56,"deg":318,"gust":5.22},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 00:00:00"},{"dt":1785812400,"main":{"temp":20.2,"feels_like":20.47,"temp_min":20.2,"temp_max":20.2,"pressure":1016,"sea_level":1016,"grnd_level":1006,"humidity":84,"temp_kf":0,"dew_point":17.33},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":1.55,"deg":307,"gust":3.14},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 03:00:00"},{"dt":1785823200,"main":{"temp":19.93,"feels_like":20.17,"temp_min":19.93,"temp_max":19.93,"pressure":1017,"sea_level":1017,"grnd_level":1007,"humidity":84,"temp_kf":0,"dew_point":17.11},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":0.61,"deg":296,"gust":1.12},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 06:00:00"},{"dt":1785834000,"main":{"temp":23.72,"feels_like":23.74,"temp_min":23.72,"temp_max":23.72,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":61,"temp_kf":0,"dew_point":15.76},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":2.13,"deg":286,"gust":2.95},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 09:00:00"},{"dt":1785844800,"main":{"temp":26.74,"feels_like":27.07,"temp_min":26.74,"temp_max":26.74,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":48,"temp_kf":0,"dew_point":14.92},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":4.25,"deg":271,"gust":4.23},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 12:00:00"},{"dt":1785855600,"main":{"temp":26.92,"feels_like":27.17,"temp_min":26.92,"temp_max":26.92,"pressure":1017,"sea_level":1017,"grnd_level":1008,"humidity":47,"temp_kf":0,"dew_point":14.69},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":5.5,"deg":290,"gust":5.52},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 15:00:00"},{"dt":1785866400,"main":{"temp":24.7,"feels_like":24.77,"temp_min":24.7,"temp_max":24.7,"pressure":1018,"sea_level":1018,"grnd_level":1008,"humidity":59,"temp_kf":0,"dew_point":16.13},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":5.33,"deg":312,"gust":6.75},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-04 18:00:00"},{"dt":1785877200,"main":{"temp":21.85,"feels_like":22.15,"temp_min":21.85,"temp_max":21.85,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":79,"temp_kf":0,"dew_point":17.96},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":4.24,"deg":325,"gust":7.25},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-04 21:00:00"},{"dt":1785888000,"main":{"temp":21.08,"feels_like":21.46,"temp_min":21.08,"temp_max":21.08,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":85,"temp_kf":0,"dew_point":18.36},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":1},"wind":{"speed":3.37,"deg":326,"gust":6.71},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 00:00:00"},{"dt":1785898800,"main":{"temp":20.46,"feels_like":20.83,"temp_min":20.46,"temp_max":20.46,"pressure":1018,"sea_level":1018,"grnd_level":1009,"humidity":87,"temp_kf":0,"dew_point":18.16},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":3},"wind":{"speed":2.55,"deg":329,"gust":5.63},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 03:00:00"},{"dt":1785909600,"main":{"temp":20,"feels_like":20.41,"temp_min":20,"temp_max":20,"pressure":1019,"sea_level":1019,"grnd_level":1009,"humidity":90,"temp_kf":0,"dew_point":18.31},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":4},"wind":{"speed":2.36,"deg":334,"gust":5.81},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 06:00:00"},{"dt":1785920400,"main":{"temp":23.82,"feels_like":23.93,"temp_min":23.82,"temp_max":23.82,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":64,"temp_kf":0,"dew_point":16.55},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":5},"wind":{"speed":3.68,"deg":337,"gust":5.02},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 09:00:00"},{"dt":1785931200,"main":{"temp":27.54,"feels_like":27.8,"temp_min":27.54,"temp_max":27.54,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":48,"temp_kf":0,"dew_point":15.71},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":3},"wind":{"speed":5.41,"deg":320,"gust":5.21},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 12:00:00"},{"dt":1785942000,"main":{"temp":27.37,"feels_like":27.64,"temp_min":27.37,"temp_max":27.37,"pressure":1019,"sea_level":1019,"grnd_level":1009,"humidity":48,"temp_kf":0,"dew_point":15.47},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":4},"wind":{"speed":6.9,"deg":320,"gust":7.18},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 15:00:00"},{"dt":1785952800,"main":{"temp":24.92,"feels_like":24.85,"temp_min":24.92,"temp_max":24.92,"pressure":1019,"sea_level":1019,"grnd_level":1010,"humidity":53,"temp_kf":0,"dew_point":14.97},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":2},"wind":{"speed":6.63,"deg":335,"gust":8.72},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-05 18:00:00"},{"dt":1785963600,"main":{"temp":21.25,"feels_like":21.26,"temp_min":21.25,"temp_max":21.25,"pressure":1021,"sea_level":1021,"grnd_level":1011,"humidity":70,"temp_kf":0,"dew_point":15.41},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":4.88,"deg":347,"gust":10.43},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-05 21:00:00"},{"dt":1785974400,"main":{"temp":20.03,"feels_like":19.99,"temp_min":20.03,"temp_max":20.03,"pressure":1021,"sea_level":1021,"grnd_level":1011,"humidity":73,"temp_kf":0,"dew_point":14.83},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":3.29,"deg":346,"gust":8.51},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-06 00:00:00"},{"dt":1785985200,"main":{"temp":19.3,"feels_like":19.32,"temp_min":19.3,"temp_max":19.3,"pressure":1020,"sea_level":1020,"grnd_level":1010,"humidity":78,"temp_kf":0,"dew_point":15.15},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01n"}],"clouds":{"all":0},"wind":{"speed":2.92,"deg":338,"gust":9.28},"visibility":10000,"pop":0,"sys":{"pod":"n"},"dt_txt":"2026-08-06 03:00:00"},{"dt":1785996000,"main":{"temp":19.21,"feels_like":19.2,"temp_min":19.21,"temp_max":19.21,"pressure":1020,"sea_level":1020,"grnd_level":1011,"humidity":77,"temp_kf":0,"dew_point":15.13},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"clouds":{"all":0},"wind":{"speed":3.21,"deg":341,"gust":9.84},"visibility":10000,"pop":0,"sys":{"pod":"d"},"dt_txt":"2026-08-06 06:00:00"}],"city":{"id":6458923,"name":"Lisbon Municipality","coord":{"lat":38.7223,"lon":-9.1393},"country":"PT","population":0,"timezone":3600,"sunrise":1785562660,"sunset":1785613679}} diff --git a/tests/Fixtures/weather/forecast/success.meta.json b/tests/Fixtures/weather/forecast/success.meta.json new file mode 100644 index 0000000..1e0b97d --- /dev/null +++ b/tests/Fixtures/weather/forecast/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "5 Day / 3 Hour Forecast API", + "endpoint": "5 day / 3 hour forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T09:02:25Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/forecast", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} From 5e9e6d1138ae3fe05967c60f59baad6c87b3559f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 11:24:14 +0100 Subject: [PATCH 024/113] feat(weather): add current weather response entities --- src/Entity/Weather/Clouds.php | 40 ++ src/Entity/Weather/Condition.php | 60 +++ src/Entity/Weather/Current/Precipitation.php | 40 ++ src/Entity/Weather/CurrentWeather.php | 343 ++++++++++++++++++ src/Entity/Weather/Wind.php | 78 ++++ src/Hydration/UnitsResolver.php | 30 ++ .../Entity/Weather/CurrentWeatherTest.php | 203 +++++++++++ 7 files changed, 794 insertions(+) create mode 100644 src/Entity/Weather/Clouds.php create mode 100644 src/Entity/Weather/Condition.php create mode 100644 src/Entity/Weather/Current/Precipitation.php create mode 100644 src/Entity/Weather/CurrentWeather.php create mode 100644 src/Entity/Weather/Wind.php create mode 100644 src/Hydration/UnitsResolver.php create mode 100644 tests/Unit/Entity/Weather/CurrentWeatherTest.php diff --git a/src/Entity/Weather/Clouds.php b/src/Entity/Weather/Clouds.php new file mode 100644 index 0000000..cbf4e43 --- /dev/null +++ b/src/Entity/Weather/Clouds.php @@ -0,0 +1,40 @@ +nullableInt('all'), + ); + } + + public function coverage(): ?int + { + return $this->coverage; + } + + public function coverageUnit(): Unit + { + return Unit::PERCENT; + } + + public function coverageWithUnit(): ?string + { + return MeasurementFormatter::format($this->coverage, $this->coverageUnit()); + } +} diff --git a/src/Entity/Weather/Condition.php b/src/Entity/Weather/Condition.php new file mode 100644 index 0000000..f0abac8 --- /dev/null +++ b/src/Entity/Weather/Condition.php @@ -0,0 +1,60 @@ +nullableInt('id'), + group: $reader->nullableString('main'), + description: $reader->nullableString('description'), + icon: $reader->nullableString('icon'), + ); + } + + public function id(): ?int + { + return $this->id; + } + + public function group(): ?string + { + return $this->group; + } + + public function description(): ?string + { + return $this->description; + } + + public function icon(): ?string + { + return $this->icon; + } + + public function iconUrl(): ?string + { + if ($this->icon === null) { + return null; + } + + return sprintf(self::ICON_URL, rawurlencode($this->icon)); + } +} diff --git a/src/Entity/Weather/Current/Precipitation.php b/src/Entity/Weather/Current/Precipitation.php new file mode 100644 index 0000000..99cba2e --- /dev/null +++ b/src/Entity/Weather/Current/Precipitation.php @@ -0,0 +1,40 @@ +nullableFloat('1h'), + ); + } + + public function lastHour(): ?float + { + return $this->lastHour; + } + + public function lastHourUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function lastHourWithUnit(): ?string + { + return MeasurementFormatter::format($this->lastHour, $this->lastHourUnit()); + } +} diff --git a/src/Entity/Weather/CurrentWeather.php b/src/Entity/Weather/CurrentWeather.php new file mode 100644 index 0000000..80d4740 --- /dev/null +++ b/src/Entity/Weather/CurrentWeather.php @@ -0,0 +1,343 @@ + $conditions + */ + private function __construct( + private readonly ?float $latitude, + private readonly ?float $longitude, + private readonly array $conditions, + private readonly ?string $base, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $minimumTemperature, + private readonly ?float $maximumTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?int $seaLevelPressure, + private readonly ?int $groundLevelPressure, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly ?\DateTimeImmutable $observedAt, + private readonly ?int $systemType, + private readonly ?int $systemId, + private readonly ?string $countryCode, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?int $timezoneOffset, + private readonly ?int $id, + private readonly ?string $name, + private readonly ?int $code, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $wind = $reader->nullableArray('wind'); + $clouds = $reader->nullableArray('clouds'); + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + + return new self( + latitude: $reader->nullableFloat('coord.lat'), + longitude: $reader->nullableFloat('coord.lon'), + conditions: $conditions, + base: $reader->nullableString('base'), + temperature: $reader->nullableFloat('main.temp'), + feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), + minimumTemperature: $reader->nullableFloat('main.temp_min'), + maximumTemperature: $reader->nullableFloat('main.temp_max'), + pressure: $reader->nullableInt('main.pressure'), + humidity: $reader->nullableInt('main.humidity'), + seaLevelPressure: $reader->nullableInt('main.sea_level'), + groundLevelPressure: $reader->nullableInt('main.grnd_level'), + visibility: $reader->nullableInt('visibility'), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + observedAt: $reader->nullableTimestamp('dt'), + systemType: $reader->nullableInt('sys.type'), + systemId: $reader->nullableInt('sys.id'), + countryCode: $reader->nullableString('sys.country'), + sunriseAt: $reader->nullableTimestamp('sys.sunrise'), + sunsetAt: $reader->nullableTimestamp('sys.sunset'), + timezoneOffset: $reader->nullableInt('timezone'), + id: $reader->nullableInt('id'), + name: $reader->nullableString('name'), + code: $reader->nullableInt('cod'), + units: UnitsResolver::fromContext($context), + ); + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function base(): ?string + { + return $this->base; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return $this->formatTemperature($this->temperature); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->feelsLikeTemperature); + } + + public function minimumTemperature(): ?float + { + return $this->minimumTemperature; + } + + public function minimumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function minimumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->minimumTemperature); + } + + public function maximumTemperature(): ?float + { + return $this->maximumTemperature; + } + + public function maximumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function maximumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->maximumTemperature); + } + + public function pressure(): ?int + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return $this->formatPressure($this->pressure); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function seaLevelPressure(): ?int + { + return $this->seaLevelPressure; + } + + public function seaLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function seaLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->seaLevelPressure); + } + + public function groundLevelPressure(): ?int + { + return $this->groundLevelPressure; + } + + public function groundLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function groundLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->groundLevelPressure); + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + public function observedAt(): ?\DateTimeImmutable + { + return $this->observedAt; + } + + public function systemType(): ?int + { + return $this->systemType; + } + + public function systemId(): ?int + { + return $this->systemId; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function timezoneOffset(): ?int + { + return $this->timezoneOffset; + } + + public function id(): ?int + { + return $this->id; + } + + public function name(): ?string + { + return $this->name; + } + + public function code(): ?int + { + return $this->code; + } + + private function formatTemperature(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } + + private function formatPressure(?int $pressure): ?string + { + return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); + } +} diff --git a/src/Entity/Weather/Wind.php b/src/Entity/Weather/Wind.php new file mode 100644 index 0000000..addd388 --- /dev/null +++ b/src/Entity/Weather/Wind.php @@ -0,0 +1,78 @@ +nullableFloat('speed'), + direction: $reader->nullableInt('deg'), + gust: $reader->nullableFloat('gust'), + units: UnitsResolver::fromContext($context), + ); + } + + public function speed(): ?float + { + return $this->speed; + } + + public function speedUnit(): Unit + { + return $this->units->speedUnit(); + } + + public function speedWithUnit(): ?string + { + return MeasurementFormatter::format($this->speed, $this->speedUnit()); + } + + public function direction(): ?int + { + return $this->direction; + } + + public function directionUnit(): Unit + { + return Unit::DEGREE; + } + + public function directionWithUnit(): ?string + { + return MeasurementFormatter::format($this->direction, $this->directionUnit()); + } + + public function gust(): ?float + { + return $this->gust; + } + + public function gustUnit(): Unit + { + return $this->units->speedUnit(); + } + + public function gustWithUnit(): ?string + { + return MeasurementFormatter::format($this->gust, $this->gustUnit()); + } +} diff --git a/src/Hydration/UnitsResolver.php b/src/Hydration/UnitsResolver.php new file mode 100644 index 0000000..d0e875e --- /dev/null +++ b/src/Hydration/UnitsResolver.php @@ -0,0 +1,30 @@ +config()->get( + OpenWeatherMap::OPTION_UNITS, + Units::METRIC, + ) ?? Units::METRIC; + + if (!$units instanceof Units) { + throw new \LogicException(sprintf( + 'The hydration context "%s" value must be an instance of %s.', + OpenWeatherMap::OPTION_UNITS, + Units::class, + )); + } + + return $units; + } +} diff --git a/tests/Unit/Entity/Weather/CurrentWeatherTest.php b/tests/Unit/Entity/Weather/CurrentWeatherTest.php new file mode 100644 index 0000000..5097ffb --- /dev/null +++ b/tests/Unit/Entity/Weather/CurrentWeatherTest.php @@ -0,0 +1,203 @@ +latitude()); + self::assertSame(-9.1393, $weather->longitude()); + self::assertSame('stations', $weather->base()); + self::assertSame(22.55, $weather->temperature()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertSame('22.55 °C', $weather->temperatureWithUnit()); + self::assertSame(22.87, $weather->feelsLikeTemperature()); + self::assertSame('22.87 °C', $weather->feelsLikeTemperatureWithUnit()); + self::assertSame(21.48, $weather->minimumTemperature()); + self::assertSame('21.48 °C', $weather->minimumTemperatureWithUnit()); + self::assertSame(23.94, $weather->maximumTemperature()); + self::assertSame('23.94 °C', $weather->maximumTemperatureWithUnit()); + self::assertSame(1016, $weather->pressure()); + self::assertSame('1016 hPa', $weather->pressureWithUnit()); + self::assertSame(77, $weather->humidity()); + self::assertSame('77 %', $weather->humidityWithUnit()); + self::assertSame(1016, $weather->seaLevelPressure()); + self::assertSame('1016 hPa', $weather->seaLevelPressureWithUnit()); + self::assertSame(1006, $weather->groundLevelPressure()); + self::assertSame('1006 hPa', $weather->groundLevelPressureWithUnit()); + self::assertSame(10000, $weather->visibility()); + self::assertSame('10000 m', $weather->visibilityWithUnit()); + + self::assertCount(1, $weather->conditions()); + self::assertSame(802, $weather->conditions()[0]->id()); + self::assertSame('Clouds', $weather->conditions()[0]->group()); + self::assertSame('scattered clouds', $weather->conditions()[0]->description()); + self::assertSame('03d', $weather->conditions()[0]->icon()); + self::assertSame( + 'https://openweathermap.org/payload/api/media/file/03d@2x.png', + $weather->conditions()[0]->iconUrl(), + ); + + self::assertSame(4.47, $weather->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $weather->wind()?->speedUnit()); + self::assertSame('4.47 m/s', $weather->wind()?->speedWithUnit()); + self::assertSame(190, $weather->wind()?->direction()); + self::assertSame('190 °', $weather->wind()?->directionWithUnit()); + self::assertSame(7.6, $weather->wind()?->gust()); + self::assertSame('7.6 m/s', $weather->wind()?->gustWithUnit()); + + self::assertSame(48, $weather->clouds()?->coverage()); + self::assertSame(Unit::PERCENT, $weather->clouds()?->coverageUnit()); + self::assertSame('48 %', $weather->clouds()?->coverageWithUnit()); + self::assertNull($weather->rain()); + self::assertNull($weather->snow()); + + self::assertSame(1785573885, $weather->observedAt()?->getTimestamp()); + self::assertSame('UTC', $weather->observedAt()?->getTimezone()->getName()); + self::assertSame(2, $weather->systemType()); + self::assertSame(2016751, $weather->systemId()); + self::assertSame('PT', $weather->countryCode()); + self::assertSame(1785562660, $weather->sunriseAt()?->getTimestamp()); + self::assertSame(1785613679, $weather->sunsetAt()?->getTimestamp()); + self::assertSame(3600, $weather->timezoneOffset()); + self::assertSame(8012502, $weather->id()); + self::assertSame('Socorro', $weather->name()); + self::assertSame(200, $weather->code()); + } + + public function testHydratesConditionalRain(): void + { + $weather = CurrentWeather::fromArray( + Fixture::json('weather/current/rain.json'), + ); + + self::assertSame('Rain', $weather->conditions()[0]->group()); + self::assertSame(2.47, $weather->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETER, $weather->rain()?->lastHourUnit()); + self::assertSame('2.47 mm', $weather->rain()?->lastHourWithUnit()); + self::assertNull($weather->snow()); + } + + public function testHydratesConditionalSnowAndMissingFields(): void + { + $weather = CurrentWeather::fromArray( + Fixture::json('weather/current/snow.json'), + ); + + self::assertSame('Snow', $weather->conditions()[0]->group()); + self::assertSame(1.37, $weather->snow()?->lastHour()); + self::assertSame('1.37 mm', $weather->snow()?->lastHourWithUnit()); + self::assertNull($weather->rain()); + self::assertNull($weather->visibility()); + self::assertNull($weather->visibilityWithUnit()); + self::assertNull($weather->systemType()); + self::assertNull($weather->systemId()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $weather = CurrentWeather::fromArray([ + 'main' => ['temp' => 72.5], + 'wind' => ['speed' => 10, 'gust' => 15], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $weather->temperatureUnit()); + self::assertSame('72.5 °F', $weather->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $weather->wind()?->speedUnit()); + self::assertSame('10 mph', $weather->wind()?->speedWithUnit()); + self::assertSame('15 mph', $weather->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertSame([], CurrentWeather::fromArray(['weather' => null])->conditions()); + + $weather = CurrentWeather::fromArray([ + 'coord' => ['lat' => null, 'unknown' => true], + 'weather' => [['icon' => null]], + 'main' => ['temp' => null], + 'wind' => null, + 'clouds' => ['all' => null], + 'rain' => ['1h' => null, 'unknown' => true], + 'sys' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($weather->latitude()); + self::assertNull($weather->longitude()); + self::assertCount(1, $weather->conditions()); + self::assertNull($weather->conditions()[0]->icon()); + self::assertNull($weather->conditions()[0]->iconUrl()); + self::assertNull($weather->temperature()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertNull($weather->temperatureWithUnit()); + self::assertNull($weather->feelsLikeTemperature()); + self::assertNull($weather->pressure()); + self::assertNull($weather->wind()); + self::assertNull($weather->clouds()?->coverage()); + self::assertNull($weather->clouds()?->coverageWithUnit()); + self::assertNull($weather->rain()?->lastHour()); + self::assertNull($weather->rain()?->lastHourWithUnit()); + self::assertNull($weather->snow()); + self::assertNull($weather->observedAt()); + self::assertNull($weather->countryCode()); + self::assertNull($weather->sunriseAt()); + self::assertNull($weather->name()); + self::assertNull($weather->code()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + CurrentWeather::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [['coord' => 'invalid'], 'coord', 'array', 'string']; + yield 'latitude' => [['coord' => ['lat' => '38.7']], 'coord.lat', 'int|float', 'string']; + yield 'conditions' => [['weather' => 'Clouds'], 'weather', 'array', 'string']; + yield 'condition member' => [['weather' => ['Clouds']], 'weather.0', 'array', 'string']; + yield 'condition id' => [['weather' => [['id' => '802']]], 'id', 'int', 'string']; + yield 'measurements' => [['main' => 'invalid'], 'main', 'array', 'string']; + yield 'temperature' => [['main' => ['temp' => '22.5']], 'main.temp', 'int|float', 'string']; + yield 'wind speed' => [['wind' => ['speed' => '4.5']], 'speed', 'int|float', 'string']; + yield 'cloud coverage' => [['clouds' => ['all' => 48.5]], 'all', 'int', 'float']; + yield 'rain' => [['rain' => ['1h' => '2.5']], '1h', 'int|float', 'string']; + yield 'observation time' => [['dt' => '1785573885'], 'dt', 'int', 'string']; + yield 'country' => [['sys' => ['country' => 1]], 'sys.country', 'string', 'int']; + yield 'code' => [['cod' => '200'], 'cod', 'int', 'string']; + } +} From c5a152a08ec9513af42843e19e3ed057c10da890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 14:37:54 +0100 Subject: [PATCH 025/113] feat(weather): add current weather resource --- README.md | 1 + composer.json | 2 +- docs/weather.md | 99 +++++++++++++++ src/OpenWeatherMap.php | 6 + src/Resource/Concern/WithLanguage.php | 16 +-- src/Resource/Concern/WithUnits.php | 12 +- src/Resource/Weather.php | 36 ++++++ .../Concern/FluentConfigurationTest.php | 10 +- tests/Unit/Resource/WeatherTest.php | 116 ++++++++++++++++++ 9 files changed, 273 insertions(+), 25 deletions(-) create mode 100644 docs/weather.md create mode 100644 src/Resource/Weather.php create mode 100644 tests/Unit/Resource/WeatherTest.php diff --git a/README.md b/README.md index 5d5d30f..8217598 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ use yet. ## Documentation +- [Weather](docs/weather.md) - [Geocoding](docs/geocoding.md) ## License diff --git a/composer.json b/composer.json index 284b3bb..866ae04 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ ], "require": { "php": ">=8.1", - "programmatordev/php-api-sdk": "^3.0" + "programmatordev/php-api-sdk": "^3.1" }, "require-dev": { "monolog/monolog": "^3.10", diff --git a/docs/weather.md b/docs/weather.md new file mode 100644 index 0000000..d6d1d5e --- /dev/null +++ b/docs/weather.md @@ -0,0 +1,99 @@ +# Weather + +The Current Weather API is available on OpenWeather's standard free and paid +subscriptions. See the +[official Current Weather API documentation](https://openweathermap.org/api/current) +for the upstream endpoint contract. + +## Current Weather + +Use `current()` with a latitude and longitude. Both coordinates are validated +before the request is sent. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The method returns a `CurrentWeather` entity. Every response property may be +absent or explicitly `null`; missing or `null` condition lists become empty +arrays. + +```php +echo $current->name(); +echo $current->latitude(); +echo $current->longitude(); +echo $current->temperature(); +echo $current->feelsLikeTemperature(); +echo $current->minimumTemperature(); +echo $current->maximumTemperature(); +echo $current->pressure(); +echo $current->humidity(); +echo $current->visibility(); +``` + +Conditions, wind, and clouds are exposed as nested entities. A condition keeps +the raw OpenWeather icon code and provides its absolute image URL. + +```php +foreach ($current->conditions() as $condition) { + echo $condition->group(); + echo $condition->description(); + echo $condition->icon(); + echo $condition->iconUrl(); +} + +echo $current->wind()?->speed(); +echo $current->wind()?->direction(); +echo $current->wind()?->gust(); +echo $current->clouds()?->coverage(); +``` + +Rain and snow are conditional. When present, `lastHour()` returns the +precipitation volume reported for the preceding hour in millimetres. + +```php +echo $current->rain()?->lastHour(); +echo $current->snow()?->lastHour(); +``` + +Observation, sunrise, and sunset timestamps are nullable `DateTimeImmutable` +values normalized to UTC. `timezoneOffset()` retains the location's offset +from UTC in seconds. + +## Units And Language + +Weather requests use the API configuration by default. Request-local fluent +overrides are immutable and do not affect later calls through the original +resource. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; + +$current = $api + ->weather() + ->withUnits(Units::METRIC) + ->withLanguage(Language::PORTUGUESE) + ->current( + latitude: 38.7223, + longitude: -9.1393, + ); +``` + +Raw measurement getters remain numeric. Companion `Unit` and `WithUnit` +methods expose the effective request unit and a locale-independent formatted +value. For example, when a metric response contains a temperature of `22.55`: + +```php +$current->temperature(); // 22.55 +$current->temperatureUnit(); // Unit::CELSIUS +$current->temperatureUnit()->symbol(); // '°C' +$current->temperatureWithUnit(); // '22.55 °C' +``` diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 03f2e5a..5d5abfe 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -13,6 +13,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; +use ProgrammatorDev\OpenWeatherMap\Resource\Weather; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; class OpenWeatherMap extends Api @@ -53,6 +54,11 @@ public function geocoding(): Geocoding return $this->resource(Geocoding::class); } + public function weather(): Weather + { + return $this->resource(Weather::class); + } + private function validateApiKey(string $apiKey): string { return Assert::notBlank($apiKey, 'API key'); diff --git a/src/Resource/Concern/WithLanguage.php b/src/Resource/Concern/WithLanguage.php index e8b056c..bb3d4f6 100644 --- a/src/Resource/Concern/WithLanguage.php +++ b/src/Resource/Concern/WithLanguage.php @@ -8,9 +8,6 @@ trait WithLanguage { - // Null means this resource inherits the API-wide language. - private Language|string|null $languageOverride = null; - public function withLanguage(Language|string $language): static { // Raw strings allow new OpenWeather language codes without an enum release. @@ -18,16 +15,13 @@ public function withLanguage(Language|string $language): static $language = Assert::notBlank($language, 'language'); } - $clone = clone $this; - $clone->languageOverride = $language; - - return $clone; + return $this->withConfig([ + OpenWeatherMap::OPTION_LANGUAGE => $language, + ]); } - protected function resolvedLanguage(): string + protected function resolvedLanguage(): Language|string { - $language = $this->languageOverride ?? $this->runtime->config()->get(OpenWeatherMap::OPTION_LANGUAGE); - - return $language instanceof Language ? $language->value : $language; + return $this->runtime->config()->get(OpenWeatherMap::OPTION_LANGUAGE); } } diff --git a/src/Resource/Concern/WithUnits.php b/src/Resource/Concern/WithUnits.php index 901ce9e..617eeeb 100644 --- a/src/Resource/Concern/WithUnits.php +++ b/src/Resource/Concern/WithUnits.php @@ -7,19 +7,15 @@ trait WithUnits { - // Null means this resource inherits the API-wide unit system. - private ?Units $unitsOverride = null; - public function withUnits(Units $units): static { - $clone = clone $this; - $clone->unitsOverride = $units; - - return $clone; + return $this->withConfig([ + OpenWeatherMap::OPTION_UNITS => $units, + ]); } protected function resolvedUnits(): Units { - return $this->unitsOverride ?? $this->runtime->config()->get(OpenWeatherMap::OPTION_UNITS); + return $this->runtime->config()->get(OpenWeatherMap::OPTION_UNITS); } } diff --git a/src/Resource/Weather.php b/src/Resource/Weather.php new file mode 100644 index 0000000..7e69a69 --- /dev/null +++ b/src/Resource/Weather.php @@ -0,0 +1,36 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/2.5/weather') + ->entity(CurrentWeather::class); + + return $weather; + } +} diff --git a/tests/Unit/Resource/Concern/FluentConfigurationTest.php b/tests/Unit/Resource/Concern/FluentConfigurationTest.php index b320ea6..7cca079 100644 --- a/tests/Unit/Resource/Concern/FluentConfigurationTest.php +++ b/tests/Unit/Resource/Concern/FluentConfigurationTest.php @@ -27,9 +27,9 @@ public function testConfigurationIsImmutableAndChainable(): void self::assertNotSame($this->resource, $configured); self::assertSame(Units::METRIC, $this->resource->resolvedUnitsValue()); - self::assertSame('en', $this->resource->resolvedLanguageValue()); + self::assertSame(Language::ENGLISH, $this->resource->resolvedLanguageValue()); self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); - self::assertSame('pt', $configured->resolvedLanguageValue()); + self::assertSame(Language::PORTUGUESE, $configured->resolvedLanguageValue()); } public function testLaterOverridesDoNotMutateEarlierClones(): void @@ -64,7 +64,7 @@ public function testItResolvesApiConfigurationWithoutOverrides(): void ]))->configurableResource(); self::assertSame(Units::STANDARD, $resource->resolvedUnitsValue()); - self::assertSame('pt', $resource->resolvedLanguageValue()); + self::assertSame(Language::PORTUGUESE, $resource->resolvedLanguageValue()); } public function testOverridesTakePrecedenceWhenResolvingConfiguration(): void @@ -74,7 +74,7 @@ public function testOverridesTakePrecedenceWhenResolvingConfiguration(): void ->withLanguage(Language::PORTUGUESE); self::assertSame(Units::IMPERIAL, $configured->resolvedUnitsValue()); - self::assertSame('pt', $configured->resolvedLanguageValue()); + self::assertSame(Language::PORTUGUESE, $configured->resolvedLanguageValue()); } } @@ -92,7 +92,7 @@ final class ConfigurableResource extends Resource use WithLanguage; use WithUnits; - public function resolvedLanguageValue(): string + public function resolvedLanguageValue(): Language|string { return $this->resolvedLanguage(); } diff --git a/tests/Unit/Resource/WeatherTest.php b/tests/Unit/Resource/WeatherTest.php new file mode 100644 index 0000000..7e5915a --- /dev/null +++ b/tests/Unit/Resource/WeatherTest.php @@ -0,0 +1,116 @@ +respondWithFixture('weather/current/success.json'); + + $weather = $this->api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(CurrentWeather::class, $weather); + self::assertSame('Socorro', $weather->name()); + self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/weather', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testUsesApiConfigurationForTheRequestAndEntity(): void + { + $this->api = new OpenWeatherMap('api-key', [ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + OpenWeatherMap::OPTION_LANGUAGE => Language::PORTUGUESE, + ]); + $this->api->setup()->client($this->client); + $this->client->addResponse(new Response( + body: '{"main":{"temp":72.5},"wind":{"speed":10}}', + )); + + $weather = $this->api->weather()->current(38.7223, -9.1393); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $weather->temperatureUnit()); + self::assertSame('72.5 °F', $weather->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $weather->wind()?->speedUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testFluentOverridesAreRequestLocal(): void + { + $this->client->addResponse(new Response( + body: '{"main":{"temp":72.5}}', + )); + $this->respondWithFixture('weather/current/success.json'); + + $weather = $this->api->weather(); + $overridden = $weather + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt'); + + $imperial = $overridden->current(38.7223, -9.1393); + $imperialRequest = $this->client->getLastRequest(); + $metric = $weather->current(38.7223, -9.1393); + $metricRequest = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $imperial->temperatureUnit()); + self::assertSame(Unit::CELSIUS, $metric->temperatureUnit()); + self::assertSame('imperial', $this->query($imperialRequest)['units']); + self::assertSame('pt', $this->query($imperialRequest)['lang']); + self::assertSame('metric', $this->query($metricRequest)['units']); + self::assertSame('en', $this->query($metricRequest)['lang']); + } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->weather()->current($latitude, $longitude); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} From d9f466ba7ffb41ba3002ab57b0c274afb66eb192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 18:14:30 +0100 Subject: [PATCH 026/113] feat(weather): add forecast period entities --- .../Concern/HasWeatherMeasurements.php | 154 +++++++++++++++ src/Entity/Weather/Condition.php | 2 +- src/Entity/Weather/CurrentWeather.php | 150 +------------- src/Entity/Weather/Forecast/Period.php | 164 ++++++++++++++++ src/Entity/Weather/Forecast/Precipitation.php | 43 ++++ .../Entity/Weather/CurrentWeatherTest.php | 2 +- .../Entity/Weather/Forecast/PeriodTest.php | 184 ++++++++++++++++++ 7 files changed, 550 insertions(+), 149 deletions(-) create mode 100644 src/Entity/Weather/Concern/HasWeatherMeasurements.php create mode 100644 src/Entity/Weather/Forecast/Period.php create mode 100644 src/Entity/Weather/Forecast/Precipitation.php create mode 100644 tests/Unit/Entity/Weather/Forecast/PeriodTest.php diff --git a/src/Entity/Weather/Concern/HasWeatherMeasurements.php b/src/Entity/Weather/Concern/HasWeatherMeasurements.php new file mode 100644 index 0000000..d1da12b --- /dev/null +++ b/src/Entity/Weather/Concern/HasWeatherMeasurements.php @@ -0,0 +1,154 @@ +temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return $this->formatTemperature($this->temperature); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->feelsLikeTemperature); + } + + public function minimumTemperature(): ?float + { + return $this->minimumTemperature; + } + + public function minimumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function minimumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->minimumTemperature); + } + + public function maximumTemperature(): ?float + { + return $this->maximumTemperature; + } + + public function maximumTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function maximumTemperatureWithUnit(): ?string + { + return $this->formatTemperature($this->maximumTemperature); + } + + public function pressure(): ?int + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return $this->formatPressure($this->pressure); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function seaLevelPressure(): ?int + { + return $this->seaLevelPressure; + } + + public function seaLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function seaLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->seaLevelPressure); + } + + public function groundLevelPressure(): ?int + { + return $this->groundLevelPressure; + } + + public function groundLevelPressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function groundLevelPressureWithUnit(): ?string + { + return $this->formatPressure($this->groundLevelPressure); + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + private function formatTemperature(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } + + private function formatPressure(?int $pressure): ?string + { + return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); + } +} diff --git a/src/Entity/Weather/Condition.php b/src/Entity/Weather/Condition.php index f0abac8..b8916b7 100644 --- a/src/Entity/Weather/Condition.php +++ b/src/Entity/Weather/Condition.php @@ -8,7 +8,7 @@ final class Condition implements EntityInterface { - private const ICON_URL = 'https://openweathermap.org/payload/api/media/file/%s@2x.png'; + private const ICON_URL = 'https://openweathermap.org/img/wn/%s@2x.png'; private function __construct( private readonly ?int $id, diff --git a/src/Entity/Weather/CurrentWeather.php b/src/Entity/Weather/CurrentWeather.php index 80d4740..f35c58c 100644 --- a/src/Entity/Weather/CurrentWeather.php +++ b/src/Entity/Weather/CurrentWeather.php @@ -4,16 +4,17 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasWeatherMeasurements; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current\Precipitation; -use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Formatting\MeasurementFormatter; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; use ProgrammatorDev\OpenWeatherMap\Hydration\UnitsResolver; final class CurrentWeather implements EntityInterface { + use HasWeatherMeasurements; + /** * @param list $conditions */ @@ -126,141 +127,6 @@ public function base(): ?string return $this->base; } - public function temperature(): ?float - { - return $this->temperature; - } - - public function temperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function temperatureWithUnit(): ?string - { - return $this->formatTemperature($this->temperature); - } - - public function feelsLikeTemperature(): ?float - { - return $this->feelsLikeTemperature; - } - - public function feelsLikeTemperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function feelsLikeTemperatureWithUnit(): ?string - { - return $this->formatTemperature($this->feelsLikeTemperature); - } - - public function minimumTemperature(): ?float - { - return $this->minimumTemperature; - } - - public function minimumTemperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function minimumTemperatureWithUnit(): ?string - { - return $this->formatTemperature($this->minimumTemperature); - } - - public function maximumTemperature(): ?float - { - return $this->maximumTemperature; - } - - public function maximumTemperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function maximumTemperatureWithUnit(): ?string - { - return $this->formatTemperature($this->maximumTemperature); - } - - public function pressure(): ?int - { - return $this->pressure; - } - - public function pressureUnit(): Unit - { - return Unit::HECTOPASCAL; - } - - public function pressureWithUnit(): ?string - { - return $this->formatPressure($this->pressure); - } - - public function humidity(): ?int - { - return $this->humidity; - } - - public function humidityUnit(): Unit - { - return Unit::PERCENT; - } - - public function humidityWithUnit(): ?string - { - return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); - } - - public function seaLevelPressure(): ?int - { - return $this->seaLevelPressure; - } - - public function seaLevelPressureUnit(): Unit - { - return Unit::HECTOPASCAL; - } - - public function seaLevelPressureWithUnit(): ?string - { - return $this->formatPressure($this->seaLevelPressure); - } - - public function groundLevelPressure(): ?int - { - return $this->groundLevelPressure; - } - - public function groundLevelPressureUnit(): Unit - { - return Unit::HECTOPASCAL; - } - - public function groundLevelPressureWithUnit(): ?string - { - return $this->formatPressure($this->groundLevelPressure); - } - - public function visibility(): ?int - { - return $this->visibility; - } - - public function visibilityUnit(): Unit - { - return Unit::METER; - } - - public function visibilityWithUnit(): ?string - { - return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); - } - public function wind(): ?Wind { return $this->wind; @@ -330,14 +196,4 @@ public function code(): ?int { return $this->code; } - - private function formatTemperature(?float $temperature): ?string - { - return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); - } - - private function formatPressure(?int $pressure): ?string - { - return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); - } } diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php new file mode 100644 index 0000000..0f7e703 --- /dev/null +++ b/src/Entity/Weather/Forecast/Period.php @@ -0,0 +1,164 @@ + $conditions + */ + private function __construct( + private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $minimumTemperature, + private readonly ?float $maximumTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?int $seaLevelPressure, + private readonly ?int $groundLevelPressure, + private readonly ?float $dewPoint, + private readonly array $conditions, + private readonly ?Clouds $clouds, + private readonly ?Wind $wind, + private readonly ?int $visibility, + private readonly ?float $precipitationProbability, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly ?string $partOfDay, + private readonly ?string $forecastAtText, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $clouds = $reader->nullableArray('clouds'); + $wind = $reader->nullableArray('wind'); + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + + return new self( + forecastAt: $reader->nullableTimestamp('dt'), + temperature: $reader->nullableFloat('main.temp'), + feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), + minimumTemperature: $reader->nullableFloat('main.temp_min'), + maximumTemperature: $reader->nullableFloat('main.temp_max'), + pressure: $reader->nullableInt('main.pressure'), + humidity: $reader->nullableInt('main.humidity'), + seaLevelPressure: $reader->nullableInt('main.sea_level'), + groundLevelPressure: $reader->nullableInt('main.grnd_level'), + // Captured 5 Day Forecast responses include dew_point even though its field table omits it. + // The related Hourly Forecast contract documents the field and its unit behavior. + // https://openweathermap.org/forecast5 + // https://openweathermap.org/api/hourly-forecast?collection=current_forecast + dewPoint: $reader->nullableFloat('main.dew_point'), + conditions: $conditions, + clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + visibility: $reader->nullableInt('visibility'), + precipitationProbability: $reader->nullableFloat('pop'), + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + partOfDay: $reader->nullableString('sys.pod'), + forecastAtText: $reader->nullableString('dt_txt'), + units: UnitsResolver::fromContext($context), + ); + } + + public function forecastAt(): ?\DateTimeImmutable + { + return $this->forecastAt; + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function dewPointUnit(): Unit + { + return $this->temperatureUnit(); + } + + public function dewPointWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPoint, + $this->dewPointUnit(), + ); + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function precipitationProbability(): ?float + { + return $this->precipitationProbability; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + public function partOfDay(): ?string + { + return $this->partOfDay; + } + + public function forecastAtText(): ?string + { + return $this->forecastAtText; + } +} diff --git a/src/Entity/Weather/Forecast/Precipitation.php b/src/Entity/Weather/Forecast/Precipitation.php new file mode 100644 index 0000000..506e6ab --- /dev/null +++ b/src/Entity/Weather/Forecast/Precipitation.php @@ -0,0 +1,43 @@ +nullableFloat('3h'), + ); + } + + public function lastThreeHours(): ?float + { + return $this->lastThreeHours; + } + + public function lastThreeHoursUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function lastThreeHoursWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->lastThreeHours, + $this->lastThreeHoursUnit(), + ); + } +} diff --git a/tests/Unit/Entity/Weather/CurrentWeatherTest.php b/tests/Unit/Entity/Weather/CurrentWeatherTest.php index 5097ffb..09067d3 100644 --- a/tests/Unit/Entity/Weather/CurrentWeatherTest.php +++ b/tests/Unit/Entity/Weather/CurrentWeatherTest.php @@ -50,7 +50,7 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame('scattered clouds', $weather->conditions()[0]->description()); self::assertSame('03d', $weather->conditions()[0]->icon()); self::assertSame( - 'https://openweathermap.org/payload/api/media/file/03d@2x.png', + 'https://openweathermap.org/img/wn/03d@2x.png', $weather->conditions()[0]->iconUrl(), ); diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php new file mode 100644 index 0000000..30948f5 --- /dev/null +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -0,0 +1,184 @@ +forecastAt()?->getTimestamp()); + self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(22.54, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('22.54 °C', $period->temperatureWithUnit()); + self::assertSame(22.83, $period->feelsLikeTemperature()); + self::assertSame(22.54, $period->minimumTemperature()); + self::assertSame(23.52, $period->maximumTemperature()); + self::assertSame(1016, $period->pressure()); + self::assertSame('1016 hPa', $period->pressureWithUnit()); + self::assertSame(76, $period->humidity()); + self::assertSame('76 %', $period->humidityWithUnit()); + self::assertSame(1016, $period->seaLevelPressure()); + self::assertSame(1006, $period->groundLevelPressure()); + self::assertSame(18.1, $period->dewPoint()); + self::assertSame(Unit::CELSIUS, $period->dewPointUnit()); + self::assertSame('18.1 °C', $period->dewPointWithUnit()); + self::assertSame(10000, $period->visibility()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + + self::assertCount(1, $period->conditions()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertSame(48, $period->clouds()?->coverage()); + self::assertSame(3.36, $period->wind()?->speed()); + self::assertSame('3.36 m/s', $period->wind()?->speedWithUnit()); + self::assertSame(349, $period->wind()?->direction()); + self::assertSame(4.91, $period->wind()?->gust()); + + self::assertSame(0.0, $period->precipitationProbability()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame('d', $period->partOfDay()); + self::assertSame('2026-08-01 09:00:00', $period->forecastAtText()); + } + + public function testHydratesConditionalRain(): void + { + $period = self::fromFixture('weather/forecast/rain.json'); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(5.49, $period->rain()?->lastThreeHours()); + self::assertSame(Unit::MILLIMETER, $period->rain()?->lastThreeHoursUnit()); + self::assertSame('5.49 mm', $period->rain()?->lastThreeHoursWithUnit()); + self::assertNull($period->snow()); + } + + public function testHydratesConditionalSnowAndMissingVisibility(): void + { + $period = self::fromFixture('weather/forecast/snow.json'); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(3.0, $period->snow()?->lastThreeHours()); + self::assertSame('3 mm', $period->snow()?->lastThreeHoursWithUnit()); + self::assertNull($period->rain()); + self::assertNull($period->visibility()); + self::assertNull($period->visibilityWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'main' => [ + 'temp' => 72.5, + 'dew_point' => 60.25, + ], + 'wind' => ['speed' => 10], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('60.25 °F', $period->dewPointWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertSame([], Period::fromArray(['weather' => null])->conditions()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => [ + 'temp' => null, + 'dew_point' => null, + ], + 'weather' => [['icon' => null]], + 'clouds' => ['all' => null], + 'wind' => null, + 'rain' => ['3h' => null, 'unknown' => true], + 'snow' => null, + 'sys' => null, + 'dt_txt' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->forecastAt()); + self::assertNull($period->temperature()); + self::assertNull($period->temperatureWithUnit()); + self::assertNull($period->feelsLikeTemperature()); + self::assertNull($period->pressure()); + self::assertNull($period->dewPoint()); + self::assertNull($period->dewPointWithUnit()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()); + self::assertNull($period->visibility()); + self::assertNull($period->precipitationProbability()); + self::assertNull($period->rain()?->lastThreeHours()); + self::assertNull($period->rain()?->lastThreeHoursWithUnit()); + self::assertNull($period->snow()); + self::assertNull($period->partOfDay()); + self::assertNull($period->forecastAtText()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [['dt' => '1785574800'], 'dt', 'int', 'string']; + yield 'measurements' => [['main' => 'invalid'], 'main', 'array', 'string']; + yield 'temperature' => [['main' => ['temp' => '22.5']], 'main.temp', 'int|float', 'string']; + yield 'dew point' => [['main' => ['dew_point' => '18.1']], 'main.dew_point', 'int|float', 'string']; + yield 'conditions' => [['weather' => 'Clouds'], 'weather', 'array', 'string']; + yield 'condition member' => [['weather' => ['Clouds']], 'weather.0', 'array', 'string']; + yield 'clouds' => [['clouds' => ['all' => 48.5]], 'all', 'int', 'float']; + yield 'wind' => [['wind' => ['speed' => '3.5']], 'speed', 'int|float', 'string']; + yield 'visibility' => [['visibility' => 10000.5], 'visibility', 'int', 'float']; + yield 'precipitation probability' => [['pop' => '1'], 'pop', 'int|float', 'string']; + yield 'rain' => [['rain' => ['3h' => '5.49']], '3h', 'int|float', 'string']; + yield 'system' => [['sys' => 'invalid'], 'sys', 'array', 'string']; + yield 'part of day' => [['sys' => ['pod' => 1]], 'sys.pod', 'string', 'int']; + yield 'forecast time text' => [['dt_txt' => 1785574800], 'dt_txt', 'string', 'int']; + } + + private static function fromFixture(string $path): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['list'][0]); + } +} From 11f557458fa1fce6ad7c248b7e3e3da35357423b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 18:22:03 +0100 Subject: [PATCH 027/113] feat(weather): add forecast response entities --- docs/weather.md | 2 +- .../{CurrentWeather.php => Current.php} | 2 +- src/Entity/Weather/Forecast.php | 81 +++++++++++ src/Entity/Weather/Forecast/City.php | 84 +++++++++++ src/Resource/Weather.php | 8 +- ...CurrentWeatherTest.php => CurrentTest.php} | 18 +-- tests/Unit/Entity/Weather/ForecastTest.php | 134 ++++++++++++++++++ .../Unit/Exception/HydrationExceptionTest.php | 4 +- tests/Unit/Resource/WeatherTest.php | 4 +- 9 files changed, 318 insertions(+), 19 deletions(-) rename src/Entity/Weather/{CurrentWeather.php => Current.php} (99%) create mode 100644 src/Entity/Weather/Forecast.php create mode 100644 src/Entity/Weather/Forecast/City.php rename tests/Unit/Entity/Weather/{CurrentWeatherTest.php => CurrentTest.php} (94%) create mode 100644 tests/Unit/Entity/Weather/ForecastTest.php diff --git a/docs/weather.md b/docs/weather.md index d6d1d5e..8c07681 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -21,7 +21,7 @@ $current = $api->weather()->current( ); ``` -The method returns a `CurrentWeather` entity. Every response property may be +The method returns a `Current` entity. Every response property may be absent or explicitly `null`; missing or `null` condition lists become empty arrays. diff --git a/src/Entity/Weather/CurrentWeather.php b/src/Entity/Weather/Current.php similarity index 99% rename from src/Entity/Weather/CurrentWeather.php rename to src/Entity/Weather/Current.php index f35c58c..9bcc1fa 100644 --- a/src/Entity/Weather/CurrentWeather.php +++ b/src/Entity/Weather/Current.php @@ -11,7 +11,7 @@ use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; use ProgrammatorDev\OpenWeatherMap\Hydration\UnitsResolver; -final class CurrentWeather implements EntityInterface +final class Current implements EntityInterface { use HasWeatherMeasurements; diff --git a/src/Entity/Weather/Forecast.php b/src/Entity/Weather/Forecast.php new file mode 100644 index 0000000..00747da --- /dev/null +++ b/src/Entity/Weather/Forecast.php @@ -0,0 +1,81 @@ + $periods + */ + private function __construct( + private readonly ?string $code, + private readonly ?float $message, + private readonly ?int $count, + private readonly array $periods, + private readonly ?City $city, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $city = $reader->nullableArray('city'); + + return new self( + code: $reader->nullableString('cod'), + message: $reader->nullableFloat('message'), + count: $reader->nullableInt('cnt'), + periods: $periods, + city: $city === null ? null : City::fromArray($city, $context), + ); + } + + public function code(): ?string + { + return $this->code; + } + + public function message(): ?float + { + return $this->message; + } + + public function count(): ?int + { + return $this->count; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + public function city(): ?City + { + return $this->city; + } +} diff --git a/src/Entity/Weather/Forecast/City.php b/src/Entity/Weather/Forecast/City.php new file mode 100644 index 0000000..0176ffb --- /dev/null +++ b/src/Entity/Weather/Forecast/City.php @@ -0,0 +1,84 @@ +nullableInt('id'), + name: $reader->nullableString('name'), + latitude: $reader->nullableFloat('coord.lat'), + longitude: $reader->nullableFloat('coord.lon'), + countryCode: $reader->nullableString('country'), + population: $reader->nullableInt('population'), + timezoneOffset: $reader->nullableInt('timezone'), + sunriseAt: $reader->nullableTimestamp('sunrise'), + sunsetAt: $reader->nullableTimestamp('sunset'), + ); + } + + public function id(): ?int + { + return $this->id; + } + + public function name(): ?string + { + return $this->name; + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } + + public function countryCode(): ?string + { + return $this->countryCode; + } + + public function population(): ?int + { + return $this->population; + } + + public function timezoneOffset(): ?int + { + return $this->timezoneOffset; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } +} diff --git a/src/Resource/Weather.php b/src/Resource/Weather.php index 7e69a69..1255b6f 100644 --- a/src/Resource/Weather.php +++ b/src/Resource/Weather.php @@ -3,7 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource; use ProgrammatorDev\Api\Resource; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\CurrentWeather; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -13,13 +13,13 @@ final class Weather extends Resource use WithLanguage; use WithUnits; - public function current(float $latitude, float $longitude): CurrentWeather + public function current(float $latitude, float $longitude): Current { $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); // https://openweathermap.org/api/current?collection=current_forecast - /** @var CurrentWeather $weather */ + /** @var Current $weather */ $weather = $this ->endpoint() ->queries([ @@ -29,7 +29,7 @@ public function current(float $latitude, float $longitude): CurrentWeather 'lang' => $this->resolvedLanguage(), ]) ->get('/data/2.5/weather') - ->entity(CurrentWeather::class); + ->entity(Current::class); return $weather; } diff --git a/tests/Unit/Entity/Weather/CurrentWeatherTest.php b/tests/Unit/Entity/Weather/CurrentTest.php similarity index 94% rename from tests/Unit/Entity/Weather/CurrentWeatherTest.php rename to tests/Unit/Entity/Weather/CurrentTest.php index 09067d3..c943afc 100644 --- a/tests/Unit/Entity/Weather/CurrentWeatherTest.php +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -6,18 +6,18 @@ use PHPUnit\Framework\TestCase; use ProgrammatorDev\Api\Config\Config; use ProgrammatorDev\Api\Context\Context; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\CurrentWeather; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; -final class CurrentWeatherTest extends TestCase +final class CurrentTest extends TestCase { public function testHydratesCapturedCurrentWeather(): void { - $weather = CurrentWeather::fromArray( + $weather = Current::fromArray( Fixture::json('weather/current/success.json'), ); @@ -83,7 +83,7 @@ public function testHydratesCapturedCurrentWeather(): void public function testHydratesConditionalRain(): void { - $weather = CurrentWeather::fromArray( + $weather = Current::fromArray( Fixture::json('weather/current/rain.json'), ); @@ -96,7 +96,7 @@ public function testHydratesConditionalRain(): void public function testHydratesConditionalSnowAndMissingFields(): void { - $weather = CurrentWeather::fromArray( + $weather = Current::fromArray( Fixture::json('weather/current/snow.json'), ); @@ -116,7 +116,7 @@ public function testRetainsUnitsFromHydrationContext(): void OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, ])); - $weather = CurrentWeather::fromArray([ + $weather = Current::fromArray([ 'main' => ['temp' => 72.5], 'wind' => ['speed' => 10, 'gust' => 15], ], $context); @@ -130,9 +130,9 @@ public function testRetainsUnitsFromHydrationContext(): void public function testToleratesMissingNullUnknownAndPartialFields(): void { - self::assertSame([], CurrentWeather::fromArray(['weather' => null])->conditions()); + self::assertSame([], Current::fromArray(['weather' => null])->conditions()); - $weather = CurrentWeather::fromArray([ + $weather = Current::fromArray([ 'coord' => ['lat' => null, 'unknown' => true], 'weather' => [['icon' => null]], 'main' => ['temp' => null], @@ -181,7 +181,7 @@ public function testRejectsInvalidKnownFieldTypes( $receivedType, )); - CurrentWeather::fromArray($data); + Current::fromArray($data); } public static function invalidFields(): iterable diff --git a/tests/Unit/Entity/Weather/ForecastTest.php b/tests/Unit/Entity/Weather/ForecastTest.php new file mode 100644 index 0000000..57e8481 --- /dev/null +++ b/tests/Unit/Entity/Weather/ForecastTest.php @@ -0,0 +1,134 @@ +code()); + self::assertSame(0.0, $forecast->message()); + self::assertSame(40, $forecast->count()); + self::assertCount(40, $forecast->periods()); + self::assertSame(1785574800, $forecast->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(22.54, $forecast->periods()[0]->temperature()); + + $city = $forecast->city(); + + self::assertSame(6458923, $city?->id()); + self::assertSame('Lisbon Municipality', $city?->name()); + self::assertSame(38.7223, $city?->latitude()); + self::assertSame(-9.1393, $city?->longitude()); + self::assertSame('PT', $city?->countryCode()); + self::assertSame(0, $city?->population()); + self::assertSame(3600, $city?->timezoneOffset()); + self::assertSame(1785562660, $city?->sunriseAt()?->getTimestamp()); + self::assertSame('UTC', $city?->sunriseAt()?->getTimezone()->getName()); + self::assertSame(1785613679, $city?->sunsetAt()?->getTimestamp()); + self::assertSame('UTC', $city?->sunsetAt()?->getTimezone()->getName()); + } + + public function testPropagatesHydrationContextToPeriods(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $forecast = Forecast::fromArray([ + 'list' => [ + ['main' => ['temp' => 72.5]], + ], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $forecast->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $forecast->periods()[0]->temperatureWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + self::assertNull(Forecast::fromArray(['city' => null])->city()); + + $forecast = Forecast::fromArray([ + 'cod' => null, + 'message' => null, + 'cnt' => null, + 'list' => null, + 'city' => [ + 'id' => null, + 'coord' => [ + 'lat' => null, + 'unknown' => true, + ], + 'sunrise' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($forecast->code()); + self::assertNull($forecast->message()); + self::assertNull($forecast->count()); + self::assertSame([], $forecast->periods()); + self::assertNull($forecast->city()?->id()); + self::assertNull($forecast->city()?->name()); + self::assertNull($forecast->city()?->latitude()); + self::assertNull($forecast->city()?->longitude()); + self::assertNull($forecast->city()?->countryCode()); + self::assertNull($forecast->city()?->population()); + self::assertNull($forecast->city()?->timezoneOffset()); + self::assertNull($forecast->city()?->sunriseAt()); + self::assertNull($forecast->city()?->sunsetAt()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFieldTypes( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + Forecast::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'code' => [['cod' => 200], 'cod', 'string', 'int']; + yield 'message' => [['message' => '0'], 'message', 'int|float', 'string']; + yield 'count' => [['cnt' => 40.0], 'cnt', 'int', 'float']; + yield 'periods' => [['list' => 'invalid'], 'list', 'array', 'string']; + yield 'period member' => [['list' => ['invalid']], 'list.0', 'array', 'string']; + yield 'city' => [['city' => 'invalid'], 'city', 'array', 'string']; + yield 'city id' => [['city' => ['id' => '1']], 'id', 'int', 'string']; + yield 'city name' => [['city' => ['name' => 1]], 'name', 'string', 'int']; + yield 'coordinates' => [['city' => ['coord' => 'invalid']], 'coord', 'array', 'string']; + yield 'latitude' => [['city' => ['coord' => ['lat' => '38.7']]], 'coord.lat', 'int|float', 'string']; + yield 'country' => [['city' => ['country' => 1]], 'country', 'string', 'int']; + yield 'population' => [['city' => ['population' => 1.5]], 'population', 'int', 'float']; + yield 'timezone' => [['city' => ['timezone' => '3600']], 'timezone', 'int', 'string']; + yield 'sunrise' => [['city' => ['sunrise' => '1785562660']], 'sunrise', 'int', 'string']; + yield 'sunset' => [['city' => ['sunset' => 1785613679.5]], 'sunset', 'int', 'float']; + } +} diff --git a/tests/Unit/Exception/HydrationExceptionTest.php b/tests/Unit/Exception/HydrationExceptionTest.php index 6aecb68..cca8865 100644 --- a/tests/Unit/Exception/HydrationExceptionTest.php +++ b/tests/Unit/Exception/HydrationExceptionTest.php @@ -10,14 +10,14 @@ final class HydrationExceptionTest extends TestCase public function testItDescribesTheInvalidPayloadValue(): void { $exception = HydrationException::invalidType( - entity: 'CurrentWeather', + entity: 'Current', path: 'main.temp', expectedType: 'int|float', value: 'warm' ); self::assertSame( - 'Cannot hydrate CurrentWeather: "main.temp" expected int|float, string received.', + 'Cannot hydrate Current: "main.temp" expected int|float, string received.', $exception->getMessage() ); } diff --git a/tests/Unit/Resource/WeatherTest.php b/tests/Unit/Resource/WeatherTest.php index 7e5915a..3c9fc57 100644 --- a/tests/Unit/Resource/WeatherTest.php +++ b/tests/Unit/Resource/WeatherTest.php @@ -4,7 +4,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\CurrentWeather; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current; use ProgrammatorDev\OpenWeatherMap\Enum\Language; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; @@ -23,7 +23,7 @@ public function testGetsCurrentWeatherByCoordinates(): void ); $request = $this->client->getLastRequest(); - self::assertInstanceOf(CurrentWeather::class, $weather); + self::assertInstanceOf(Current::class, $weather); self::assertSame('Socorro', $weather->name()); self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); self::assertSame('GET', $request->getMethod()); From 57b7b24714773f7d95e0133e9aee1d7f9a9e29f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 18:40:33 +0100 Subject: [PATCH 028/113] feat(weather): add forecast endpoint --- docs/weather.md | 48 ++++++++++++ src/Entity/Weather/Current.php | 7 -- src/Entity/Weather/Forecast.php | 14 ---- src/Entity/Weather/Forecast/Period.php | 18 ++++- src/Enum/PartOfDay.php | 9 +++ src/Exception/HydrationException.php | 15 ++++ src/Resource/Weather.php | 30 ++++++++ tests/Unit/Entity/Weather/CurrentTest.php | 4 +- .../Entity/Weather/Forecast/PeriodTest.php | 14 +++- tests/Unit/Entity/Weather/ForecastTest.php | 10 +-- tests/Unit/Resource/WeatherTest.php | 73 ++++++++++++++++++- 11 files changed, 205 insertions(+), 37 deletions(-) create mode 100644 src/Enum/PartOfDay.php diff --git a/docs/weather.md b/docs/weather.md index 8c07681..90c6468 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -67,6 +67,54 @@ Observation, sunrise, and sunset timestamps are nullable `DateTimeImmutable` values normalized to UTC. `timezoneOffset()` retains the location's offset from UTC in seconds. +## Forecast + +The 5 Day / 3 Hour Forecast API is available on OpenWeather's standard free +and paid subscriptions. See the +[official forecast documentation](https://openweathermap.org/api/forecast5) +for the upstream endpoint contract. + +Use `forecast()` with a latitude and longitude. The optional `count` limits the +number of three-hour periods returned. It must be a positive integer; no +maximum is imposed by this library because the official documentation does not +define one. + +```php +$forecast = $api->weather()->forecast( + latitude: 38.7223, + longitude: -9.1393, + count: 8, +); +``` + +The method returns a `Forecast` entity containing its periods and city +metadata. Missing or `null` period lists become empty arrays. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\PartOfDay; + +foreach ($forecast->periods() as $period) { + echo $period->forecastAt()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); + echo $period->wind()?->speed(); + echo $period->rain()?->lastThreeHours(); + echo $period->snow()?->lastThreeHours(); + + if ($period->partOfDay() === PartOfDay::DAY) { + // This period occurs during daytime at the forecast location. + } +} + +echo $forecast->city()?->name(); +echo $forecast->city()?->latitude(); +echo $forecast->city()?->longitude(); +echo $forecast->city()?->timezoneOffset(); +``` + +Forecast, sunrise, and sunset timestamps are nullable UTC +`DateTimeImmutable` values. The city timezone offset remains separate. + ## Units And Language Weather requests use the API configuration by default. Request-local fluent diff --git a/src/Entity/Weather/Current.php b/src/Entity/Weather/Current.php index 9bcc1fa..f32d8b0 100644 --- a/src/Entity/Weather/Current.php +++ b/src/Entity/Weather/Current.php @@ -45,7 +45,6 @@ private function __construct( private readonly ?int $timezoneOffset, private readonly ?int $id, private readonly ?string $name, - private readonly ?int $code, private readonly Units $units, ) {} @@ -99,7 +98,6 @@ public static function fromArray(array $data, ?Context $context = null): static timezoneOffset: $reader->nullableInt('timezone'), id: $reader->nullableInt('id'), name: $reader->nullableString('name'), - code: $reader->nullableInt('cod'), units: UnitsResolver::fromContext($context), ); } @@ -191,9 +189,4 @@ public function name(): ?string { return $this->name; } - - public function code(): ?int - { - return $this->code; - } } diff --git a/src/Entity/Weather/Forecast.php b/src/Entity/Weather/Forecast.php index 00747da..5085ed3 100644 --- a/src/Entity/Weather/Forecast.php +++ b/src/Entity/Weather/Forecast.php @@ -15,8 +15,6 @@ final class Forecast implements EntityInterface * @param list $periods */ private function __construct( - private readonly ?string $code, - private readonly ?float $message, private readonly ?int $count, private readonly array $periods, private readonly ?City $city, @@ -43,24 +41,12 @@ public static function fromArray(array $data, ?Context $context = null): static $city = $reader->nullableArray('city'); return new self( - code: $reader->nullableString('cod'), - message: $reader->nullableFloat('message'), count: $reader->nullableInt('cnt'), periods: $periods, city: $city === null ? null : City::fromArray($city, $context), ); } - public function code(): ?string - { - return $this->code; - } - - public function message(): ?float - { - return $this->message; - } - public function count(): ?int { return $this->count; diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php index 0f7e703..5014296 100644 --- a/src/Entity/Weather/Forecast/Period.php +++ b/src/Entity/Weather/Forecast/Period.php @@ -8,6 +8,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasWeatherMeasurements; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Condition; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Wind; +use ProgrammatorDev\OpenWeatherMap\Enum\PartOfDay; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; @@ -40,7 +41,7 @@ private function __construct( private readonly ?float $precipitationProbability, private readonly ?Precipitation $rain, private readonly ?Precipitation $snow, - private readonly ?string $partOfDay, + private readonly ?PartOfDay $partOfDay, private readonly ?string $forecastAtText, private readonly Units $units, ) {} @@ -67,6 +68,17 @@ public static function fromArray(array $data, ?Context $context = null): static $wind = $reader->nullableArray('wind'); $rain = $reader->nullableArray('rain'); $snow = $reader->nullableArray('snow'); + $partOfDay = $reader->nullableString('sys.pod'); + + if ($partOfDay !== null) { + $partOfDay = PartOfDay::tryFrom($partOfDay) + ?? throw HydrationException::invalidValue( + self::class, + 'sys.pod', + '"d" or "n"', + $partOfDay, + ); + } return new self( forecastAt: $reader->nullableTimestamp('dt'), @@ -90,7 +102,7 @@ public static function fromArray(array $data, ?Context $context = null): static precipitationProbability: $reader->nullableFloat('pop'), rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), - partOfDay: $reader->nullableString('sys.pod'), + partOfDay: $partOfDay, forecastAtText: $reader->nullableString('dt_txt'), units: UnitsResolver::fromContext($context), ); @@ -152,7 +164,7 @@ public function snow(): ?Precipitation return $this->snow; } - public function partOfDay(): ?string + public function partOfDay(): ?PartOfDay { return $this->partOfDay; } diff --git a/src/Enum/PartOfDay.php b/src/Enum/PartOfDay.php new file mode 100644 index 0000000..1fce385 --- /dev/null +++ b/src/Enum/PartOfDay.php @@ -0,0 +1,9 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/2.5/forecast') + ->entity(Forecast::class); + + return $forecast; + } } diff --git a/tests/Unit/Entity/Weather/CurrentTest.php b/tests/Unit/Entity/Weather/CurrentTest.php index c943afc..1e6d9eb 100644 --- a/tests/Unit/Entity/Weather/CurrentTest.php +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -78,7 +78,6 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(3600, $weather->timezoneOffset()); self::assertSame(8012502, $weather->id()); self::assertSame('Socorro', $weather->name()); - self::assertSame(200, $weather->code()); } public function testHydratesConditionalRain(): void @@ -140,6 +139,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'clouds' => ['all' => null], 'rain' => ['1h' => null, 'unknown' => true], 'sys' => null, + 'cod' => new \stdClass(), 'unknown' => new \stdClass(), ]); @@ -163,7 +163,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($weather->countryCode()); self::assertNull($weather->sunriseAt()); self::assertNull($weather->name()); - self::assertNull($weather->code()); } #[DataProvider('invalidFields')] @@ -198,6 +197,5 @@ public static function invalidFields(): iterable yield 'rain' => [['rain' => ['1h' => '2.5']], '1h', 'int|float', 'string']; yield 'observation time' => [['dt' => '1785573885'], 'dt', 'int', 'string']; yield 'country' => [['sys' => ['country' => 1]], 'sys.country', 'string', 'int']; - yield 'code' => [['cod' => '200'], 'cod', 'int', 'string']; } } diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php index 30948f5..14a5a4f 100644 --- a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -7,6 +7,7 @@ use ProgrammatorDev\Api\Config\Config; use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Forecast\Period; +use ProgrammatorDev\OpenWeatherMap\Enum\PartOfDay; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; @@ -50,7 +51,7 @@ public function testHydratesCapturedForecastPeriod(): void self::assertSame(0.0, $period->precipitationProbability()); self::assertNull($period->rain()); self::assertNull($period->snow()); - self::assertSame('d', $period->partOfDay()); + self::assertSame(PartOfDay::DAY, $period->partOfDay()); self::assertSame('2026-08-01 09:00:00', $period->forecastAtText()); } @@ -76,6 +77,7 @@ public function testHydratesConditionalSnowAndMissingVisibility(): void self::assertNull($period->rain()); self::assertNull($period->visibility()); self::assertNull($period->visibilityWithUnit()); + self::assertSame(PartOfDay::NIGHT, $period->partOfDay()); } public function testRetainsUnitsFromHydrationContext(): void @@ -139,6 +141,16 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($period->forecastAtText()); } + public function testRejectsUnknownPartOfDay(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '"sys.pod" expected "d" or "n", "x" received.', + ); + + Period::fromArray(['sys' => ['pod' => 'x']]); + } + #[DataProvider('invalidFields')] public function testRejectsInvalidKnownFieldTypes( array $data, diff --git a/tests/Unit/Entity/Weather/ForecastTest.php b/tests/Unit/Entity/Weather/ForecastTest.php index 57e8481..739c569 100644 --- a/tests/Unit/Entity/Weather/ForecastTest.php +++ b/tests/Unit/Entity/Weather/ForecastTest.php @@ -21,8 +21,6 @@ public function testHydratesCapturedForecast(): void Fixture::json('weather/forecast/success.json'), ); - self::assertSame('200', $forecast->code()); - self::assertSame(0.0, $forecast->message()); self::assertSame(40, $forecast->count()); self::assertCount(40, $forecast->periods()); self::assertSame(1785574800, $forecast->periods()[0]->forecastAt()?->getTimestamp()); @@ -64,8 +62,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull(Forecast::fromArray(['city' => null])->city()); $forecast = Forecast::fromArray([ - 'cod' => null, - 'message' => null, + 'cod' => new \stdClass(), + 'message' => new \stdClass(), 'cnt' => null, 'list' => null, 'city' => [ @@ -80,8 +78,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($forecast->code()); - self::assertNull($forecast->message()); self::assertNull($forecast->count()); self::assertSame([], $forecast->periods()); self::assertNull($forecast->city()?->id()); @@ -115,8 +111,6 @@ public function testRejectsInvalidKnownFieldTypes( public static function invalidFields(): iterable { - yield 'code' => [['cod' => 200], 'cod', 'string', 'int']; - yield 'message' => [['message' => '0'], 'message', 'int|float', 'string']; yield 'count' => [['cnt' => 40.0], 'cnt', 'int', 'float']; yield 'periods' => [['list' => 'invalid'], 'list', 'array', 'string']; yield 'period member' => [['list' => ['invalid']], 'list.0', 'array', 'string']; diff --git a/tests/Unit/Resource/WeatherTest.php b/tests/Unit/Resource/WeatherTest.php index 3c9fc57..893e367 100644 --- a/tests/Unit/Resource/WeatherTest.php +++ b/tests/Unit/Resource/WeatherTest.php @@ -5,6 +5,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Forecast; use ProgrammatorDev\OpenWeatherMap\Enum\Language; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; @@ -88,8 +89,58 @@ public function testFluentOverridesAreRequestLocal(): void self::assertSame('en', $this->query($metricRequest)['lang']); } + public function testGetsForecastByCoordinates(): void + { + $this->respondWithFixture('weather/forecast/success.json'); + + $forecast = $this->api->weather()->forecast( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Forecast::class, $forecast); + self::assertSame(40, $forecast->count()); + self::assertCount(40, $forecast->periods()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/forecast', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsLimitedForecastWithFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"cnt":1,"list":[{"main":{"temp":72.5}}]}', + )); + + $forecast = $this->api + ->weather() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->forecast(38.7223, -9.1393, count: 1); + $request = $this->client->getLastRequest(); + + self::assertSame(1, $forecast->count()); + self::assertSame(Unit::FAHRENHEIT, $forecast->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $forecast->periods()[0]->temperatureWithUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'cnt' => '1', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + #[DataProvider('invalidCoordinates')] - public function testRejectsInvalidCoordinates( + public function testCurrentRejectsInvalidCoordinates( float $latitude, float $longitude, string $message, @@ -100,6 +151,26 @@ public function testRejectsInvalidCoordinates( $this->api->weather()->current($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testForecastRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->weather()->forecast($latitude, $longitude); + } + + public function testForecastRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The forecast count must be at least 1.'); + + $this->api->weather()->forecast(38.7223, -9.1393, count: 0); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From d40b200b9c2ec1c4df9027fc29b34d0ff4db85f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 18:48:33 +0100 Subject: [PATCH 029/113] fix(weather): align entities with API contract --- docs/weather.md | 6 ++++-- src/Entity/Weather/Condition.php | 2 ++ src/Entity/Weather/Current.php | 21 ------------------- src/Entity/Weather/Current/Precipitation.php | 4 +++- src/Entity/Weather/Forecast/Precipitation.php | 2 ++ src/Enum/PartOfDay.php | 5 +++++ tests/Unit/Entity/Weather/CurrentTest.php | 17 +++++++-------- 7 files changed, 24 insertions(+), 33 deletions(-) diff --git a/docs/weather.md b/docs/weather.md index 90c6468..7c3ad34 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -39,7 +39,9 @@ echo $current->visibility(); ``` Conditions, wind, and clouds are exposed as nested entities. A condition keeps -the raw OpenWeather icon code and provides its absolute image URL. +the raw OpenWeather icon code and provides its absolute image URL. A response +can contain multiple conditions; OpenWeather defines the first as the primary +condition. ```php foreach ($current->conditions() as $condition) { @@ -56,7 +58,7 @@ echo $current->clouds()?->coverage(); ``` Rain and snow are conditional. When present, `lastHour()` returns the -precipitation volume reported for the preceding hour in millimetres. +precipitation reported for the preceding hour in millimetres per hour. ```php echo $current->rain()?->lastHour(); diff --git a/src/Entity/Weather/Condition.php b/src/Entity/Weather/Condition.php index b8916b7..7694358 100644 --- a/src/Entity/Weather/Condition.php +++ b/src/Entity/Weather/Condition.php @@ -8,6 +8,8 @@ final class Condition implements EntityInterface { + // Verified against the working OpenWeather icon endpoint. + // https://openweathermap.org/img/wn/10d@2x.png private const ICON_URL = 'https://openweathermap.org/img/wn/%s@2x.png'; private function __construct( diff --git a/src/Entity/Weather/Current.php b/src/Entity/Weather/Current.php index f32d8b0..0300e89 100644 --- a/src/Entity/Weather/Current.php +++ b/src/Entity/Weather/Current.php @@ -22,7 +22,6 @@ private function __construct( private readonly ?float $latitude, private readonly ?float $longitude, private readonly array $conditions, - private readonly ?string $base, private readonly ?float $temperature, private readonly ?float $feelsLikeTemperature, private readonly ?float $minimumTemperature, @@ -37,8 +36,6 @@ private function __construct( private readonly ?Precipitation $rain, private readonly ?Precipitation $snow, private readonly ?\DateTimeImmutable $observedAt, - private readonly ?int $systemType, - private readonly ?int $systemId, private readonly ?string $countryCode, private readonly ?\DateTimeImmutable $sunriseAt, private readonly ?\DateTimeImmutable $sunsetAt, @@ -75,7 +72,6 @@ public static function fromArray(array $data, ?Context $context = null): static latitude: $reader->nullableFloat('coord.lat'), longitude: $reader->nullableFloat('coord.lon'), conditions: $conditions, - base: $reader->nullableString('base'), temperature: $reader->nullableFloat('main.temp'), feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), minimumTemperature: $reader->nullableFloat('main.temp_min'), @@ -90,8 +86,6 @@ public static function fromArray(array $data, ?Context $context = null): static rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), observedAt: $reader->nullableTimestamp('dt'), - systemType: $reader->nullableInt('sys.type'), - systemId: $reader->nullableInt('sys.id'), countryCode: $reader->nullableString('sys.country'), sunriseAt: $reader->nullableTimestamp('sys.sunrise'), sunsetAt: $reader->nullableTimestamp('sys.sunset'), @@ -120,11 +114,6 @@ public function conditions(): array return $this->conditions; } - public function base(): ?string - { - return $this->base; - } - public function wind(): ?Wind { return $this->wind; @@ -150,16 +139,6 @@ public function observedAt(): ?\DateTimeImmutable return $this->observedAt; } - public function systemType(): ?int - { - return $this->systemType; - } - - public function systemId(): ?int - { - return $this->systemId; - } - public function countryCode(): ?string { return $this->countryCode; diff --git a/src/Entity/Weather/Current/Precipitation.php b/src/Entity/Weather/Current/Precipitation.php index 99cba2e..3d22281 100644 --- a/src/Entity/Weather/Current/Precipitation.php +++ b/src/Entity/Weather/Current/Precipitation.php @@ -30,7 +30,9 @@ public function lastHour(): ?float public function lastHourUnit(): Unit { - return Unit::MILLIMETER; + // Current Weather documents the one-hour precipitation value as mm/h. + // https://openweathermap.org/api/current + return Unit::MILLIMETERS_PER_HOUR; } public function lastHourWithUnit(): ?string diff --git a/src/Entity/Weather/Forecast/Precipitation.php b/src/Entity/Weather/Forecast/Precipitation.php index 506e6ab..9d8ba95 100644 --- a/src/Entity/Weather/Forecast/Precipitation.php +++ b/src/Entity/Weather/Forecast/Precipitation.php @@ -30,6 +30,8 @@ public function lastThreeHours(): ?float public function lastThreeHoursUnit(): Unit { + // The forecast reports a three-hour volume in mm, unlike Current Weather's mm/h value. + // https://openweathermap.org/api/forecast5 return Unit::MILLIMETER; } diff --git a/src/Enum/PartOfDay.php b/src/Enum/PartOfDay.php index 1fce385..4f398a3 100644 --- a/src/Enum/PartOfDay.php +++ b/src/Enum/PartOfDay.php @@ -2,6 +2,11 @@ namespace ProgrammatorDev\OpenWeatherMap\Enum; +/** + * Backed by the `sys.pod` codes documented by the 5 Day / 3 Hour Forecast API. + * + * @see https://openweathermap.org/api/forecast5 + */ enum PartOfDay: string { case DAY = 'd'; diff --git a/tests/Unit/Entity/Weather/CurrentTest.php b/tests/Unit/Entity/Weather/CurrentTest.php index 1e6d9eb..c09529d 100644 --- a/tests/Unit/Entity/Weather/CurrentTest.php +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -23,7 +23,6 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(38.7223, $weather->latitude()); self::assertSame(-9.1393, $weather->longitude()); - self::assertSame('stations', $weather->base()); self::assertSame(22.55, $weather->temperature()); self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); self::assertSame('22.55 °C', $weather->temperatureWithUnit()); @@ -70,8 +69,6 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(1785573885, $weather->observedAt()?->getTimestamp()); self::assertSame('UTC', $weather->observedAt()?->getTimezone()->getName()); - self::assertSame(2, $weather->systemType()); - self::assertSame(2016751, $weather->systemId()); self::assertSame('PT', $weather->countryCode()); self::assertSame(1785562660, $weather->sunriseAt()?->getTimestamp()); self::assertSame(1785613679, $weather->sunsetAt()?->getTimestamp()); @@ -88,8 +85,8 @@ public function testHydratesConditionalRain(): void self::assertSame('Rain', $weather->conditions()[0]->group()); self::assertSame(2.47, $weather->rain()?->lastHour()); - self::assertSame(Unit::MILLIMETER, $weather->rain()?->lastHourUnit()); - self::assertSame('2.47 mm', $weather->rain()?->lastHourWithUnit()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $weather->rain()?->lastHourUnit()); + self::assertSame('2.47 mm/h', $weather->rain()?->lastHourWithUnit()); self::assertNull($weather->snow()); } @@ -101,12 +98,10 @@ public function testHydratesConditionalSnowAndMissingFields(): void self::assertSame('Snow', $weather->conditions()[0]->group()); self::assertSame(1.37, $weather->snow()?->lastHour()); - self::assertSame('1.37 mm', $weather->snow()?->lastHourWithUnit()); + self::assertSame('1.37 mm/h', $weather->snow()?->lastHourWithUnit()); self::assertNull($weather->rain()); self::assertNull($weather->visibility()); self::assertNull($weather->visibilityWithUnit()); - self::assertNull($weather->systemType()); - self::assertNull($weather->systemId()); } public function testRetainsUnitsFromHydrationContext(): void @@ -135,10 +130,14 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'coord' => ['lat' => null, 'unknown' => true], 'weather' => [['icon' => null]], 'main' => ['temp' => null], + 'base' => new \stdClass(), 'wind' => null, 'clouds' => ['all' => null], 'rain' => ['1h' => null, 'unknown' => true], - 'sys' => null, + 'sys' => [ + 'type' => new \stdClass(), + 'id' => new \stdClass(), + ], 'cod' => new \stdClass(), 'unknown' => new \stdClass(), ]); From 5847182b515a18705520dc9d99bd7469eb571662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 18:52:12 +0100 Subject: [PATCH 030/113] docs(weather): align section structure --- docs/weather.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/weather.md b/docs/weather.md index 7c3ad34..b0b9521 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -1,12 +1,12 @@ # Weather +## Current + The Current Weather API is available on OpenWeather's standard free and paid subscriptions. See the [official Current Weather API documentation](https://openweathermap.org/api/current) for the upstream endpoint contract. -## Current Weather - Use `current()` with a latitude and longitude. Both coordinates are validated before the request is sent. From a277d392e3cc0e5658111bed0de7a5b9a64bc0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 22:02:30 +0100 Subject: [PATCH 031/113] test(air-pollution): add response fixtures --- .../Fixtures/air-pollution/current/good.json | 24 + .../air-pollution/current/good.meta.json | 17 + .../current/invalid-coordinates.json | 4 + .../current/invalid-coordinates.meta.json | 17 + .../air-pollution/current/moderate.json | 24 + .../air-pollution/current/moderate.meta.json | 17 + .../Fixtures/air-pollution/current/poor.json | 24 + .../air-pollution/current/poor.meta.json | 17 + .../forecast/good-to-moderate.json | 1544 +++++++++++++++++ .../forecast/good-to-moderate.meta.json | 17 + .../forecast/moderate-to-very-poor.json | 1544 +++++++++++++++++ .../forecast/moderate-to-very-poor.meta.json | 17 + .../Fixtures/air-pollution/history/empty.json | 7 + .../air-pollution/history/empty.meta.json | 19 + .../air-pollution/history/invalid-range.json | 4 + .../history/invalid-range.meta.json | 19 + .../air-pollution/history/success.json | 408 +++++ .../air-pollution/history/success.meta.json | 19 + 18 files changed, 3742 insertions(+) create mode 100644 tests/Fixtures/air-pollution/current/good.json create mode 100644 tests/Fixtures/air-pollution/current/good.meta.json create mode 100644 tests/Fixtures/air-pollution/current/invalid-coordinates.json create mode 100644 tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json create mode 100644 tests/Fixtures/air-pollution/current/moderate.json create mode 100644 tests/Fixtures/air-pollution/current/moderate.meta.json create mode 100644 tests/Fixtures/air-pollution/current/poor.json create mode 100644 tests/Fixtures/air-pollution/current/poor.meta.json create mode 100644 tests/Fixtures/air-pollution/forecast/good-to-moderate.json create mode 100644 tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json create mode 100644 tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json create mode 100644 tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json create mode 100644 tests/Fixtures/air-pollution/history/empty.json create mode 100644 tests/Fixtures/air-pollution/history/empty.meta.json create mode 100644 tests/Fixtures/air-pollution/history/invalid-range.json create mode 100644 tests/Fixtures/air-pollution/history/invalid-range.meta.json create mode 100644 tests/Fixtures/air-pollution/history/success.json create mode 100644 tests/Fixtures/air-pollution/history/success.meta.json diff --git a/tests/Fixtures/air-pollution/current/good.json b/tests/Fixtures/air-pollution/current/good.json new file mode 100644 index 0000000..274e279 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/good.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 151.2073, + "lat": -33.8679 + }, + "list": [ + { + "main": { + "aqi": 1 + }, + "components": { + "co": 96.56, + "no": 0.01, + "no2": 7.17, + "o3": 29.69, + "so2": 1.03, + "pm2_5": 5.89, + "pm10": 7.62, + "nh3": 0.65 + }, + "dt": 1785616883 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/good.meta.json b/tests/Fixtures/air-pollution/current/good.meta.json new file mode 100644 index 0000000..9ca7384 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/good.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": -33.8688, + "lon": 151.2093 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/invalid-coordinates.json b/tests/Fixtures/air-pollution/current/invalid-coordinates.json new file mode 100644 index 0000000..e8a1d66 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/invalid-coordinates.json @@ -0,0 +1,4 @@ +{ + "cod": "400", + "message": "wrong latitude" +} diff --git a/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json b/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json new file mode 100644 index 0000000..7adba4e --- /dev/null +++ b/tests/Fixtures/air-pollution/current/invalid-coordinates.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 91, + "lon": 0 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/moderate.json b/tests/Fixtures/air-pollution/current/moderate.json new file mode 100644 index 0000000..52c5fe9 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/moderate.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 77.209, + "lat": 28.6139 + }, + "list": [ + { + "main": { + "aqi": 3 + }, + "components": { + "co": 383.18, + "no": 0, + "no2": 8.65, + "o3": 40.98, + "so2": 1.11, + "pm2_5": 32.69, + "pm10": 35.31, + "nh3": 3 + }, + "dt": 1785616886 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/moderate.meta.json b/tests/Fixtures/air-pollution/current/moderate.meta.json new file mode 100644 index 0000000..fb4795a --- /dev/null +++ b/tests/Fixtures/air-pollution/current/moderate.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 28.6139, + "lon": 77.209 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/current/poor.json b/tests/Fixtures/air-pollution/current/poor.json new file mode 100644 index 0000000..ee5fcce --- /dev/null +++ b/tests/Fixtures/air-pollution/current/poor.json @@ -0,0 +1,24 @@ +{ + "coord": { + "lon": 116.4074, + "lat": 39.9042 + }, + "list": [ + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.74, + "no": 0, + "no2": 8.33, + "o3": 28.05, + "so2": 6.92, + "pm2_5": 52.28, + "pm10": 57.21, + "nh3": 5.63 + }, + "dt": 1785616886 + } + ] +} diff --git a/tests/Fixtures/air-pollution/current/poor.meta.json b/tests/Fixtures/air-pollution/current/poor.meta.json new file mode 100644 index 0000000..55a0525 --- /dev/null +++ b/tests/Fixtures/air-pollution/current/poor.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Current air pollution by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:49:47Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution", + "query": { + "lat": 39.9042, + "lon": 116.4074 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/forecast/good-to-moderate.json b/tests/Fixtures/air-pollution/forecast/good-to-moderate.json new file mode 100644 index 0000000..b26169a --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/good-to-moderate.json @@ -0,0 +1,1544 @@ +{ + "coord": { + "lon": 77.209, + "lat": 28.6139 + }, + "list": [ + { + "main": { + "aqi": 3 + }, + "components": { + "co": 414.72, + "no": 0, + "no2": 9.7, + "o3": 43.64, + "so2": 1.39, + "pm2_5": 36.74, + "pm10": 39.79, + "nh3": 3.44 + }, + "dt": 1785614400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 383.18, + "no": 0, + "no2": 8.65, + "o3": 40.98, + "so2": 1.11, + "pm2_5": 32.69, + "pm10": 35.31, + "nh3": 3 + }, + "dt": 1785618000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 358.11, + "no": 0, + "no2": 7.98, + "o3": 37.86, + "so2": 0.91, + "pm2_5": 29.46, + "pm10": 31.84, + "nh3": 2.6 + }, + "dt": 1785621600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 337.8, + "no": 0, + "no2": 7.63, + "o3": 34.72, + "so2": 0.8, + "pm2_5": 26.77, + "pm10": 29.1, + "nh3": 2.24 + }, + "dt": 1785625200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 315.49, + "no": 0, + "no2": 7.36, + "o3": 32.97, + "so2": 0.81, + "pm2_5": 23.83, + "pm10": 26.17, + "nh3": 1.96 + }, + "dt": 1785628800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 284.77, + "no": 0.01, + "no2": 6.73, + "o3": 33.56, + "so2": 0.96, + "pm2_5": 19.95, + "pm10": 22.36, + "nh3": 1.72 + }, + "dt": 1785632400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 269.92, + "no": 0.2, + "no2": 6.78, + "o3": 32.36, + "so2": 1.08, + "pm2_5": 17.4, + "pm10": 20.02, + "nh3": 1.7 + }, + "dt": 1785636000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 253.09, + "no": 0.7, + "no2": 6.23, + "o3": 33.68, + "so2": 1.12, + "pm2_5": 15.14, + "pm10": 17.83, + "nh3": 1.71 + }, + "dt": 1785639600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 229.19, + "no": 0.66, + "no2": 5.48, + "o3": 37.93, + "so2": 1.06, + "pm2_5": 12.79, + "pm10": 15.08, + "nh3": 1.53 + }, + "dt": 1785643200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 215.36, + "no": 0.46, + "no2": 5.19, + "o3": 41.08, + "so2": 1.01, + "pm2_5": 11.81, + "pm10": 13.77, + "nh3": 1.45 + }, + "dt": 1785646800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 206.76, + "no": 0.28, + "no2": 5.03, + "o3": 43.17, + "so2": 0.99, + "pm2_5": 11.69, + "pm10": 13.42, + "nh3": 1.41 + }, + "dt": 1785650400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 203.05, + "no": 0.3, + "no2": 4.51, + "o3": 46.57, + "so2": 0.97, + "pm2_5": 11.53, + "pm10": 12.92, + "nh3": 1.26 + }, + "dt": 1785654000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 202.19, + "no": 0.37, + "no2": 3.94, + "o3": 50.34, + "so2": 0.9, + "pm2_5": 12.31, + "pm10": 13.48, + "nh3": 1.2 + }, + "dt": 1785657600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 202.5, + "no": 0.42, + "no2": 3.41, + "o3": 55.02, + "so2": 0.85, + "pm2_5": 14.16, + "pm10": 15.25, + "nh3": 1.17 + }, + "dt": 1785661200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 203.65, + "no": 0.37, + "no2": 3.3, + "o3": 59.26, + "so2": 0.8, + "pm2_5": 16.34, + "pm10": 17.48, + "nh3": 1.26 + }, + "dt": 1785664800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 209.3, + "no": 0.28, + "no2": 4.59, + "o3": 55.88, + "so2": 0.82, + "pm2_5": 15.59, + "pm10": 16.9, + "nh3": 1.54 + }, + "dt": 1785668400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 217.75, + "no": 0.21, + "no2": 6.46, + "o3": 50.18, + "so2": 0.9, + "pm2_5": 13.94, + "pm10": 15.45, + "nh3": 1.89 + }, + "dt": 1785672000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 229.18, + "no": 0.1, + "no2": 8.18, + "o3": 44.7, + "so2": 1.08, + "pm2_5": 12.83, + "pm10": 14.65, + "nh3": 2.19 + }, + "dt": 1785675600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 240.85, + "no": 0.01, + "no2": 9.15, + "o3": 39.89, + "so2": 1.24, + "pm2_5": 12.69, + "pm10": 14.86, + "nh3": 2.19 + }, + "dt": 1785679200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 248.77, + "no": 0, + "no2": 9.26, + "o3": 37.48, + "so2": 1.29, + "pm2_5": 12.81, + "pm10": 15.1, + "nh3": 1.9 + }, + "dt": 1785682800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 254.79, + "no": 0, + "no2": 9.23, + "o3": 35.38, + "so2": 1.27, + "pm2_5": 13.09, + "pm10": 15.44, + "nh3": 1.79 + }, + "dt": 1785686400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 268.97, + "no": 0, + "no2": 9.74, + "o3": 32.92, + "so2": 1.32, + "pm2_5": 13.84, + "pm10": 16.38, + "nh3": 1.9 + }, + "dt": 1785690000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 281.66, + "no": 0, + "no2": 10.28, + "o3": 30.68, + "so2": 1.31, + "pm2_5": 14.61, + "pm10": 17.11, + "nh3": 1.94 + }, + "dt": 1785693600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 289.55, + "no": 0, + "no2": 10.5, + "o3": 28.55, + "so2": 1.21, + "pm2_5": 15.22, + "pm10": 17.57, + "nh3": 1.97 + }, + "dt": 1785697200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 301.37, + "no": 0, + "no2": 10.63, + "o3": 26.34, + "so2": 1.19, + "pm2_5": 15.94, + "pm10": 18.39, + "nh3": 2.01 + }, + "dt": 1785700800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 315.9, + "no": 0, + "no2": 10.78, + "o3": 23.67, + "so2": 1.16, + "pm2_5": 16.57, + "pm10": 19.09, + "nh3": 2.04 + }, + "dt": 1785704400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 327.05, + "no": 0.01, + "no2": 10.9, + "o3": 21.09, + "so2": 1.12, + "pm2_5": 17.01, + "pm10": 19.79, + "nh3": 2.13 + }, + "dt": 1785708000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 334.83, + "no": 0.01, + "no2": 10.96, + "o3": 18.98, + "so2": 1.11, + "pm2_5": 17.33, + "pm10": 20.54, + "nh3": 2.26 + }, + "dt": 1785711600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 338.9, + "no": 0.01, + "no2": 11.04, + "o3": 17.29, + "so2": 1.14, + "pm2_5": 17.71, + "pm10": 21.36, + "nh3": 2.37 + }, + "dt": 1785715200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 340.99, + "no": 0.03, + "no2": 11.22, + "o3": 16.21, + "so2": 1.26, + "pm2_5": 18.27, + "pm10": 22.64, + "nh3": 2.42 + }, + "dt": 1785718800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 346.45, + "no": 0.33, + "no2": 11.62, + "o3": 15.52, + "so2": 1.51, + "pm2_5": 19.19, + "pm10": 24.67, + "nh3": 2.53 + }, + "dt": 1785722400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 347.53, + "no": 0.67, + "no2": 11.85, + "o3": 16.22, + "so2": 1.71, + "pm2_5": 20.17, + "pm10": 26.42, + "nh3": 2.71 + }, + "dt": 1785726000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 314.32, + "no": 0.52, + "no2": 9.19, + "o3": 26.43, + "so2": 1.57, + "pm2_5": 18.47, + "pm10": 23.65, + "nh3": 2.15 + }, + "dt": 1785729600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 302.16, + "no": 0.35, + "no2": 7.91, + "o3": 32.41, + "so2": 1.36, + "pm2_5": 16.47, + "pm10": 20.59, + "nh3": 1.81 + }, + "dt": 1785733200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 302.72, + "no": 0.25, + "no2": 7.5, + "o3": 35.08, + "so2": 1.23, + "pm2_5": 15.17, + "pm10": 18.56, + "nh3": 1.69 + }, + "dt": 1785736800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 319.6, + "no": 0.41, + "no2": 7.76, + "o3": 35.92, + "so2": 1.24, + "pm2_5": 15.07, + "pm10": 18.32, + "nh3": 1.93 + }, + "dt": 1785740400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 351.58, + "no": 0.81, + "no2": 8.64, + "o3": 35.56, + "so2": 1.35, + "pm2_5": 16.95, + "pm10": 20.59, + "nh3": 2.38 + }, + "dt": 1785744000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 383.68, + "no": 1.16, + "no2": 9.31, + "o3": 38.03, + "so2": 1.45, + "pm2_5": 19.68, + "pm10": 23.64, + "nh3": 2.77 + }, + "dt": 1785747600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 408.65, + "no": 1.18, + "no2": 10.2, + "o3": 40.35, + "so2": 1.48, + "pm2_5": 22.25, + "pm10": 26.38, + "nh3": 3.11 + }, + "dt": 1785751200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 429.38, + "no": 0.89, + "no2": 11.54, + "o3": 40.31, + "so2": 1.47, + "pm2_5": 23.19, + "pm10": 27.31, + "nh3": 3.11 + }, + "dt": 1785754800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 443.07, + "no": 0.63, + "no2": 12.82, + "o3": 40.24, + "so2": 1.46, + "pm2_5": 22.28, + "pm10": 26.17, + "nh3": 3.06 + }, + "dt": 1785758400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 445.87, + "no": 0.08, + "no2": 13.2, + "o3": 41.25, + "so2": 1.47, + "pm2_5": 20.22, + "pm10": 23.78, + "nh3": 2.9 + }, + "dt": 1785762000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 456.54, + "no": 0, + "no2": 13.08, + "o3": 39.68, + "so2": 1.52, + "pm2_5": 19.12, + "pm10": 22.82, + "nh3": 2.75 + }, + "dt": 1785765600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 463.56, + "no": 0, + "no2": 12.67, + "o3": 38.36, + "so2": 1.51, + "pm2_5": 18.55, + "pm10": 22.35, + "nh3": 2.53 + }, + "dt": 1785769200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 473.87, + "no": 0, + "no2": 12.88, + "o3": 34.53, + "so2": 1.51, + "pm2_5": 18.68, + "pm10": 22.76, + "nh3": 2.46 + }, + "dt": 1785772800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 496.24, + "no": 0, + "no2": 14, + "o3": 28.9, + "so2": 1.55, + "pm2_5": 19.4, + "pm10": 24.06, + "nh3": 2.54 + }, + "dt": 1785776400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 506.84, + "no": 0.01, + "no2": 14.67, + "o3": 24.55, + "so2": 1.45, + "pm2_5": 19.46, + "pm10": 24, + "nh3": 2.52 + }, + "dt": 1785780000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 502.31, + "no": 0.02, + "no2": 14.61, + "o3": 21.27, + "so2": 1.22, + "pm2_5": 18.9, + "pm10": 22.9, + "nh3": 2.53 + }, + "dt": 1785783600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 495.13, + "no": 0.03, + "no2": 14.4, + "o3": 17.94, + "so2": 1.09, + "pm2_5": 18.67, + "pm10": 22.53, + "nh3": 2.78 + }, + "dt": 1785787200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 487.96, + "no": 0.06, + "no2": 14.19, + "o3": 14.95, + "so2": 1.02, + "pm2_5": 18.61, + "pm10": 22.45, + "nh3": 3.1 + }, + "dt": 1785790800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 478.84, + "no": 0.09, + "no2": 13.97, + "o3": 12.85, + "so2": 0.99, + "pm2_5": 18.56, + "pm10": 22.44, + "nh3": 3.25 + }, + "dt": 1785794400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 461.7, + "no": 0.09, + "no2": 13.52, + "o3": 12.02, + "so2": 0.98, + "pm2_5": 18.16, + "pm10": 22.01, + "nh3": 2.96 + }, + "dt": 1785798000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 438.14, + "no": 0.09, + "no2": 13.15, + "o3": 11.92, + "so2": 1.01, + "pm2_5": 17.45, + "pm10": 21.28, + "nh3": 2.62 + }, + "dt": 1785801600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 419.78, + "no": 0.15, + "no2": 13.14, + "o3": 11.6, + "so2": 1.13, + "pm2_5": 16.76, + "pm10": 21, + "nh3": 2.43 + }, + "dt": 1785805200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 418.78, + "no": 0.68, + "no2": 13.64, + "o3": 10.79, + "so2": 1.36, + "pm2_5": 16.52, + "pm10": 21.57, + "nh3": 2.44 + }, + "dt": 1785808800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 413.06, + "no": 1.84, + "no2": 13.37, + "o3": 12.33, + "so2": 1.53, + "pm2_5": 16.06, + "pm10": 21.6, + "nh3": 2.45 + }, + "dt": 1785812400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 317.79, + "no": 0.99, + "no2": 9.27, + "o3": 27.36, + "so2": 1.27, + "pm2_5": 11.94, + "pm10": 15.79, + "nh3": 1.65 + }, + "dt": 1785816000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 277.21, + "no": 0.67, + "no2": 7.47, + "o3": 36.02, + "so2": 1.06, + "pm2_5": 10.25, + "pm10": 13.5, + "nh3": 1.46 + }, + "dt": 1785819600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 258.41, + "no": 0.51, + "no2": 6.75, + "o3": 41.19, + "so2": 0.96, + "pm2_5": 10.37, + "pm10": 13.4, + "nh3": 1.46 + }, + "dt": 1785823200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 247.49, + "no": 0.42, + "no2": 6.31, + "o3": 45.76, + "so2": 0.92, + "pm2_5": 11.55, + "pm10": 14.47, + "nh3": 1.49 + }, + "dt": 1785826800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 242.4, + "no": 0.49, + "no2": 5.96, + "o3": 49.94, + "so2": 0.91, + "pm2_5": 13.43, + "pm10": 16.31, + "nh3": 1.57 + }, + "dt": 1785830400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 243.17, + "no": 0.63, + "no2": 5.42, + "o3": 55.69, + "so2": 0.91, + "pm2_5": 15.93, + "pm10": 18.81, + "nh3": 1.64 + }, + "dt": 1785834000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 246.74, + "no": 0.15, + "no2": 6.14, + "o3": 58.38, + "so2": 0.91, + "pm2_5": 18.01, + "pm10": 20.89, + "nh3": 1.71 + }, + "dt": 1785837600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 248.79, + "no": 0.04, + "no2": 7.13, + "o3": 55.46, + "so2": 0.93, + "pm2_5": 17.78, + "pm10": 20.62, + "nh3": 1.76 + }, + "dt": 1785841200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 250, + "no": 0.01, + "no2": 8.22, + "o3": 52.03, + "so2": 0.98, + "pm2_5": 17.39, + "pm10": 20.21, + "nh3": 1.78 + }, + "dt": 1785844800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 263.43, + "no": 0, + "no2": 10.06, + "o3": 46.41, + "so2": 1.12, + "pm2_5": 17.47, + "pm10": 20.56, + "nh3": 1.93 + }, + "dt": 1785848400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 286.48, + "no": 0, + "no2": 12, + "o3": 40, + "so2": 1.28, + "pm2_5": 18.12, + "pm10": 21.89, + "nh3": 2.1 + }, + "dt": 1785852000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 304.11, + "no": 0, + "no2": 12.88, + "o3": 35.54, + "so2": 1.32, + "pm2_5": 18.63, + "pm10": 22.96, + "nh3": 2.12 + }, + "dt": 1785855600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 314.58, + "no": 0, + "no2": 13.09, + "o3": 31.81, + "so2": 1.29, + "pm2_5": 18.76, + "pm10": 23.42, + "nh3": 2.2 + }, + "dt": 1785859200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 328.77, + "no": 0.01, + "no2": 13.48, + "o3": 28.25, + "so2": 1.29, + "pm2_5": 19.02, + "pm10": 24.01, + "nh3": 2.46 + }, + "dt": 1785862800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 339.11, + "no": 0.01, + "no2": 13.88, + "o3": 25.09, + "so2": 1.23, + "pm2_5": 19.11, + "pm10": 23.83, + "nh3": 2.8 + }, + "dt": 1785866400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 334.15, + "no": 0.01, + "no2": 13.43, + "o3": 23.18, + "so2": 1.07, + "pm2_5": 18.73, + "pm10": 22.96, + "nh3": 2.87 + }, + "dt": 1785870000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 321.33, + "no": 0.01, + "no2": 12.15, + "o3": 22.78, + "so2": 0.93, + "pm2_5": 18.05, + "pm10": 21.81, + "nh3": 2.45 + }, + "dt": 1785873600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 305.47, + "no": 0, + "no2": 10.83, + "o3": 23.29, + "so2": 0.83, + "pm2_5": 17.02, + "pm10": 20.31, + "nh3": 2.04 + }, + "dt": 1785877200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 284.79, + "no": 0, + "no2": 9.38, + "o3": 25.61, + "so2": 0.76, + "pm2_5": 15.39, + "pm10": 18.25, + "nh3": 1.7 + }, + "dt": 1785880800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 273.46, + "no": 0, + "no2": 8.59, + "o3": 26.68, + "so2": 0.68, + "pm2_5": 14.18, + "pm10": 16.87, + "nh3": 1.6 + }, + "dt": 1785884400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 266.85, + "no": 0, + "no2": 8.22, + "o3": 27.23, + "so2": 0.65, + "pm2_5": 13.3, + "pm10": 16, + "nh3": 1.58 + }, + "dt": 1785888000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 267.75, + "no": 0.01, + "no2": 8.29, + "o3": 27.13, + "so2": 0.68, + "pm2_5": 12.62, + "pm10": 15.32, + "nh3": 1.55 + }, + "dt": 1785891600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 274.14, + "no": 0.09, + "no2": 8.84, + "o3": 26.69, + "so2": 0.74, + "pm2_5": 12.01, + "pm10": 14.87, + "nh3": 1.51 + }, + "dt": 1785895200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 274.1, + "no": 0.36, + "no2": 9.2, + "o3": 27.16, + "so2": 0.74, + "pm2_5": 11.25, + "pm10": 14.19, + "nh3": 1.45 + }, + "dt": 1785898800 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 252.99, + "no": 0.51, + "no2": 7.7, + "o3": 33.38, + "so2": 0.65, + "pm2_5": 9.89, + "pm10": 12.59, + "nh3": 1.22 + }, + "dt": 1785902400 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 237.22, + "no": 0.54, + "no2": 6.48, + "o3": 39.51, + "so2": 0.6, + "pm2_5": 9.06, + "pm10": 11.65, + "nh3": 1.12 + }, + "dt": 1785906000 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 234.28, + "no": 0.51, + "no2": 5.68, + "o3": 44.66, + "so2": 0.55, + "pm2_5": 9.26, + "pm10": 11.8, + "nh3": 1.08 + }, + "dt": 1785909600 + }, + { + "main": { + "aqi": 1 + }, + "components": { + "co": 237.43, + "no": 0.38, + "no2": 4.6, + "o3": 53.32, + "so2": 0.48, + "pm2_5": 9.97, + "pm10": 12.48, + "nh3": 1.02 + }, + "dt": 1785913200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 242.75, + "no": 0.25, + "no2": 4.32, + "o3": 58.49, + "so2": 0.47, + "pm2_5": 10.64, + "pm10": 13.14, + "nh3": 1.07 + }, + "dt": 1785916800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 251.7, + "no": 0.2, + "no2": 4.55, + "o3": 60.56, + "so2": 0.5, + "pm2_5": 11.3, + "pm10": 13.84, + "nh3": 1.19 + }, + "dt": 1785920400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 264.74, + "no": 0.18, + "no2": 5.23, + "o3": 61.29, + "so2": 0.58, + "pm2_5": 12.22, + "pm10": 14.92, + "nh3": 1.37 + }, + "dt": 1785924000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 272.16, + "no": 0.14, + "no2": 6.42, + "o3": 58.75, + "so2": 0.67, + "pm2_5": 13.07, + "pm10": 15.98, + "nh3": 1.59 + }, + "dt": 1785927600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 275.11, + "no": 0.1, + "no2": 7.77, + "o3": 55.35, + "so2": 0.74, + "pm2_5": 13.39, + "pm10": 16.47, + "nh3": 1.73 + }, + "dt": 1785931200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 294.23, + "no": 0.05, + "no2": 10.6, + "o3": 47.41, + "so2": 0.93, + "pm2_5": 13.88, + "pm10": 17.6, + "nh3": 2.15 + }, + "dt": 1785934800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 325.96, + "no": 0, + "no2": 13.4, + "o3": 38.5, + "so2": 1.21, + "pm2_5": 14.88, + "pm10": 19.85, + "nh3": 2.55 + }, + "dt": 1785938400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 347.45, + "no": 0.01, + "no2": 14.64, + "o3": 33.06, + "so2": 1.39, + "pm2_5": 15.82, + "pm10": 21.92, + "nh3": 2.7 + }, + "dt": 1785942000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 360.46, + "no": 0.01, + "no2": 15.1, + "o3": 28.64, + "so2": 1.47, + "pm2_5": 16.49, + "pm10": 22.88, + "nh3": 2.75 + }, + "dt": 1785945600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 369.78, + "no": 0.02, + "no2": 15.35, + "o3": 25.02, + "so2": 1.46, + "pm2_5": 17.04, + "pm10": 23.41, + "nh3": 2.67 + }, + "dt": 1785949200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 366.32, + "no": 0.02, + "no2": 15.12, + "o3": 22.69, + "so2": 1.28, + "pm2_5": 17.07, + "pm10": 22.9, + "nh3": 2.43 + }, + "dt": 1785952800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 346.04, + "no": 0.01, + "no2": 14, + "o3": 22.35, + "so2": 1, + "pm2_5": 16.44, + "pm10": 21.34, + "nh3": 2.08 + }, + "dt": 1785956400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json b/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json new file mode 100644 index 0000000..1e9b9d5 --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/good-to-moderate.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Air pollution forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:55:10Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "query": { + "lat": 28.6139, + "lon": 77.209 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json new file mode 100644 index 0000000..bec2f2b --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.json @@ -0,0 +1,1544 @@ +{ + "coord": { + "lon": 116.4074, + "lat": 39.9042 + }, + "list": [ + { + "main": { + "aqi": 4 + }, + "components": { + "co": 302.89, + "no": 0, + "no2": 7.98, + "o3": 33.59, + "so2": 7.19, + "pm2_5": 54.55, + "pm10": 59.46, + "nh3": 5.55 + }, + "dt": 1785614400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.74, + "no": 0, + "no2": 8.33, + "o3": 28.05, + "so2": 6.92, + "pm2_5": 52.28, + "pm10": 57.21, + "nh3": 5.63 + }, + "dt": 1785618000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 284.17, + "no": 0.01, + "no2": 8.82, + "o3": 23.17, + "so2": 7.08, + "pm2_5": 49.53, + "pm10": 54.55, + "nh3": 5.8 + }, + "dt": 1785621600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 281.33, + "no": 0.73, + "no2": 8.9, + "o3": 19.8, + "so2": 7.66, + "pm2_5": 47.72, + "pm10": 53.19, + "nh3": 6.22 + }, + "dt": 1785625200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 284.56, + "no": 2.58, + "no2": 8.19, + "o3": 22.17, + "so2": 9.12, + "pm2_5": 47.42, + "pm10": 53.43, + "nh3": 6.74 + }, + "dt": 1785628800 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 285.33, + "no": 2.83, + "no2": 8.98, + "o3": 33.39, + "so2": 12.59, + "pm2_5": 49.88, + "pm10": 55.79, + "nh3": 6.35 + }, + "dt": 1785632400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 283.81, + "no": 2.07, + "no2": 9.42, + "o3": 45, + "so2": 13.17, + "pm2_5": 54.05, + "pm10": 59.76, + "nh3": 5.94 + }, + "dt": 1785636000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 280.23, + "no": 1.15, + "no2": 9.5, + "o3": 56.29, + "so2": 13.15, + "pm2_5": 59.12, + "pm10": 64.55, + "nh3": 5.83 + }, + "dt": 1785639600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 272.6, + "no": 0.61, + "no2": 7.66, + "o3": 77.2, + "so2": 11.35, + "pm2_5": 62.55, + "pm10": 66.76, + "nh3": 4.54 + }, + "dt": 1785643200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 262.99, + "no": 0.44, + "no2": 5.24, + "o3": 98.39, + "so2": 9.15, + "pm2_5": 61.95, + "pm10": 65.11, + "nh3": 3.67 + }, + "dt": 1785646800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 253.23, + "no": 0.33, + "no2": 3.84, + "o3": 114.84, + "so2": 7.9, + "pm2_5": 61.26, + "pm10": 63.91, + "nh3": 3.55 + }, + "dt": 1785650400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 247.13, + "no": 0.25, + "no2": 3.41, + "o3": 125.86, + "so2": 7.3, + "pm2_5": 59.97, + "pm10": 62.33, + "nh3": 3.7 + }, + "dt": 1785654000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 241.92, + "no": 0.17, + "no2": 3.82, + "o3": 127.12, + "so2": 7.05, + "pm2_5": 57.59, + "pm10": 59.75, + "nh3": 4.03 + }, + "dt": 1785657600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 237.55, + "no": 0.08, + "no2": 4.65, + "o3": 125.14, + "so2": 6.83, + "pm2_5": 54.38, + "pm10": 56.3, + "nh3": 4.27 + }, + "dt": 1785661200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 237.2, + "no": 0.04, + "no2": 6.35, + "o3": 114.12, + "so2": 6.86, + "pm2_5": 52.33, + "pm10": 54.33, + "nh3": 5.2 + }, + "dt": 1785664800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 248.69, + "no": 0.01, + "no2": 8.44, + "o3": 97.85, + "so2": 7.47, + "pm2_5": 53.6, + "pm10": 56.26, + "nh3": 6.54 + }, + "dt": 1785668400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 266.28, + "no": 0, + "no2": 9.67, + "o3": 84.9, + "so2": 8.26, + "pm2_5": 56.99, + "pm10": 60.48, + "nh3": 7.29 + }, + "dt": 1785672000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 284.48, + "no": 0, + "no2": 10.7, + "o3": 70.32, + "so2": 8.34, + "pm2_5": 60.47, + "pm10": 65.04, + "nh3": 8.4 + }, + "dt": 1785675600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 301.38, + "no": 0, + "no2": 11.6, + "o3": 57.09, + "so2": 7.91, + "pm2_5": 63.2, + "pm10": 68.99, + "nh3": 10.01 + }, + "dt": 1785679200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 307.9, + "no": 0, + "no2": 12.11, + "o3": 50.81, + "so2": 7.67, + "pm2_5": 65.34, + "pm10": 71.78, + "nh3": 11.68 + }, + "dt": 1785682800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 300.83, + "no": 0, + "no2": 11.86, + "o3": 44.55, + "so2": 7.44, + "pm2_5": 64.97, + "pm10": 71.08, + "nh3": 12.36 + }, + "dt": 1785686400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 297.95, + "no": 0, + "no2": 11.3, + "o3": 35.35, + "so2": 7.04, + "pm2_5": 64.2, + "pm10": 69.75, + "nh3": 11.17 + }, + "dt": 1785690000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 298.39, + "no": 0, + "no2": 10.82, + "o3": 28.76, + "so2": 6.87, + "pm2_5": 63.84, + "pm10": 68.81, + "nh3": 10.09 + }, + "dt": 1785693600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 299.87, + "no": 0.01, + "no2": 10.56, + "o3": 24.09, + "so2": 6.45, + "pm2_5": 64.5, + "pm10": 69.13, + "nh3": 9.53 + }, + "dt": 1785697200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 301.34, + "no": 0.01, + "no2": 10.33, + "o3": 20.77, + "so2": 6.03, + "pm2_5": 65.72, + "pm10": 70.27, + "nh3": 8.74 + }, + "dt": 1785700800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 300.89, + "no": 0.02, + "no2": 10.26, + "o3": 18.36, + "so2": 5.93, + "pm2_5": 66.14, + "pm10": 70.65, + "nh3": 8.11 + }, + "dt": 1785704400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 299.25, + "no": 0.06, + "no2": 10.39, + "o3": 15.91, + "so2": 6.08, + "pm2_5": 65.29, + "pm10": 70, + "nh3": 7.64 + }, + "dt": 1785708000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 308.51, + "no": 0.83, + "no2": 10.62, + "o3": 13.54, + "so2": 6.85, + "pm2_5": 64.19, + "pm10": 70.01, + "nh3": 7.74 + }, + "dt": 1785711600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 325.42, + "no": 3.01, + "no2": 10.78, + "o3": 15.25, + "so2": 8.72, + "pm2_5": 64.6, + "pm10": 71.71, + "nh3": 8.04 + }, + "dt": 1785715200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 305.88, + "no": 2.54, + "no2": 11.94, + "o3": 35.27, + "so2": 15.29, + "pm2_5": 65.3, + "pm10": 71.88, + "nh3": 6.57 + }, + "dt": 1785718800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.63, + "no": 2.42, + "no2": 10.5, + "o3": 51.76, + "so2": 16.72, + "pm2_5": 67.97, + "pm10": 74.41, + "nh3": 6.07 + }, + "dt": 1785722400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 283.54, + "no": 1.58, + "no2": 7.98, + "o3": 78.24, + "so2": 16.4, + "pm2_5": 77.33, + "pm10": 83.54, + "nh3": 5.9 + }, + "dt": 1785726000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 234.36, + "no": 0.44, + "no2": 3.35, + "o3": 115.06, + "so2": 10.67, + "pm2_5": 70.06, + "pm10": 74.05, + "nh3": 3.51 + }, + "dt": 1785729600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 198.65, + "no": 0.25, + "no2": 2, + "o3": 120.19, + "so2": 7.07, + "pm2_5": 51.6, + "pm10": 54.15, + "nh3": 2.44 + }, + "dt": 1785733200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 181.13, + "no": 0.23, + "no2": 1.81, + "o3": 118.24, + "so2": 5.85, + "pm2_5": 41.78, + "pm10": 43.8, + "nh3": 2.27 + }, + "dt": 1785736800 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 176.49, + "no": 0.28, + "no2": 2.35, + "o3": 113.79, + "so2": 6.11, + "pm2_5": 37.74, + "pm10": 39.7, + "nh3": 2.85 + }, + "dt": 1785740400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 183.3, + "no": 0.37, + "no2": 3.72, + "o3": 106.53, + "so2": 7.48, + "pm2_5": 39.28, + "pm10": 41.59, + "nh3": 4.07 + }, + "dt": 1785744000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 194.93, + "no": 0.38, + "no2": 5.84, + "o3": 97.5, + "so2": 8.93, + "pm2_5": 42.16, + "pm10": 44.81, + "nh3": 5.02 + }, + "dt": 1785747600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 216.01, + "no": 0.28, + "no2": 9.26, + "o3": 77.45, + "so2": 10.7, + "pm2_5": 45.31, + "pm10": 48.53, + "nh3": 6.51 + }, + "dt": 1785751200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 245.98, + "no": 0.07, + "no2": 12.41, + "o3": 58.99, + "so2": 12.97, + "pm2_5": 49.19, + "pm10": 53.47, + "nh3": 7.46 + }, + "dt": 1785754800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 276.18, + "no": 0, + "no2": 14.2, + "o3": 49.45, + "so2": 15.51, + "pm2_5": 53.5, + "pm10": 58.93, + "nh3": 7.6 + }, + "dt": 1785758400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 293.46, + "no": 0.01, + "no2": 15.05, + "o3": 42.49, + "so2": 14.95, + "pm2_5": 56.18, + "pm10": 62.31, + "nh3": 8.01 + }, + "dt": 1785762000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.36, + "no": 0.01, + "no2": 15.47, + "o3": 35.57, + "so2": 13.42, + "pm2_5": 58.5, + "pm10": 65.33, + "nh3": 9.11 + }, + "dt": 1785765600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 319.91, + "no": 0.02, + "no2": 15.57, + "o3": 29.66, + "so2": 12.19, + "pm2_5": 60.47, + "pm10": 67.5, + "nh3": 10.33 + }, + "dt": 1785769200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 316.39, + "no": 0.02, + "no2": 15.05, + "o3": 24.13, + "so2": 11.25, + "pm2_5": 60.91, + "pm10": 67.7, + "nh3": 11.94 + }, + "dt": 1785772800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 312.43, + "no": 0.02, + "no2": 14.02, + "o3": 18.6, + "so2": 10.43, + "pm2_5": 60.92, + "pm10": 67.51, + "nh3": 13.5 + }, + "dt": 1785776400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.23, + "no": 0.04, + "no2": 12.91, + "o3": 14.48, + "so2": 9.81, + "pm2_5": 61.38, + "pm10": 67.44, + "nh3": 14.74 + }, + "dt": 1785780000 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 310.41, + "no": 0.06, + "no2": 12.26, + "o3": 11.9, + "so2": 9.11, + "pm2_5": 63.17, + "pm10": 68.58, + "nh3": 16.29 + }, + "dt": 1785783600 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 312.26, + "no": 0.08, + "no2": 12.19, + "o3": 10.65, + "so2": 9.07, + "pm2_5": 66.09, + "pm10": 71.38, + "nh3": 18.93 + }, + "dt": 1785787200 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 318, + "no": 0.12, + "no2": 12.67, + "o3": 9.66, + "so2": 9.44, + "pm2_5": 69.01, + "pm10": 74.33, + "nh3": 22.26 + }, + "dt": 1785790800 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 329.06, + "no": 0.25, + "no2": 13.34, + "o3": 8.04, + "so2": 9.52, + "pm2_5": 71.4, + "pm10": 76.98, + "nh3": 22.94 + }, + "dt": 1785794400 + }, + { + "main": { + "aqi": 4 + }, + "components": { + "co": 354.89, + "no": 1.97, + "no2": 12.62, + "o3": 7.29, + "so2": 9.26, + "pm2_5": 73.49, + "pm10": 80.47, + "nh3": 16.69 + }, + "dt": 1785798000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 377.82, + "no": 5.13, + "no2": 11.6, + "o3": 11.56, + "so2": 10.68, + "pm2_5": 76.03, + "pm10": 84.07, + "nh3": 11.88 + }, + "dt": 1785801600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 356.73, + "no": 3.59, + "no2": 13.64, + "o3": 34.96, + "so2": 19.46, + "pm2_5": 84.24, + "pm10": 91.23, + "nh3": 6.44 + }, + "dt": 1785805200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 346.86, + "no": 2.9, + "no2": 12.32, + "o3": 54.59, + "so2": 21.5, + "pm2_5": 86.71, + "pm10": 93.98, + "nh3": 6.04 + }, + "dt": 1785808800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 341.58, + "no": 1.63, + "no2": 9.71, + "o3": 87.34, + "so2": 22.24, + "pm2_5": 100.49, + "pm10": 108.09, + "nh3": 5.37 + }, + "dt": 1785812400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 308.82, + "no": 0.58, + "no2": 5.28, + "o3": 132.35, + "so2": 17.71, + "pm2_5": 105.91, + "pm10": 112.13, + "nh3": 1.81 + }, + "dt": 1785816000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 282.82, + "no": 0.34, + "no2": 3.52, + "o3": 154.41, + "so2": 13.95, + "pm2_5": 98.47, + "pm10": 103.56, + "nh3": 0.59 + }, + "dt": 1785819600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 269.37, + "no": 0.28, + "no2": 3.04, + "o3": 168.97, + "so2": 12.46, + "pm2_5": 94.36, + "pm10": 99.05, + "nh3": 0.63 + }, + "dt": 1785823200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 264.55, + "no": 0.27, + "no2": 3.12, + "o3": 180.55, + "so2": 11.83, + "pm2_5": 92.63, + "pm10": 97.24, + "nh3": 1.16 + }, + "dt": 1785826800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 264, + "no": 0.26, + "no2": 3.69, + "o3": 183.79, + "so2": 11.96, + "pm2_5": 93.76, + "pm10": 98.61, + "nh3": 2.16 + }, + "dt": 1785830400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 266.54, + "no": 0.25, + "no2": 4.76, + "o3": 180.71, + "so2": 12.37, + "pm2_5": 94.38, + "pm10": 99.53, + "nh3": 3.17 + }, + "dt": 1785834000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 288.66, + "no": 0.3, + "no2": 9.39, + "o3": 135.4, + "so2": 11.08, + "pm2_5": 95.57, + "pm10": 102.06, + "nh3": 7.54 + }, + "dt": 1785837600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 328.96, + "no": 0.12, + "no2": 14.67, + "o3": 86.48, + "so2": 10.01, + "pm2_5": 97.71, + "pm10": 106.81, + "nh3": 13.2 + }, + "dt": 1785841200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 367.54, + "no": 0, + "no2": 17.26, + "o3": 62.63, + "so2": 10.43, + "pm2_5": 99.66, + "pm10": 111.59, + "nh3": 17.67 + }, + "dt": 1785844800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 396.31, + "no": 0, + "no2": 19.18, + "o3": 50.3, + "so2": 11.39, + "pm2_5": 101.22, + "pm10": 115.54, + "nh3": 22.72 + }, + "dt": 1785848400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 421.8, + "no": 0, + "no2": 20.31, + "o3": 39.56, + "so2": 11.93, + "pm2_5": 103.51, + "pm10": 119.79, + "nh3": 26.68 + }, + "dt": 1785852000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 439.43, + "no": 0, + "no2": 21.42, + "o3": 29.58, + "so2": 11.88, + "pm2_5": 106.38, + "pm10": 124, + "nh3": 30.06 + }, + "dt": 1785855600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 435.81, + "no": 0.02, + "no2": 21.18, + "o3": 20.7, + "so2": 10.45, + "pm2_5": 107.03, + "pm10": 124.72, + "nh3": 30.38 + }, + "dt": 1785859200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 427.53, + "no": 0.06, + "no2": 19.02, + "o3": 13.39, + "so2": 7.8, + "pm2_5": 106.09, + "pm10": 123.65, + "nh3": 24.07 + }, + "dt": 1785862800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 416.43, + "no": 0.13, + "no2": 16.32, + "o3": 8.45, + "so2": 5.46, + "pm2_5": 104.57, + "pm10": 121.92, + "nh3": 17.1 + }, + "dt": 1785866400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 403.82, + "no": 0.27, + "no2": 14.13, + "o3": 5.13, + "so2": 4.27, + "pm2_5": 102.76, + "pm10": 119.12, + "nh3": 14.79 + }, + "dt": 1785870000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 395.65, + "no": 0.49, + "no2": 13.42, + "o3": 3.27, + "so2": 4.63, + "pm2_5": 101.69, + "pm10": 117.43, + "nh3": 18.73 + }, + "dt": 1785873600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 391.66, + "no": 0.88, + "no2": 13.88, + "o3": 2.31, + "so2": 5.76, + "pm2_5": 100.66, + "pm10": 114.94, + "nh3": 24.62 + }, + "dt": 1785877200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 390.88, + "no": 1.7, + "no2": 14.62, + "o3": 2.24, + "so2": 7.31, + "pm2_5": 98.88, + "pm10": 112.39, + "nh3": 30.24 + }, + "dt": 1785880800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 407.64, + "no": 5.21, + "no2": 12.31, + "o3": 3.47, + "so2": 8.37, + "pm2_5": 98.44, + "pm10": 113.19, + "nh3": 29.44 + }, + "dt": 1785884400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 436.21, + "no": 10.71, + "no2": 8.97, + "o3": 6.08, + "so2": 8.39, + "pm2_5": 98.47, + "pm10": 114.79, + "nh3": 19.72 + }, + "dt": 1785888000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 412.25, + "no": 4.23, + "no2": 13.45, + "o3": 42.44, + "so2": 20.39, + "pm2_5": 112.02, + "pm10": 125.26, + "nh3": 8.28 + }, + "dt": 1785891600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 404.56, + "no": 2.36, + "no2": 11.31, + "o3": 78.75, + "so2": 23.06, + "pm2_5": 120.26, + "pm10": 132.85, + "nh3": 7.2 + }, + "dt": 1785895200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 399.34, + "no": 1.05, + "no2": 7.55, + "o3": 122.82, + "so2": 22.77, + "pm2_5": 138.24, + "pm10": 150.56, + "nh3": 6.62 + }, + "dt": 1785898800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 294.85, + "no": 0.29, + "no2": 3.6, + "o3": 148.75, + "so2": 17.1, + "pm2_5": 104.48, + "pm10": 111.39, + "nh3": 0.64 + }, + "dt": 1785902400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 250.21, + "no": 0.25, + "no2": 2.77, + "o3": 149.1, + "so2": 12.79, + "pm2_5": 84.71, + "pm10": 89.76, + "nh3": 0.01 + }, + "dt": 1785906000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 238.28, + "no": 0.26, + "no2": 2.65, + "o3": 153.69, + "so2": 11.77, + "pm2_5": 82.2, + "pm10": 86.96, + "nh3": 0.06 + }, + "dt": 1785909600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 248.15, + "no": 0.5, + "no2": 4.57, + "o3": 134.15, + "so2": 10.71, + "pm2_5": 84.68, + "pm10": 90.3, + "nh3": 3.87 + }, + "dt": 1785913200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 271.92, + "no": 0.9, + "no2": 8.22, + "o3": 105.94, + "so2": 10.05, + "pm2_5": 88.07, + "pm10": 95.32, + "nh3": 9.37 + }, + "dt": 1785916800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 297.89, + "no": 1.13, + "no2": 13.83, + "o3": 87.04, + "so2": 10.62, + "pm2_5": 89.17, + "pm10": 97.87, + "nh3": 14.29 + }, + "dt": 1785920400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 328.46, + "no": 0.94, + "no2": 21.09, + "o3": 72, + "so2": 11.95, + "pm2_5": 89.85, + "pm10": 100.17, + "nh3": 20.21 + }, + "dt": 1785924000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 360.21, + "no": 0.26, + "no2": 25.75, + "o3": 54.59, + "so2": 12.73, + "pm2_5": 90.77, + "pm10": 103.16, + "nh3": 25.1 + }, + "dt": 1785927600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 387.66, + "no": 0, + "no2": 26.94, + "o3": 41.65, + "so2": 13.29, + "pm2_5": 91.14, + "pm10": 105.68, + "nh3": 28.61 + }, + "dt": 1785931200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 404.88, + "no": 0.01, + "no2": 26.53, + "o3": 32.5, + "so2": 13.14, + "pm2_5": 91.55, + "pm10": 107.67, + "nh3": 29.42 + }, + "dt": 1785934800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 420.06, + "no": 0.03, + "no2": 24.95, + "o3": 24.41, + "so2": 11.96, + "pm2_5": 93.24, + "pm10": 110.57, + "nh3": 26.59 + }, + "dt": 1785938400 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 426.65, + "no": 0.12, + "no2": 23.11, + "o3": 18.02, + "so2": 10.01, + "pm2_5": 94.22, + "pm10": 111.85, + "nh3": 21.38 + }, + "dt": 1785942000 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 411.58, + "no": 0.17, + "no2": 20.63, + "o3": 14.97, + "so2": 8.03, + "pm2_5": 93.64, + "pm10": 110.17, + "nh3": 17.66 + }, + "dt": 1785945600 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 398.35, + "no": 0.16, + "no2": 18.38, + "o3": 14.86, + "so2": 7.44, + "pm2_5": 94.94, + "pm10": 110.43, + "nh3": 18.13 + }, + "dt": 1785949200 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 393.08, + "no": 0.17, + "no2": 16.81, + "o3": 15.1, + "so2": 7.48, + "pm2_5": 96.36, + "pm10": 111.25, + "nh3": 19.75 + }, + "dt": 1785952800 + }, + { + "main": { + "aqi": 5 + }, + "components": { + "co": 391.16, + "no": 0.2, + "no2": 15.99, + "o3": 14.77, + "so2": 7.75, + "pm2_5": 96.46, + "pm10": 110.87, + "nh3": 22.18 + }, + "dt": 1785956400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json new file mode 100644 index 0000000..0045547 --- /dev/null +++ b/tests/Fixtures/air-pollution/forecast/moderate-to-very-poor.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Air pollution forecast by coordinates", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:55:10Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/forecast", + "query": { + "lat": 39.9042, + "lon": 116.4074 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/empty.json b/tests/Fixtures/air-pollution/history/empty.json new file mode 100644 index 0000000..b06c61e --- /dev/null +++ b/tests/Fixtures/air-pollution/history/empty.json @@ -0,0 +1,7 @@ +{ + "coord": { + "lon": -9.1393, + "lat": 38.7223 + }, + "list": [] +} diff --git a/tests/Fixtures/air-pollution/history/empty.meta.json b/tests/Fixtures/air-pollution/history/empty.meta.json new file mode 100644 index 0000000..3e9228b --- /dev/null +++ b/tests/Fixtures/air-pollution/history/empty.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1604188800, + "end": 1604192400 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/invalid-range.json b/tests/Fixtures/air-pollution/history/invalid-range.json new file mode 100644 index 0000000..26b66d4 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/invalid-range.json @@ -0,0 +1,4 @@ +{ + "cod": "400", + "message": "end must be after start" +} diff --git a/tests/Fixtures/air-pollution/history/invalid-range.meta.json b/tests/Fixtures/air-pollution/history/invalid-range.meta.json new file mode 100644 index 0000000..b63618e --- /dev/null +++ b/tests/Fixtures/air-pollution/history/invalid-range.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 400, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1782950400, + "end": 1782864000 + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/air-pollution/history/success.json b/tests/Fixtures/air-pollution/history/success.json new file mode 100644 index 0000000..c859165 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/success.json @@ -0,0 +1,408 @@ +{ + "coord": { + "lon": -9.1393, + "lat": 38.7223 + }, + "list": [ + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.71, + "no": 0, + "no2": 2.91, + "o3": 86.35, + "so2": 2.17, + "pm2_5": 6.86, + "pm10": 26.12, + "nh3": 0 + }, + "dt": 1782864000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.52, + "no": 0, + "no2": 3.03, + "o3": 82.88, + "so2": 2.25, + "pm2_5": 6.78, + "pm10": 26.39, + "nh3": 0 + }, + "dt": 1782867600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.34, + "no": 0, + "no2": 3.25, + "o3": 79.36, + "so2": 2.37, + "pm2_5": 6.94, + "pm10": 27.23, + "nh3": 0 + }, + "dt": 1782871200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.1, + "no": 0, + "no2": 3.51, + "o3": 75.64, + "so2": 2.48, + "pm2_5": 7.18, + "pm10": 28.01, + "nh3": 0 + }, + "dt": 1782874800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.09, + "no": 0, + "no2": 3.71, + "o3": 72.78, + "so2": 2.55, + "pm2_5": 7.33, + "pm10": 28.29, + "nh3": 0 + }, + "dt": 1782878400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.21, + "no": 0, + "no2": 3.8, + "o3": 70.88, + "so2": 2.58, + "pm2_5": 7.31, + "pm10": 27.88, + "nh3": 0.02 + }, + "dt": 1782882000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.51, + "no": 0, + "no2": 3.88, + "o3": 69.79, + "so2": 2.59, + "pm2_5": 7.2, + "pm10": 27.15, + "nh3": 0.03 + }, + "dt": 1782885600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.02, + "no": 0.19, + "no2": 3.99, + "o3": 69.57, + "so2": 2.61, + "pm2_5": 7.1, + "pm10": 26.32, + "nh3": 0.05 + }, + "dt": 1782889200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.33, + "no": 0.57, + "no2": 3.64, + "o3": 70.29, + "so2": 2.64, + "pm2_5": 7.03, + "pm10": 25.61, + "nh3": 0.05 + }, + "dt": 1782892800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.46, + "no": 0.77, + "no2": 3.15, + "o3": 72.33, + "so2": 2.62, + "pm2_5": 7.02, + "pm10": 25.35, + "nh3": 0.05 + }, + "dt": 1782896400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.2, + "no": 0.74, + "no2": 2.6, + "o3": 76.11, + "so2": 2.59, + "pm2_5": 7.17, + "pm10": 25.56, + "nh3": 0.03 + }, + "dt": 1782900000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.11, + "no": 0.57, + "no2": 2.02, + "o3": 82.11, + "so2": 2.55, + "pm2_5": 7.62, + "pm10": 26.57, + "nh3": 0.01 + }, + "dt": 1782903600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 81.05, + "no": 0.36, + "no2": 1.44, + "o3": 89.19, + "so2": 2.42, + "pm2_5": 8.15, + "pm10": 27.71, + "nh3": 0 + }, + "dt": 1782907200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.88, + "no": 0.24, + "no2": 1.05, + "o3": 94.79, + "so2": 2.25, + "pm2_5": 8.43, + "pm10": 28.4, + "nh3": 0 + }, + "dt": 1782910800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.37, + "no": 0.19, + "no2": 0.91, + "o3": 98.15, + "so2": 2.13, + "pm2_5": 8.6, + "pm10": 28.86, + "nh3": 0 + }, + "dt": 1782914400 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.91, + "no": 0.18, + "no2": 0.9, + "o3": 100.05, + "so2": 2.09, + "pm2_5": 8.79, + "pm10": 29.55, + "nh3": 0 + }, + "dt": 1782918000 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.64, + "no": 0.18, + "no2": 0.95, + "o3": 101.09, + "so2": 2.07, + "pm2_5": 8.99, + "pm10": 30.53, + "nh3": 0 + }, + "dt": 1782921600 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.65, + "no": 0.19, + "no2": 1.1, + "o3": 101.16, + "so2": 2.06, + "pm2_5": 9.1, + "pm10": 31.25, + "nh3": 0 + }, + "dt": 1782925200 + }, + { + "main": { + "aqi": 3 + }, + "components": { + "co": 79.59, + "no": 0.19, + "no2": 1.41, + "o3": 100.75, + "so2": 2.11, + "pm2_5": 9.14, + "pm10": 31.42, + "nh3": 0 + }, + "dt": 1782928800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 79.95, + "no": 0.14, + "no2": 1.97, + "o3": 99.3, + "so2": 2.2, + "pm2_5": 9.08, + "pm10": 31.06, + "nh3": 0 + }, + "dt": 1782932400 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.1, + "no": 0.03, + "no2": 2.69, + "o3": 96.52, + "so2": 2.28, + "pm2_5": 8.95, + "pm10": 30.38, + "nh3": 0 + }, + "dt": 1782936000 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.25, + "no": 0, + "no2": 3.09, + "o3": 93.3, + "so2": 2.36, + "pm2_5": 8.85, + "pm10": 29.74, + "nh3": 0.01 + }, + "dt": 1782939600 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.42, + "no": 0, + "no2": 3.53, + "o3": 89.97, + "so2": 2.48, + "pm2_5": 8.81, + "pm10": 29.23, + "nh3": 0.02 + }, + "dt": 1782943200 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.6, + "no": 0, + "no2": 4.02, + "o3": 86.33, + "so2": 2.6, + "pm2_5": 8.77, + "pm10": 28.67, + "nh3": 0.02 + }, + "dt": 1782946800 + }, + { + "main": { + "aqi": 2 + }, + "components": { + "co": 80.92, + "no": 0, + "no2": 4.47, + "o3": 82.81, + "so2": 2.71, + "pm2_5": 8.73, + "pm10": 28.1, + "nh3": 0.02 + }, + "dt": 1782950400 + } + ] +} diff --git a/tests/Fixtures/air-pollution/history/success.meta.json b/tests/Fixtures/air-pollution/history/success.meta.json new file mode 100644 index 0000000..21a3722 --- /dev/null +++ b/tests/Fixtures/air-pollution/history/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "Air Pollution API", + "endpoint": "Historical air pollution by coordinates and time range", + "apiVersion": "2.5", + "capturedAt": "2026-08-01T20:59:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/2.5/air_pollution/history", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1782864000, + "end": 1782950400 + } + }, + "sanitization": [] +} From 2aa867f9eaf59c7e6205305c0862946a9c7b76cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 1 Aug 2026 22:48:53 +0100 Subject: [PATCH 032/113] feat(air-pollution): add current response model --- src/Entity/AirPollution/Components.php | 201 ++++++++++++++++++ src/Entity/AirPollution/Current.php | 73 +++++++ src/Enum/AirQualityIndex.php | 17 ++ src/Exception/HydrationException.php | 2 +- .../Unit/Entity/AirPollution/CurrentTest.php | 167 +++++++++++++++ 5 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/Entity/AirPollution/Components.php create mode 100644 src/Entity/AirPollution/Current.php create mode 100644 src/Enum/AirQualityIndex.php create mode 100644 tests/Unit/Entity/AirPollution/CurrentTest.php diff --git a/src/Entity/AirPollution/Components.php b/src/Entity/AirPollution/Components.php new file mode 100644 index 0000000..ee67a94 --- /dev/null +++ b/src/Entity/AirPollution/Components.php @@ -0,0 +1,201 @@ +nullableFloat('co'), + nitrogenMonoxide: $reader->nullableFloat('no'), + nitrogenDioxide: $reader->nullableFloat('no2'), + ozone: $reader->nullableFloat('o3'), + sulphurDioxide: $reader->nullableFloat('so2'), + fineParticulateMatter: $reader->nullableFloat('pm2_5'), + coarseParticulateMatter: $reader->nullableFloat('pm10'), + ammonia: $reader->nullableFloat('nh3'), + ); + } + + /** + * CO (carbon monoxide) concentration. + */ + public function carbonMonoxide(): ?float + { + return $this->carbonMonoxide; + } + + public function carbonMonoxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function carbonMonoxideWithUnit(): ?string + { + return $this->format($this->carbonMonoxide); + } + + /** + * NO (nitrogen monoxide) concentration. + */ + public function nitrogenMonoxide(): ?float + { + return $this->nitrogenMonoxide; + } + + public function nitrogenMonoxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function nitrogenMonoxideWithUnit(): ?string + { + return $this->format($this->nitrogenMonoxide); + } + + /** + * NO2 (nitrogen dioxide) concentration. + */ + public function nitrogenDioxide(): ?float + { + return $this->nitrogenDioxide; + } + + public function nitrogenDioxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function nitrogenDioxideWithUnit(): ?string + { + return $this->format($this->nitrogenDioxide); + } + + /** + * O3 (ozone) concentration. + */ + public function ozone(): ?float + { + return $this->ozone; + } + + public function ozoneUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function ozoneWithUnit(): ?string + { + return $this->format($this->ozone); + } + + /** + * SO2 (sulphur dioxide) concentration. + */ + public function sulphurDioxide(): ?float + { + return $this->sulphurDioxide; + } + + public function sulphurDioxideUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function sulphurDioxideWithUnit(): ?string + { + return $this->format($this->sulphurDioxide); + } + + /** + * PM2.5 (fine particulate matter) concentration. + */ + public function fineParticulateMatter(): ?float + { + return $this->fineParticulateMatter; + } + + public function fineParticulateMatterUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function fineParticulateMatterWithUnit(): ?string + { + return $this->format($this->fineParticulateMatter); + } + + /** + * PM10 (coarse particulate matter) concentration. + */ + public function coarseParticulateMatter(): ?float + { + return $this->coarseParticulateMatter; + } + + public function coarseParticulateMatterUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function coarseParticulateMatterWithUnit(): ?string + { + return $this->format($this->coarseParticulateMatter); + } + + /** + * NH3 (ammonia) concentration. + */ + public function ammonia(): ?float + { + return $this->ammonia; + } + + public function ammoniaUnit(): Unit + { + return $this->concentrationUnit(); + } + + public function ammoniaWithUnit(): ?string + { + return $this->format($this->ammonia); + } + + private function concentrationUnit(): Unit + { + return Unit::MICROGRAMS_PER_CUBIC_METER; + } + + private function format(?float $concentration): ?string + { + return MeasurementFormatter::format( + $concentration, + $this->concentrationUnit(), + ); + } +} diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php new file mode 100644 index 0000000..e260ab5 --- /dev/null +++ b/src/Entity/AirPollution/Current.php @@ -0,0 +1,73 @@ +nullableInt('list.0.main.aqi'); + + if ($airQualityIndex !== null) { + $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) + ?? throw HydrationException::invalidValue( + self::class, + 'list.0.main.aqi', + 'an integer from 1 through 5', + $airQualityIndex, + ); + } + + $components = $reader->nullableArray('list.0.components'); + + return new self( + latitude: $reader->nullableFloat('coord.lat'), + longitude: $reader->nullableFloat('coord.lon'), + observedAt: $reader->nullableTimestamp('list.0.dt'), + airQualityIndex: $airQualityIndex, + components: $components === null + ? null + : Components::fromArray($components, $context), + ); + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } + + public function observedAt(): ?\DateTimeImmutable + { + return $this->observedAt; + } + + public function airQualityIndex(): ?AirQualityIndex + { + return $this->airQualityIndex; + } + + public function components(): ?Components + { + return $this->components; + } +} diff --git a/src/Enum/AirQualityIndex.php b/src/Enum/AirQualityIndex.php new file mode 100644 index 0000000..ddcbd13 --- /dev/null +++ b/src/Enum/AirQualityIndex.php @@ -0,0 +1,17 @@ +latitude()); + self::assertSame(151.2073, $current->longitude()); + self::assertSame(1785616883, $current->observedAt()?->getTimestamp()); + self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); + + $components = $current->components(); + + self::assertSame(96.56, $components?->carbonMonoxide()); + self::assertSame('96.56 µg/m³', $components?->carbonMonoxideWithUnit()); + self::assertSame(0.01, $components?->nitrogenMonoxide()); + self::assertSame('0.01 µg/m³', $components?->nitrogenMonoxideWithUnit()); + self::assertSame(7.17, $components?->nitrogenDioxide()); + self::assertSame('7.17 µg/m³', $components?->nitrogenDioxideWithUnit()); + self::assertSame(29.69, $components?->ozone()); + self::assertSame('29.69 µg/m³', $components?->ozoneWithUnit()); + self::assertSame(1.03, $components?->sulphurDioxide()); + self::assertSame('1.03 µg/m³', $components?->sulphurDioxideWithUnit()); + self::assertSame(5.89, $components?->fineParticulateMatter()); + self::assertSame('5.89 µg/m³', $components?->fineParticulateMatterWithUnit()); + self::assertSame(7.62, $components?->coarseParticulateMatter()); + self::assertSame('7.62 µg/m³', $components?->coarseParticulateMatterWithUnit()); + self::assertSame(0.65, $components?->ammonia()); + self::assertSame('0.65 µg/m³', $components?->ammoniaWithUnit()); + self::assertSame( + Unit::MICROGRAMS_PER_CUBIC_METER, + $components?->fineParticulateMatterUnit(), + ); + } + + #[DataProvider('airQualityIndexes')] + public function testHydratesEveryDocumentedAirQualityIndex( + int $value, + AirQualityIndex $expected, + ): void { + $current = Current::fromArray([ + 'list' => [['main' => ['aqi' => $value]]], + ]); + + self::assertSame($expected, $current->airQualityIndex()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Current::fromArray([]); + + self::assertNull($missing->latitude()); + self::assertNull($missing->longitude()); + self::assertNull($missing->observedAt()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $current = Current::fromArray([ + 'coord' => ['lat' => null, 'unknown' => true], + 'list' => [[ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($current->latitude()); + self::assertNull($current->longitude()); + self::assertNull($current->observedAt()); + self::assertNull($current->airQualityIndex()); + self::assertNull($current->components()?->carbonMonoxide()); + self::assertNull($current->components()?->nitrogenMonoxide()); + self::assertSame( + Unit::MICROGRAMS_PER_CUBIC_METER, + $current->components()?->nitrogenMonoxideUnit(), + ); + + self::assertNull(Current::fromArray(['list' => null])->observedAt()); + self::assertNull(Current::fromArray(['list' => []])->observedAt()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Current::fromArray($data); + } + + public static function airQualityIndexes(): iterable + { + yield 'good' => [1, AirQualityIndex::GOOD]; + yield 'fair' => [2, AirQualityIndex::FAIR]; + yield 'moderate' => [3, AirQualityIndex::MODERATE]; + yield 'poor' => [4, AirQualityIndex::POOR]; + yield 'very poor' => [5, AirQualityIndex::VERY_POOR]; + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '-33.8']], + '"coord.lat" expected int|float, string received.', + ]; + yield 'list' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'list member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'date and time' => [ + ['list' => [['dt' => '1785616883']]], + '"list.0.dt" expected int, string received.', + ]; + yield 'main' => [ + ['list' => [['main' => 'invalid']]], + '"list.0.main" expected array, string received.', + ]; + yield 'AQI type' => [ + ['list' => [['main' => ['aqi' => '1']]]], + '"list.0.main.aqi" expected int, string received.', + ]; + yield 'AQI float' => [ + ['list' => [['main' => ['aqi' => 1.0]]]], + '"list.0.main.aqi" expected int, float received.', + ]; + yield 'unsupported AQI' => [ + ['list' => [['main' => ['aqi' => 6]]]], + '"list.0.main.aqi" expected an integer from 1 through 5, "6" received.', + ]; + yield 'components' => [ + ['list' => [['components' => 'invalid']]], + '"list.0.components" expected array, string received.', + ]; + yield 'component concentration' => [ + ['list' => [['components' => ['co' => '96.56']]]], + '"co" expected int|float, string received.', + ]; + } +} From ff1319967b8cfd182fb0dcbad4294c569c943fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 08:55:33 +0100 Subject: [PATCH 033/113] feat(air-pollution): expose current endpoint --- README.md | 1 + docs/air-pollution.md | 61 +++++++++++++++++++ src/Entity/AirPollution/Coordinates.php | 35 +++++++++++ src/Entity/AirPollution/Current.php | 18 +++--- src/OpenWeatherMap.php | 6 ++ src/Resource/AirPollution.php | 29 +++++++++ .../Unit/Entity/AirPollution/CurrentTest.php | 13 ++-- tests/Unit/Resource/AirPollutionTest.php | 59 ++++++++++++++++++ 8 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 docs/air-pollution.md create mode 100644 src/Entity/AirPollution/Coordinates.php create mode 100644 src/Resource/AirPollution.php create mode 100644 tests/Unit/Resource/AirPollutionTest.php diff --git a/README.md b/README.md index 8217598..6a7c195 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ use yet. ## Documentation +- [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) - [Geocoding](docs/geocoding.md) diff --git a/docs/air-pollution.md b/docs/air-pollution.md new file mode 100644 index 0000000..e035e28 --- /dev/null +++ b/docs/air-pollution.md @@ -0,0 +1,61 @@ +# Air Pollution + +## Current + +The Current Air Pollution API is available on OpenWeather's standard free and +paid subscriptions. See the +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for the upstream endpoint contract. + +Use `current()` with a latitude and longitude. Both coordinates are validated +before the request is sent. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->airPollution()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The returned `Current` entity exposes the single observation directly. Every +response property may be absent or explicitly `null`. + +```php +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); +echo $current->observedAt()?->format(DATE_ATOM); +echo $current->airQualityIndex()?->value; +``` + +The air quality index uses OpenWeather's native scale from 1 (good) through 5 +(very poor). Pollutant concentrations are grouped under `components()` and use +the fixed `µg/m³` unit documented by OpenWeather; weather unit configuration +does not affect them. + +```php +$components = $current->components(); + +echo $components?->carbonMonoxide(); +echo $components?->nitrogenMonoxide(); +echo $components?->nitrogenDioxide(); +echo $components?->ozone(); +echo $components?->sulphurDioxide(); +echo $components?->fineParticulateMatter(); +echo $components?->coarseParticulateMatter(); +echo $components?->ammonia(); +``` + +Raw concentration getters return nullable floats. Companion methods expose the +unit and a locale-independent formatted value: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; + +$components?->fineParticulateMatter(); // 5.89 +$components?->fineParticulateMatterUnit(); // Unit::MICROGRAMS_PER_CUBIC_METER +$components?->fineParticulateMatterWithUnit(); // '5.89 µg/m³' +``` diff --git a/src/Entity/AirPollution/Coordinates.php b/src/Entity/AirPollution/Coordinates.php new file mode 100644 index 0000000..8a1ead9 --- /dev/null +++ b/src/Entity/AirPollution/Coordinates.php @@ -0,0 +1,35 @@ +nullableFloat('lat'), + longitude: $reader->nullableFloat('lon'), + ); + } + + public function latitude(): ?float + { + return $this->latitude; + } + + public function longitude(): ?float + { + return $this->longitude; + } +} diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php index e260ab5..d0b3478 100644 --- a/src/Entity/AirPollution/Current.php +++ b/src/Entity/AirPollution/Current.php @@ -11,8 +11,7 @@ final class Current implements EntityInterface { private function __construct( - private readonly ?float $latitude, - private readonly ?float $longitude, + private readonly ?Coordinates $coordinates, private readonly ?\DateTimeImmutable $observedAt, private readonly ?AirQualityIndex $airQualityIndex, private readonly ?Components $components, @@ -33,11 +32,13 @@ public static function fromArray(array $data, ?Context $context = null): static ); } + $coordinates = $reader->nullableArray('coord'); $components = $reader->nullableArray('list.0.components'); return new self( - latitude: $reader->nullableFloat('coord.lat'), - longitude: $reader->nullableFloat('coord.lon'), + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), observedAt: $reader->nullableTimestamp('list.0.dt'), airQualityIndex: $airQualityIndex, components: $components === null @@ -46,14 +47,9 @@ public static function fromArray(array $data, ?Context $context = null): static ); } - public function latitude(): ?float - { - return $this->latitude; - } - - public function longitude(): ?float + public function coordinates(): ?Coordinates { - return $this->longitude; + return $this->coordinates; } public function observedAt(): ?\DateTimeImmutable diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 5d5abfe..6f31c19 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -12,6 +12,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; +use ProgrammatorDev\OpenWeatherMap\Resource\AirPollution; use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; use ProgrammatorDev\OpenWeatherMap\Resource\Weather; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -49,6 +50,11 @@ public function __construct(string $apiKey, array $options = []) }); } + public function airPollution(): AirPollution + { + return $this->resource(AirPollution::class); + } + public function geocoding(): Geocoding { return $this->resource(Geocoding::class); diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php new file mode 100644 index 0000000..850843d --- /dev/null +++ b/src/Resource/AirPollution.php @@ -0,0 +1,29 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + ]) + ->get('/data/2.5/air_pollution') + ->entity(Current::class); + + return $current; + } +} diff --git a/tests/Unit/Entity/AirPollution/CurrentTest.php b/tests/Unit/Entity/AirPollution/CurrentTest.php index 1981ebd..46001c1 100644 --- a/tests/Unit/Entity/AirPollution/CurrentTest.php +++ b/tests/Unit/Entity/AirPollution/CurrentTest.php @@ -18,8 +18,8 @@ public function testHydratesCapturedCurrentAirPollution(): void Fixture::json('air-pollution/current/good.json'), ); - self::assertSame(-33.8679, $current->latitude()); - self::assertSame(151.2073, $current->longitude()); + self::assertSame(-33.8679, $current->coordinates()?->latitude()); + self::assertSame(151.2073, $current->coordinates()?->longitude()); self::assertSame(1785616883, $current->observedAt()?->getTimestamp()); self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); @@ -64,8 +64,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = Current::fromArray([]); - self::assertNull($missing->latitude()); - self::assertNull($missing->longitude()); + self::assertNull($missing->coordinates()); self::assertNull($missing->observedAt()); self::assertNull($missing->airQualityIndex()); self::assertNull($missing->components()); @@ -84,8 +83,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($current->latitude()); - self::assertNull($current->longitude()); + self::assertNull($current->coordinates()?->latitude()); + self::assertNull($current->coordinates()?->longitude()); self::assertNull($current->observedAt()); self::assertNull($current->airQualityIndex()); self::assertNull($current->components()?->carbonMonoxide()); @@ -125,7 +124,7 @@ public static function invalidFields(): iterable ]; yield 'latitude' => [ ['coord' => ['lat' => '-33.8']], - '"coord.lat" expected int|float, string received.', + '"lat" expected int|float, string received.', ]; yield 'list' => [ ['list' => 'invalid'], diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php new file mode 100644 index 0000000..d98a1db --- /dev/null +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -0,0 +1,59 @@ +respondWithFixture('air-pollution/current/good.json'); + + $current = $this->api->airPollution()->current( + latitude: -33.8688, + longitude: 151.2093, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Current::class, $current); + self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); + self::assertSame(-33.8679, $current->coordinates()?->latitude()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/2.5/air_pollution', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '-33.8688', + 'lon' => '151.2093', + 'appid' => 'api-key', + ], $this->query($request)); + } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->current($latitude, $longitude); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} From 81add165f0667c62ad3d04496946386bf1c7e902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 08:58:58 +0100 Subject: [PATCH 034/113] feat(air-pollution): add forecast period entity --- src/Entity/AirPollution/Forecast/Period.php | 60 ++++++++++++++ .../AirPollution/Forecast/PeriodTest.php | 83 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/Entity/AirPollution/Forecast/Period.php create mode 100644 tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php diff --git a/src/Entity/AirPollution/Forecast/Period.php b/src/Entity/AirPollution/Forecast/Period.php new file mode 100644 index 0000000..7bbd921 --- /dev/null +++ b/src/Entity/AirPollution/Forecast/Period.php @@ -0,0 +1,60 @@ +nullableInt('main.aqi'); + + if ($airQualityIndex !== null) { + $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) + ?? throw HydrationException::invalidValue( + self::class, + 'main.aqi', + 'an integer from 1 through 5', + $airQualityIndex, + ); + } + + $components = $reader->nullableArray('components'); + + return new self( + forecastAt: $reader->nullableTimestamp('dt'), + airQualityIndex: $airQualityIndex, + components: $components === null + ? null + : Components::fromArray($components, $context), + ); + } + + public function forecastAt(): ?\DateTimeImmutable + { + return $this->forecastAt; + } + + public function airQualityIndex(): ?AirQualityIndex + { + return $this->airQualityIndex; + } + + public function components(): ?Components + { + return $this->components; + } +} diff --git a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php new file mode 100644 index 0000000..18bf11e --- /dev/null +++ b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php @@ -0,0 +1,83 @@ +forecastAt()?->getTimestamp()); + self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::MODERATE, $period->airQualityIndex()); + self::assertSame(414.72, $period->components()?->carbonMonoxide()); + self::assertSame(36.74, $period->components()?->fineParticulateMatter()); + self::assertSame('36.74 µg/m³', $period->components()?->fineParticulateMatterWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->forecastAt()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->forecastAt()); + self::assertNull($period->airQualityIndex()); + self::assertNull($period->components()?->carbonMonoxide()); + self::assertNull($period->components()?->nitrogenDioxide()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [ + ['dt' => '1785614400'], + '"dt" expected int, string received.', + ]; + yield 'main' => [ + ['main' => 'invalid'], + '"main" expected array, string received.', + ]; + yield 'AQI float' => [ + ['main' => ['aqi' => 1.0]], + '"main.aqi" expected int, float received.', + ]; + yield 'unsupported AQI' => [ + ['main' => ['aqi' => 6]], + '"main.aqi" expected an integer from 1 through 5, "6" received.', + ]; + yield 'components' => [ + ['components' => 'invalid'], + '"components" expected array, string received.', + ]; + } +} From 2589d77d49cc927f194dcba7381aa1a947b122f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:00:51 +0100 Subject: [PATCH 035/113] refactor(weather): remove textual forecast timestamp --- src/Entity/Weather/Forecast/Period.php | 7 ------- tests/Unit/Entity/Weather/Forecast/PeriodTest.php | 5 +---- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php index 5014296..3c99b4a 100644 --- a/src/Entity/Weather/Forecast/Period.php +++ b/src/Entity/Weather/Forecast/Period.php @@ -42,7 +42,6 @@ private function __construct( private readonly ?Precipitation $rain, private readonly ?Precipitation $snow, private readonly ?PartOfDay $partOfDay, - private readonly ?string $forecastAtText, private readonly Units $units, ) {} @@ -103,7 +102,6 @@ public static function fromArray(array $data, ?Context $context = null): static rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), partOfDay: $partOfDay, - forecastAtText: $reader->nullableString('dt_txt'), units: UnitsResolver::fromContext($context), ); } @@ -168,9 +166,4 @@ public function partOfDay(): ?PartOfDay { return $this->partOfDay; } - - public function forecastAtText(): ?string - { - return $this->forecastAtText; - } } diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php index 14a5a4f..9660c7b 100644 --- a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -52,7 +52,6 @@ public function testHydratesCapturedForecastPeriod(): void self::assertNull($period->rain()); self::assertNull($period->snow()); self::assertSame(PartOfDay::DAY, $period->partOfDay()); - self::assertSame('2026-08-01 09:00:00', $period->forecastAtText()); } public function testHydratesConditionalRain(): void @@ -117,7 +116,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'rain' => ['3h' => null, 'unknown' => true], 'snow' => null, 'sys' => null, - 'dt_txt' => null, + 'dt_txt' => new \stdClass(), 'unknown' => new \stdClass(), ]); @@ -138,7 +137,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($period->rain()?->lastThreeHoursWithUnit()); self::assertNull($period->snow()); self::assertNull($period->partOfDay()); - self::assertNull($period->forecastAtText()); } public function testRejectsUnknownPartOfDay(): void @@ -184,7 +182,6 @@ public static function invalidFields(): iterable yield 'rain' => [['rain' => ['3h' => '5.49']], '3h', 'int|float', 'string']; yield 'system' => [['sys' => 'invalid'], 'sys', 'array', 'string']; yield 'part of day' => [['sys' => ['pod' => 1]], 'sys.pod', 'string', 'int']; - yield 'forecast time text' => [['dt_txt' => 1785574800], 'dt_txt', 'string', 'int']; } private static function fromFixture(string $path): Period From 569027c382986b6954b3e56510866438f5cc5c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:03:48 +0100 Subject: [PATCH 036/113] feat(air-pollution): add forecast response entity --- src/Entity/AirPollution/Forecast.php | 60 +++++++++ .../Unit/Entity/AirPollution/ForecastTest.php | 116 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/Entity/AirPollution/Forecast.php create mode 100644 tests/Unit/Entity/AirPollution/ForecastTest.php diff --git a/src/Entity/AirPollution/Forecast.php b/src/Entity/AirPollution/Forecast.php new file mode 100644 index 0000000..1631d64 --- /dev/null +++ b/src/Entity/AirPollution/Forecast.php @@ -0,0 +1,60 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/tests/Unit/Entity/AirPollution/ForecastTest.php b/tests/Unit/Entity/AirPollution/ForecastTest.php new file mode 100644 index 0000000..d8c43f9 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/ForecastTest.php @@ -0,0 +1,116 @@ +coordinates()?->latitude()); + self::assertSame($longitude, $forecast->coordinates()?->longitude()); + self::assertCount(96, $forecast->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $forecast->periods()); + self::assertSame(1785614400, $forecast->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(1785956400, $forecast->periods()[95]->forecastAt()?->getTimestamp()); + + $actualAirQualityIndexes = array_values(array_unique(array_map( + static fn (Period $period): ?int => $period->airQualityIndex()?->value, + $forecast->periods(), + ))); + sort($actualAirQualityIndexes); + + self::assertSame($airQualityIndexes, $actualAirQualityIndexes); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Forecast::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertSame([], $missing->periods()); + + $forecast = Forecast::fromArray([ + 'coord' => [ + 'lat' => null, + 'unknown' => new \stdClass(), + ], + 'list' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($forecast->coordinates()?->latitude()); + self::assertNull($forecast->coordinates()?->longitude()); + self::assertCount(2, $forecast->periods()); + self::assertNull($forecast->periods()[0]->forecastAt()); + self::assertNull($forecast->periods()[1]->airQualityIndex()); + + self::assertSame([], Forecast::fromArray(['list' => null])->periods()); + self::assertNull(Forecast::fromArray(['coord' => null])->coordinates()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Forecast::fromArray($data); + } + + public static function capturedForecasts(): iterable + { + yield 'good to moderate' => [ + 'air-pollution/forecast/good-to-moderate.json', + 28.6139, + 77.209, + [1, 2, 3], + ]; + yield 'moderate to very poor' => [ + 'air-pollution/forecast/moderate-to-very-poor.json', + 39.9042, + 116.4074, + [3, 4, 5], + ]; + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '28.6']], + '"lat" expected int|float, string received.', + ]; + yield 'periods' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'period member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'period field' => [ + ['list' => [['main' => 'invalid']]], + '"main" expected array, string received.', + ]; + } +} From 523de68c8a1e5740c0dbeb3fdbc73f7d20e14c7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:06:49 +0100 Subject: [PATCH 037/113] feat(air-pollution): expose forecast endpoint --- docs/air-pollution.md | 34 ++++++++++++++++++++ src/Resource/AirPollution.php | 20 ++++++++++++ tests/Unit/Resource/AirPollutionTest.php | 40 +++++++++++++++++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index e035e28..84be57e 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -59,3 +59,37 @@ $components?->fineParticulateMatter(); // 5.89 $components?->fineParticulateMatterUnit(); // Unit::MICROGRAMS_PER_CUBIC_METER $components?->fineParticulateMatterWithUnit(); // '5.89 µg/m³' ``` + +## Forecast + +The Air Pollution Forecast API provides hourly periods for four days. See the +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for the upstream endpoint contract. + +Use `forecast()` with a latitude and longitude. Both coordinates are validated +before the request is sent. + +```php +$forecast = $api->airPollution()->forecast( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The returned `Forecast` entity exposes the response coordinates and a typed +collection of hourly periods. Missing or `null` period lists become empty +arrays, and every period property may be absent or explicitly `null`. + +```php +echo $forecast->coordinates()?->latitude(); +echo $forecast->coordinates()?->longitude(); + +foreach ($forecast->periods() as $period) { + echo $period->forecastAt()?->format(DATE_ATOM); + echo $period->airQualityIndex()?->value; + echo $period->components()?->fineParticulateMatter(); +} +``` + +Forecast periods use the same OpenWeather Air Quality Index and fixed +`µg/m³` pollutant units as current observations. diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php index 850843d..60081eb 100644 --- a/src/Resource/AirPollution.php +++ b/src/Resource/AirPollution.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; final class AirPollution extends Resource @@ -26,4 +27,23 @@ public function current(float $latitude, float $longitude): Current return $current; } + + public function forecast(float $latitude, float $longitude): Forecast + { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/air-pollution + /** @var Forecast $forecast */ + $forecast = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + ]) + ->get('/data/2.5/air_pollution/forecast') + ->entity(Forecast::class); + + return $forecast; + } } diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php index d98a1db..2cea149 100644 --- a/tests/Unit/Resource/AirPollutionTest.php +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast; use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -31,8 +32,33 @@ public function testGetsCurrentAirPollutionByCoordinates(): void ], $this->query($request)); } + public function testGetsAirPollutionForecastByCoordinates(): void + { + $this->respondWithFixture('air-pollution/forecast/good-to-moderate.json'); + + $forecast = $this->api->airPollution()->forecast( + latitude: 28.6139, + longitude: 77.209, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Forecast::class, $forecast); + self::assertCount(96, $forecast->periods()); + self::assertSame(1785614400, $forecast->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/2.5/air_pollution/forecast', + $request->getUri()->getPath(), + ); + self::assertSame([ + 'lat' => '28.6139', + 'lon' => '77.209', + 'appid' => 'api-key', + ], $this->query($request)); + } + #[DataProvider('invalidCoordinates')] - public function testRejectsInvalidCoordinates( + public function testCurrentRejectsInvalidCoordinates( float $latitude, float $longitude, string $message, @@ -43,6 +69,18 @@ public function testRejectsInvalidCoordinates( $this->api->airPollution()->current($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testForecastRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->forecast($latitude, $longitude); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From 28294efa03b81a0373f1ee8221718b2af357dc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:09:27 +0100 Subject: [PATCH 038/113] feat(air-pollution): add historical period entity --- src/Entity/AirPollution/History/Period.php | 60 ++++++++++++++ .../AirPollution/History/PeriodTest.php | 83 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/Entity/AirPollution/History/Period.php create mode 100644 tests/Unit/Entity/AirPollution/History/PeriodTest.php diff --git a/src/Entity/AirPollution/History/Period.php b/src/Entity/AirPollution/History/Period.php new file mode 100644 index 0000000..1289245 --- /dev/null +++ b/src/Entity/AirPollution/History/Period.php @@ -0,0 +1,60 @@ +nullableInt('main.aqi'); + + if ($airQualityIndex !== null) { + $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) + ?? throw HydrationException::invalidValue( + self::class, + 'main.aqi', + 'an integer from 1 through 5', + $airQualityIndex, + ); + } + + $components = $reader->nullableArray('components'); + + return new self( + observedAt: $reader->nullableTimestamp('dt'), + airQualityIndex: $airQualityIndex, + components: $components === null + ? null + : Components::fromArray($components, $context), + ); + } + + public function observedAt(): ?\DateTimeImmutable + { + return $this->observedAt; + } + + public function airQualityIndex(): ?AirQualityIndex + { + return $this->airQualityIndex; + } + + public function components(): ?Components + { + return $this->components; + } +} diff --git a/tests/Unit/Entity/AirPollution/History/PeriodTest.php b/tests/Unit/Entity/AirPollution/History/PeriodTest.php new file mode 100644 index 0000000..d634926 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/History/PeriodTest.php @@ -0,0 +1,83 @@ +observedAt()?->getTimestamp()); + self::assertSame('UTC', $period->observedAt()?->getTimezone()->getName()); + self::assertSame(AirQualityIndex::FAIR, $period->airQualityIndex()); + self::assertSame(80.71, $period->components()?->carbonMonoxide()); + self::assertSame(6.86, $period->components()?->fineParticulateMatter()); + self::assertSame('6.86 µg/m³', $period->components()?->fineParticulateMatterWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->observedAt()); + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $period = Period::fromArray([ + 'dt' => null, + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->observedAt()); + self::assertNull($period->airQualityIndex()); + self::assertNull($period->components()?->carbonMonoxide()); + self::assertNull($period->components()?->nitrogenDioxide()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'observation time' => [ + ['dt' => '1782864000'], + '"dt" expected int, string received.', + ]; + yield 'main' => [ + ['main' => 'invalid'], + '"main" expected array, string received.', + ]; + yield 'AQI float' => [ + ['main' => ['aqi' => 1.0]], + '"main.aqi" expected int, float received.', + ]; + yield 'unsupported AQI' => [ + ['main' => ['aqi' => 6]], + '"main.aqi" expected an integer from 1 through 5, "6" received.', + ]; + yield 'components' => [ + ['components' => 'invalid'], + '"components" expected array, string received.', + ]; + } +} From 389b2c2fe8edb24b8955631c65cd1b4f83c363b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:24:02 +0100 Subject: [PATCH 039/113] refactor(air-pollution): centralize air quality hydration --- src/Entity/AirPollution/AirQuality.php | 52 ++++++++ .../AirPollution/Concern/HasAirQuality.php | 19 +++ src/Entity/AirPollution/Current.php | 37 +----- src/Entity/AirPollution/Forecast/Period.php | 38 +----- src/Entity/AirPollution/History/Period.php | 38 +----- .../Entity/AirPollution/AirQualityTest.php | 124 ++++++++++++++++++ .../Unit/Entity/AirPollution/CurrentTest.php | 74 +---------- .../AirPollution/Forecast/PeriodTest.php | 32 +---- .../AirPollution/History/PeriodTest.php | 32 +---- 9 files changed, 221 insertions(+), 225 deletions(-) create mode 100644 src/Entity/AirPollution/AirQuality.php create mode 100644 src/Entity/AirPollution/Concern/HasAirQuality.php create mode 100644 tests/Unit/Entity/AirPollution/AirQualityTest.php diff --git a/src/Entity/AirPollution/AirQuality.php b/src/Entity/AirPollution/AirQuality.php new file mode 100644 index 0000000..db525b9 --- /dev/null +++ b/src/Entity/AirPollution/AirQuality.php @@ -0,0 +1,52 @@ +nullableInt('main.aqi'); + + if ($airQualityIndex !== null) { + $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) + ?? throw HydrationException::invalidValue( + self::class, + 'main.aqi', + 'an integer from 1 through 5', + $airQualityIndex, + ); + } + + $components = $reader->nullableArray('components'); + + return new self( + airQualityIndex: $airQualityIndex, + components: $components === null + ? null + : Components::fromArray($components, $context), + ); + } + + public function airQualityIndex(): ?AirQualityIndex + { + return $this->airQualityIndex; + } + + public function components(): ?Components + { + return $this->components; + } +} diff --git a/src/Entity/AirPollution/Concern/HasAirQuality.php b/src/Entity/AirPollution/Concern/HasAirQuality.php new file mode 100644 index 0000000..f8e409e --- /dev/null +++ b/src/Entity/AirPollution/Concern/HasAirQuality.php @@ -0,0 +1,19 @@ +airQuality->airQualityIndex(); + } + + public function components(): ?Components + { + return $this->airQuality->components(); + } +} diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php index d0b3478..29b76e9 100644 --- a/src/Entity/AirPollution/Current.php +++ b/src/Entity/AirPollution/Current.php @@ -4,46 +4,31 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Concern\HasAirQuality; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class Current implements EntityInterface { + use HasAirQuality; + private function __construct( private readonly ?Coordinates $coordinates, private readonly ?\DateTimeImmutable $observedAt, - private readonly ?AirQualityIndex $airQualityIndex, - private readonly ?Components $components, + private readonly AirQuality $airQuality, ) {} public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $airQualityIndex = $reader->nullableInt('list.0.main.aqi'); - - if ($airQualityIndex !== null) { - $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) - ?? throw HydrationException::invalidValue( - self::class, - 'list.0.main.aqi', - 'an integer from 1 through 5', - $airQualityIndex, - ); - } - $coordinates = $reader->nullableArray('coord'); - $components = $reader->nullableArray('list.0.components'); + $observation = $reader->nullableArray('list.0') ?? []; return new self( coordinates: $coordinates === null ? null : Coordinates::fromArray($coordinates, $context), observedAt: $reader->nullableTimestamp('list.0.dt'), - airQualityIndex: $airQualityIndex, - components: $components === null - ? null - : Components::fromArray($components, $context), + airQuality: AirQuality::fromArray($observation, $context), ); } @@ -56,14 +41,4 @@ public function observedAt(): ?\DateTimeImmutable { return $this->observedAt; } - - public function airQualityIndex(): ?AirQualityIndex - { - return $this->airQualityIndex; - } - - public function components(): ?Components - { - return $this->components; - } } diff --git a/src/Entity/AirPollution/Forecast/Period.php b/src/Entity/AirPollution/Forecast/Period.php index 7bbd921..3c9100e 100644 --- a/src/Entity/AirPollution/Forecast/Period.php +++ b/src/Entity/AirPollution/Forecast/Period.php @@ -4,42 +4,26 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Components; -use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\AirQuality; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Concern\HasAirQuality; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class Period implements EntityInterface { + use HasAirQuality; + private function __construct( private readonly ?\DateTimeImmutable $forecastAt, - private readonly ?AirQualityIndex $airQualityIndex, - private readonly ?Components $components, + private readonly AirQuality $airQuality, ) {} public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $airQualityIndex = $reader->nullableInt('main.aqi'); - - if ($airQualityIndex !== null) { - $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) - ?? throw HydrationException::invalidValue( - self::class, - 'main.aqi', - 'an integer from 1 through 5', - $airQualityIndex, - ); - } - - $components = $reader->nullableArray('components'); return new self( forecastAt: $reader->nullableTimestamp('dt'), - airQualityIndex: $airQualityIndex, - components: $components === null - ? null - : Components::fromArray($components, $context), + airQuality: AirQuality::fromArray($data, $context), ); } @@ -47,14 +31,4 @@ public function forecastAt(): ?\DateTimeImmutable { return $this->forecastAt; } - - public function airQualityIndex(): ?AirQualityIndex - { - return $this->airQualityIndex; - } - - public function components(): ?Components - { - return $this->components; - } } diff --git a/src/Entity/AirPollution/History/Period.php b/src/Entity/AirPollution/History/Period.php index 1289245..25d7597 100644 --- a/src/Entity/AirPollution/History/Period.php +++ b/src/Entity/AirPollution/History/Period.php @@ -4,42 +4,26 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Components; -use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\AirQuality; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Concern\HasAirQuality; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class Period implements EntityInterface { + use HasAirQuality; + private function __construct( private readonly ?\DateTimeImmutable $observedAt, - private readonly ?AirQualityIndex $airQualityIndex, - private readonly ?Components $components, + private readonly AirQuality $airQuality, ) {} public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $airQualityIndex = $reader->nullableInt('main.aqi'); - - if ($airQualityIndex !== null) { - $airQualityIndex = AirQualityIndex::tryFrom($airQualityIndex) - ?? throw HydrationException::invalidValue( - self::class, - 'main.aqi', - 'an integer from 1 through 5', - $airQualityIndex, - ); - } - - $components = $reader->nullableArray('components'); return new self( observedAt: $reader->nullableTimestamp('dt'), - airQualityIndex: $airQualityIndex, - components: $components === null - ? null - : Components::fromArray($components, $context), + airQuality: AirQuality::fromArray($data, $context), ); } @@ -47,14 +31,4 @@ public function observedAt(): ?\DateTimeImmutable { return $this->observedAt; } - - public function airQualityIndex(): ?AirQualityIndex - { - return $this->airQualityIndex; - } - - public function components(): ?Components - { - return $this->components; - } } diff --git a/tests/Unit/Entity/AirPollution/AirQualityTest.php b/tests/Unit/Entity/AirPollution/AirQualityTest.php new file mode 100644 index 0000000..b2f83e3 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/AirQualityTest.php @@ -0,0 +1,124 @@ +airQualityIndex()); + + $components = $airQuality->components(); + + self::assertSame(96.56, $components?->carbonMonoxide()); + self::assertSame('96.56 µg/m³', $components?->carbonMonoxideWithUnit()); + self::assertSame(0.01, $components?->nitrogenMonoxide()); + self::assertSame('0.01 µg/m³', $components?->nitrogenMonoxideWithUnit()); + self::assertSame(7.17, $components?->nitrogenDioxide()); + self::assertSame('7.17 µg/m³', $components?->nitrogenDioxideWithUnit()); + self::assertSame(29.69, $components?->ozone()); + self::assertSame('29.69 µg/m³', $components?->ozoneWithUnit()); + self::assertSame(1.03, $components?->sulphurDioxide()); + self::assertSame('1.03 µg/m³', $components?->sulphurDioxideWithUnit()); + self::assertSame(5.89, $components?->fineParticulateMatter()); + self::assertSame('5.89 µg/m³', $components?->fineParticulateMatterWithUnit()); + self::assertSame(7.62, $components?->coarseParticulateMatter()); + self::assertSame('7.62 µg/m³', $components?->coarseParticulateMatterWithUnit()); + self::assertSame(0.65, $components?->ammonia()); + self::assertSame('0.65 µg/m³', $components?->ammoniaWithUnit()); + self::assertSame( + Unit::MICROGRAMS_PER_CUBIC_METER, + $components?->fineParticulateMatterUnit(), + ); + } + + #[DataProvider('airQualityIndexes')] + public function testHydratesEveryDocumentedAirQualityIndex( + int $value, + AirQualityIndex $expected, + ): void { + $airQuality = AirQuality::fromArray([ + 'main' => ['aqi' => $value], + ]); + + self::assertSame($expected, $airQuality->airQualityIndex()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = AirQuality::fromArray([]); + + self::assertNull($missing->airQualityIndex()); + self::assertNull($missing->components()); + + $airQuality = AirQuality::fromArray([ + 'main' => ['aqi' => null, 'unknown' => true], + 'components' => [ + 'co' => null, + 'unknown' => new \stdClass(), + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($airQuality->airQualityIndex()); + self::assertNull($airQuality->components()?->carbonMonoxide()); + self::assertNull($airQuality->components()?->nitrogenMonoxide()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + AirQuality::fromArray($data); + } + + public static function airQualityIndexes(): iterable + { + yield 'good' => [1, AirQualityIndex::GOOD]; + yield 'fair' => [2, AirQualityIndex::FAIR]; + yield 'moderate' => [3, AirQualityIndex::MODERATE]; + yield 'poor' => [4, AirQualityIndex::POOR]; + yield 'very poor' => [5, AirQualityIndex::VERY_POOR]; + } + + public static function invalidFields(): iterable + { + yield 'main' => [ + ['main' => 'invalid'], + '"main" expected array, string received.', + ]; + yield 'AQI type' => [ + ['main' => ['aqi' => '1']], + '"main.aqi" expected int, string received.', + ]; + yield 'AQI float' => [ + ['main' => ['aqi' => 1.0]], + '"main.aqi" expected int, float received.', + ]; + yield 'unsupported AQI' => [ + ['main' => ['aqi' => 6]], + '"main.aqi" expected an integer from 1 through 5, "6" received.', + ]; + yield 'components' => [ + ['components' => 'invalid'], + '"components" expected array, string received.', + ]; + yield 'component concentration' => [ + ['components' => ['co' => '96.56']], + '"co" expected int|float, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/AirPollution/CurrentTest.php b/tests/Unit/Entity/AirPollution/CurrentTest.php index 46001c1..155fb3b 100644 --- a/tests/Unit/Entity/AirPollution/CurrentTest.php +++ b/tests/Unit/Entity/AirPollution/CurrentTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Current; use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; -use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; @@ -24,40 +23,8 @@ public function testHydratesCapturedCurrentAirPollution(): void self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); - $components = $current->components(); - - self::assertSame(96.56, $components?->carbonMonoxide()); - self::assertSame('96.56 µg/m³', $components?->carbonMonoxideWithUnit()); - self::assertSame(0.01, $components?->nitrogenMonoxide()); - self::assertSame('0.01 µg/m³', $components?->nitrogenMonoxideWithUnit()); - self::assertSame(7.17, $components?->nitrogenDioxide()); - self::assertSame('7.17 µg/m³', $components?->nitrogenDioxideWithUnit()); - self::assertSame(29.69, $components?->ozone()); - self::assertSame('29.69 µg/m³', $components?->ozoneWithUnit()); - self::assertSame(1.03, $components?->sulphurDioxide()); - self::assertSame('1.03 µg/m³', $components?->sulphurDioxideWithUnit()); - self::assertSame(5.89, $components?->fineParticulateMatter()); - self::assertSame('5.89 µg/m³', $components?->fineParticulateMatterWithUnit()); - self::assertSame(7.62, $components?->coarseParticulateMatter()); - self::assertSame('7.62 µg/m³', $components?->coarseParticulateMatterWithUnit()); - self::assertSame(0.65, $components?->ammonia()); - self::assertSame('0.65 µg/m³', $components?->ammoniaWithUnit()); - self::assertSame( - Unit::MICROGRAMS_PER_CUBIC_METER, - $components?->fineParticulateMatterUnit(), - ); - } - - #[DataProvider('airQualityIndexes')] - public function testHydratesEveryDocumentedAirQualityIndex( - int $value, - AirQualityIndex $expected, - ): void { - $current = Current::fromArray([ - 'list' => [['main' => ['aqi' => $value]]], - ]); - - self::assertSame($expected, $current->airQualityIndex()); + self::assertSame(96.56, $current->components()?->carbonMonoxide()); + self::assertSame('96.56 µg/m³', $current->components()?->carbonMonoxideWithUnit()); } public function testToleratesMissingNullUnknownAndPartialFields(): void @@ -89,10 +56,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($current->airQualityIndex()); self::assertNull($current->components()?->carbonMonoxide()); self::assertNull($current->components()?->nitrogenMonoxide()); - self::assertSame( - Unit::MICROGRAMS_PER_CUBIC_METER, - $current->components()?->nitrogenMonoxideUnit(), - ); self::assertNull(Current::fromArray(['list' => null])->observedAt()); self::assertNull(Current::fromArray(['list' => []])->observedAt()); @@ -107,15 +70,6 @@ public function testRejectsInvalidKnownFields(array $data, string $message): voi Current::fromArray($data); } - public static function airQualityIndexes(): iterable - { - yield 'good' => [1, AirQualityIndex::GOOD]; - yield 'fair' => [2, AirQualityIndex::FAIR]; - yield 'moderate' => [3, AirQualityIndex::MODERATE]; - yield 'poor' => [4, AirQualityIndex::POOR]; - yield 'very poor' => [5, AirQualityIndex::VERY_POOR]; - } - public static function invalidFields(): iterable { yield 'coordinates' => [ @@ -138,29 +92,5 @@ public static function invalidFields(): iterable ['list' => [['dt' => '1785616883']]], '"list.0.dt" expected int, string received.', ]; - yield 'main' => [ - ['list' => [['main' => 'invalid']]], - '"list.0.main" expected array, string received.', - ]; - yield 'AQI type' => [ - ['list' => [['main' => ['aqi' => '1']]]], - '"list.0.main.aqi" expected int, string received.', - ]; - yield 'AQI float' => [ - ['list' => [['main' => ['aqi' => 1.0]]]], - '"list.0.main.aqi" expected int, float received.', - ]; - yield 'unsupported AQI' => [ - ['list' => [['main' => ['aqi' => 6]]]], - '"list.0.main.aqi" expected an integer from 1 through 5, "6" received.', - ]; - yield 'components' => [ - ['list' => [['components' => 'invalid']]], - '"list.0.components" expected array, string received.', - ]; - yield 'component concentration' => [ - ['list' => [['components' => ['co' => '96.56']]]], - '"co" expected int|float, string received.', - ]; } } diff --git a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php index 18bf11e..2a6a878 100644 --- a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Entity\AirPollution\Forecast; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast\Period; use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; @@ -48,36 +47,11 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($period->components()?->nitrogenDioxide()); } - #[DataProvider('invalidFields')] - public function testRejectsInvalidKnownFields(array $data, string $message): void + public function testRejectsInvalidForecastTime(): void { $this->expectException(HydrationException::class); - $this->expectExceptionMessage($message); + $this->expectExceptionMessage('"dt" expected int, string received.'); - Period::fromArray($data); - } - - public static function invalidFields(): iterable - { - yield 'forecast time' => [ - ['dt' => '1785614400'], - '"dt" expected int, string received.', - ]; - yield 'main' => [ - ['main' => 'invalid'], - '"main" expected array, string received.', - ]; - yield 'AQI float' => [ - ['main' => ['aqi' => 1.0]], - '"main.aqi" expected int, float received.', - ]; - yield 'unsupported AQI' => [ - ['main' => ['aqi' => 6]], - '"main.aqi" expected an integer from 1 through 5, "6" received.', - ]; - yield 'components' => [ - ['components' => 'invalid'], - '"components" expected array, string received.', - ]; + Period::fromArray(['dt' => '1785614400']); } } diff --git a/tests/Unit/Entity/AirPollution/History/PeriodTest.php b/tests/Unit/Entity/AirPollution/History/PeriodTest.php index d634926..d67e754 100644 --- a/tests/Unit/Entity/AirPollution/History/PeriodTest.php +++ b/tests/Unit/Entity/AirPollution/History/PeriodTest.php @@ -2,7 +2,6 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Entity\AirPollution\History; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\History\Period; use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; @@ -48,36 +47,11 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($period->components()?->nitrogenDioxide()); } - #[DataProvider('invalidFields')] - public function testRejectsInvalidKnownFields(array $data, string $message): void + public function testRejectsInvalidObservationTime(): void { $this->expectException(HydrationException::class); - $this->expectExceptionMessage($message); + $this->expectExceptionMessage('"dt" expected int, string received.'); - Period::fromArray($data); - } - - public static function invalidFields(): iterable - { - yield 'observation time' => [ - ['dt' => '1782864000'], - '"dt" expected int, string received.', - ]; - yield 'main' => [ - ['main' => 'invalid'], - '"main" expected array, string received.', - ]; - yield 'AQI float' => [ - ['main' => ['aqi' => 1.0]], - '"main.aqi" expected int, float received.', - ]; - yield 'unsupported AQI' => [ - ['main' => ['aqi' => 6]], - '"main.aqi" expected an integer from 1 through 5, "6" received.', - ]; - yield 'components' => [ - ['components' => 'invalid'], - '"components" expected array, string received.', - ]; + Period::fromArray(['dt' => '1782864000']); } } From ba10535006f715e6cae1194853d8a383d08586b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:27:14 +0100 Subject: [PATCH 040/113] feat(air-pollution): add historical response entity --- src/Entity/AirPollution/History.php | 60 ++++++++++ .../Unit/Entity/AirPollution/HistoryTest.php | 103 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/Entity/AirPollution/History.php create mode 100644 tests/Unit/Entity/AirPollution/HistoryTest.php diff --git a/src/Entity/AirPollution/History.php b/src/Entity/AirPollution/History.php new file mode 100644 index 0000000..2869ec5 --- /dev/null +++ b/src/Entity/AirPollution/History.php @@ -0,0 +1,60 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); + $periods = []; + + foreach ($reader->nullableArray('list') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('list.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + return new self( + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/tests/Unit/Entity/AirPollution/HistoryTest.php b/tests/Unit/Entity/AirPollution/HistoryTest.php new file mode 100644 index 0000000..b350319 --- /dev/null +++ b/tests/Unit/Entity/AirPollution/HistoryTest.php @@ -0,0 +1,103 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $history->coordinates()?->longitude()); + self::assertCount(25, $history->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $history->periods()); + self::assertSame(1782864000, $history->periods()[0]->observedAt()?->getTimestamp()); + self::assertSame(1782950400, $history->periods()[24]->observedAt()?->getTimestamp()); + self::assertSame(AirQualityIndex::FAIR, $history->periods()[0]->airQualityIndex()); + self::assertSame(80.71, $history->periods()[0]->components()?->carbonMonoxide()); + } + + public function testHydratesCapturedEmptyHistory(): void + { + $history = History::fromArray( + Fixture::json('air-pollution/history/empty.json'), + ); + + self::assertSame(38.7223, $history->coordinates()?->latitude()); + self::assertSame(-9.1393, $history->coordinates()?->longitude()); + self::assertSame([], $history->periods()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = History::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertSame([], $missing->periods()); + + $history = History::fromArray([ + 'coord' => [ + 'lat' => null, + 'unknown' => new \stdClass(), + ], + 'list' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($history->coordinates()?->latitude()); + self::assertNull($history->coordinates()?->longitude()); + self::assertCount(2, $history->periods()); + self::assertNull($history->periods()[0]->observedAt()); + self::assertNull($history->periods()[1]->airQualityIndex()); + + self::assertSame([], History::fromArray(['list' => null])->periods()); + self::assertNull(History::fromArray(['coord' => null])->coordinates()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + History::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'coordinates' => [ + ['coord' => 'invalid'], + '"coord" expected array, string received.', + ]; + yield 'latitude' => [ + ['coord' => ['lat' => '38.7']], + '"lat" expected int|float, string received.', + ]; + yield 'periods' => [ + ['list' => 'invalid'], + '"list" expected array, string received.', + ]; + yield 'period member' => [ + ['list' => ['invalid']], + '"list.0" expected array, string received.', + ]; + yield 'period field' => [ + ['list' => [['main' => 'invalid']]], + '"main" expected array, string received.', + ]; + } +} From be70b0eebfdc64ac21e4d240bb797ec358677c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:34:03 +0100 Subject: [PATCH 041/113] feat(air-pollution): expose historical endpoint --- docs/air-pollution.md | 40 ++++++++++ src/Resource/AirPollution.php | 28 +++++++ src/Validation/Assert.php | 25 ++++++ tests/Unit/Resource/AirPollutionTest.php | 97 ++++++++++++++++++++++++ 4 files changed, 190 insertions(+) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index 84be57e..95d299e 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -93,3 +93,43 @@ foreach ($forecast->periods() as $period) { Forecast periods use the same OpenWeather Air Quality Index and fixed `µg/m³` pollutant units as current observations. + +## History + +The Historical Air Pollution API returns hourly observations for a coordinate +and date range. OpenWeather documents historical availability from November 27, +2020, although actual availability may vary. See the +[official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) +for the upstream endpoint contract. + +Use `history()` with a latitude, longitude, start date, and end date. The date +arguments accept any `DateTimeInterface` implementation and are sent as Unix +timestamps. The end must be after or equal to the start and cannot be in the +future. + +```php +$history = $api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + start: new DateTimeImmutable('2 days ago'), + end: new DateTimeImmutable('1 day ago'), +); +``` + +The returned `History` entity exposes the response coordinates and a typed +collection of hourly periods. A valid range for which OpenWeather has no data +returns an empty collection. + +```php +echo $history->coordinates()?->latitude(); +echo $history->coordinates()?->longitude(); + +foreach ($history->periods() as $period) { + echo $period->observedAt()?->format(DATE_ATOM); + echo $period->airQualityIndex()?->value; + echo $period->components()?->fineParticulateMatter(); +} +``` + +Historical periods use the same OpenWeather Air Quality Index and fixed +`µg/m³` pollutant units as current observations and forecast periods. diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php index 60081eb..ed768ce 100644 --- a/src/Resource/AirPollution.php +++ b/src/Resource/AirPollution.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Current; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\History; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; final class AirPollution extends Resource @@ -46,4 +47,31 @@ public function forecast(float $latitude, float $longitude): Forecast return $forecast; } + + public function history( + float $latitude, + float $longitude, + \DateTimeInterface $start, + \DateTimeInterface $end, + ): History { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + Assert::chronologicalRange($start, $end); + $end = Assert::notFuture($end, 'end date'); + + // https://openweathermap.org/api/air-pollution + /** @var History $history */ + $history = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $start->getTimestamp(), + 'end' => $end->getTimestamp(), + ]) + ->get('/data/2.5/air_pollution/history') + ->entity(History::class); + + return $history; + } } diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index 31f824f..00da576 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -55,6 +55,31 @@ public static function countryCode(string $countryCode): string return $countryCode; } + public static function chronologicalRange( + \DateTimeInterface $start, + \DateTimeInterface $end, + ): void { + if ($end->getTimestamp() < $start->getTimestamp()) { + throw new \InvalidArgumentException( + 'The end date must be after or equal to the start date.', + ); + } + } + + public static function notFuture( + \DateTimeInterface $value, + string $name, + ): \DateTimeInterface { + if ($value->getTimestamp() > time()) { + throw new \InvalidArgumentException(sprintf( + 'The %s must not be in the future.', + $name, + )); + } + + return $value; + } + public static function positiveInteger(int $value, string $name): int { if ($value < 1) { diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php index 2cea149..287f35d 100644 --- a/tests/Unit/Resource/AirPollutionTest.php +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Current; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast; +use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\History; use ProgrammatorDev\OpenWeatherMap\Enum\AirQualityIndex; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -57,6 +58,85 @@ public function testGetsAirPollutionForecastByCoordinates(): void ], $this->query($request)); } + public function testGetsHistoricalAirPollutionByCoordinatesAndDateRange(): void + { + $this->respondWithFixture('air-pollution/history/success.json'); + + $history = $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + start: new \DateTimeImmutable('@1782864000'), + end: new \DateTimeImmutable('@1782950400'), + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(History::class, $history); + self::assertCount(25, $history->periods()); + self::assertSame(1782864000, $history->periods()[0]->observedAt()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/2.5/air_pollution/history', + $request->getUri()->getPath(), + ); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1782864000', + 'end' => '1782950400', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testHistoryAllowsEqualRangeBoundaries(): void + { + $this->respondWithFixture('air-pollution/history/empty.json'); + + $boundary = new \DateTimeImmutable('@1604188800'); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + start: $boundary, + end: $boundary, + ); + + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1604188800', + 'end' => '1604188800', + 'appid' => 'api-key', + ], $this->query($this->client->getLastRequest())); + } + + public function testHistoryRejectsReversedRange(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The end date must be after or equal to the start date.', + ); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + start: new \DateTimeImmutable('@1782950400'), + end: new \DateTimeImmutable('@1782864000'), + ); + } + + public function testHistoryRejectsFutureEnd(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The end date must not be in the future.'); + + $this->api->airPollution()->history( + latitude: 38.7223, + longitude: -9.1393, + start: new \DateTimeImmutable('@0'), + end: new \DateTimeImmutable(sprintf('@%d', time() + 60)), + ); + } + #[DataProvider('invalidCoordinates')] public function testCurrentRejectsInvalidCoordinates( float $latitude, @@ -81,6 +161,23 @@ public function testForecastRejectsInvalidCoordinates( $this->api->airPollution()->forecast($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testHistoryRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->airPollution()->history( + $latitude, + $longitude, + new \DateTimeImmutable('@1604188800'), + new \DateTimeImmutable('@1604192400'), + ); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From 09614bb8ec58eea8c3c9f24d92537f0813b0f8c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 09:51:16 +0100 Subject: [PATCH 042/113] refactor(entities): share nested coordinates --- docs/air-pollution.md | 10 ++++++++-- docs/weather.md | 10 +++++----- src/Entity/AirPollution/Current.php | 3 +++ src/Entity/AirPollution/Forecast.php | 1 + src/Entity/AirPollution/History.php | 1 + src/Entity/{AirPollution => }/Coordinates.php | 2 +- src/Entity/Weather/Current.php | 19 ++++++++----------- src/Entity/Weather/Forecast/City.php | 19 ++++++++----------- src/Resource/AirPollution.php | 3 +++ tests/Unit/Entity/Weather/CurrentTest.php | 11 ++++++----- tests/Unit/Entity/Weather/ForecastTest.php | 11 ++++++----- 11 files changed, 50 insertions(+), 40 deletions(-) rename src/Entity/{AirPollution => }/Coordinates.php (92%) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index 95d299e..55be940 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -1,9 +1,11 @@ # Air Pollution +Current, forecast, and historical air pollution are included in OpenWeather's +standard free and paid subscriptions. + ## Current -The Current Air Pollution API is available on OpenWeather's standard free and -paid subscriptions. See the +See OpenWeather's [official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) for the upstream endpoint contract. @@ -36,6 +38,10 @@ The air quality index uses OpenWeather's native scale from 1 (good) through 5 the fixed `µg/m³` unit documented by OpenWeather; weather unit configuration does not affect them. +OpenWeather also documents the UK, European, US, and Mainland China scales in +its [Air Pollution Index levels](https://openweathermap.org/api/air-pollution-index-levels) +reference. + ```php $components = $current->components(); diff --git a/docs/weather.md b/docs/weather.md index b0b9521..0dcd378 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -23,12 +23,12 @@ $current = $api->weather()->current( The method returns a `Current` entity. Every response property may be absent or explicitly `null`; missing or `null` condition lists become empty -arrays. +arrays. Contextual response coordinates are grouped under `coordinates()`. ```php echo $current->name(); -echo $current->latitude(); -echo $current->longitude(); +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); echo $current->temperature(); echo $current->feelsLikeTemperature(); echo $current->minimumTemperature(); @@ -109,8 +109,8 @@ foreach ($forecast->periods() as $period) { } echo $forecast->city()?->name(); -echo $forecast->city()?->latitude(); -echo $forecast->city()?->longitude(); +echo $forecast->city()?->coordinates()?->latitude(); +echo $forecast->city()?->coordinates()?->longitude(); echo $forecast->city()?->timezoneOffset(); ``` diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php index 29b76e9..9deaec8 100644 --- a/src/Entity/AirPollution/Current.php +++ b/src/Entity/AirPollution/Current.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Concern\HasAirQuality; +use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class Current implements EntityInterface @@ -21,6 +22,8 @@ public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); $coordinates = $reader->nullableArray('coord'); + + // The current endpoint wraps its single observation in a list. $observation = $reader->nullableArray('list.0') ?? []; return new self( diff --git a/src/Entity/AirPollution/Forecast.php b/src/Entity/AirPollution/Forecast.php index 1631d64..9c0c412 100644 --- a/src/Entity/AirPollution/Forecast.php +++ b/src/Entity/AirPollution/Forecast.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\Forecast\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; diff --git a/src/Entity/AirPollution/History.php b/src/Entity/AirPollution/History.php index 2869ec5..e7c5259 100644 --- a/src/Entity/AirPollution/History.php +++ b/src/Entity/AirPollution/History.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\AirPollution\History\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; diff --git a/src/Entity/AirPollution/Coordinates.php b/src/Entity/Coordinates.php similarity index 92% rename from src/Entity/AirPollution/Coordinates.php rename to src/Entity/Coordinates.php index 8a1ead9..b0f9262 100644 --- a/src/Entity/AirPollution/Coordinates.php +++ b/src/Entity/Coordinates.php @@ -1,6 +1,6 @@ $conditions */ private function __construct( - private readonly ?float $latitude, - private readonly ?float $longitude, + private readonly ?Coordinates $coordinates, private readonly array $conditions, private readonly ?float $temperature, private readonly ?float $feelsLikeTemperature, @@ -48,6 +48,7 @@ private function __construct( public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); $conditions = []; foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { @@ -69,8 +70,9 @@ public static function fromArray(array $data, ?Context $context = null): static $snow = $reader->nullableArray('snow'); return new self( - latitude: $reader->nullableFloat('coord.lat'), - longitude: $reader->nullableFloat('coord.lon'), + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), conditions: $conditions, temperature: $reader->nullableFloat('main.temp'), feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), @@ -96,14 +98,9 @@ public static function fromArray(array $data, ?Context $context = null): static ); } - public function latitude(): ?float + public function coordinates(): ?Coordinates { - return $this->latitude; - } - - public function longitude(): ?float - { - return $this->longitude; + return $this->coordinates; } /** diff --git a/src/Entity/Weather/Forecast/City.php b/src/Entity/Weather/Forecast/City.php index 0176ffb..ce61009 100644 --- a/src/Entity/Weather/Forecast/City.php +++ b/src/Entity/Weather/Forecast/City.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; +use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class City implements EntityInterface @@ -11,8 +12,7 @@ final class City implements EntityInterface private function __construct( private readonly ?int $id, private readonly ?string $name, - private readonly ?float $latitude, - private readonly ?float $longitude, + private readonly ?Coordinates $coordinates, private readonly ?string $countryCode, private readonly ?int $population, private readonly ?int $timezoneOffset, @@ -23,12 +23,14 @@ private function __construct( public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); + $coordinates = $reader->nullableArray('coord'); return new self( id: $reader->nullableInt('id'), name: $reader->nullableString('name'), - latitude: $reader->nullableFloat('coord.lat'), - longitude: $reader->nullableFloat('coord.lon'), + coordinates: $coordinates === null + ? null + : Coordinates::fromArray($coordinates, $context), countryCode: $reader->nullableString('country'), population: $reader->nullableInt('population'), timezoneOffset: $reader->nullableInt('timezone'), @@ -47,14 +49,9 @@ public function name(): ?string return $this->name; } - public function latitude(): ?float + public function coordinates(): ?Coordinates { - return $this->latitude; - } - - public function longitude(): ?float - { - return $this->longitude; + return $this->coordinates; } public function countryCode(): ?string diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php index ed768ce..b519b7d 100644 --- a/src/Resource/AirPollution.php +++ b/src/Resource/AirPollution.php @@ -57,6 +57,9 @@ public function history( $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); Assert::chronologicalRange($start, $end); + + // A non-future end also constrains the ordered start. + // The documented minimum is left to OpenWeather because live availability differs. $end = Assert::notFuture($end, 'end date'); // https://openweathermap.org/api/air-pollution diff --git a/tests/Unit/Entity/Weather/CurrentTest.php b/tests/Unit/Entity/Weather/CurrentTest.php index c09529d..42bc12c 100644 --- a/tests/Unit/Entity/Weather/CurrentTest.php +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -21,8 +21,8 @@ public function testHydratesCapturedCurrentWeather(): void Fixture::json('weather/current/success.json'), ); - self::assertSame(38.7223, $weather->latitude()); - self::assertSame(-9.1393, $weather->longitude()); + self::assertSame(38.7223, $weather->coordinates()?->latitude()); + self::assertSame(-9.1393, $weather->coordinates()?->longitude()); self::assertSame(22.55, $weather->temperature()); self::assertSame(Unit::CELSIUS, $weather->temperatureUnit()); self::assertSame('22.55 °C', $weather->temperatureWithUnit()); @@ -124,6 +124,7 @@ public function testRetainsUnitsFromHydrationContext(): void public function testToleratesMissingNullUnknownAndPartialFields(): void { + self::assertNull(Current::fromArray([])->coordinates()); self::assertSame([], Current::fromArray(['weather' => null])->conditions()); $weather = Current::fromArray([ @@ -142,8 +143,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($weather->latitude()); - self::assertNull($weather->longitude()); + self::assertNull($weather->coordinates()?->latitude()); + self::assertNull($weather->coordinates()?->longitude()); self::assertCount(1, $weather->conditions()); self::assertNull($weather->conditions()[0]->icon()); self::assertNull($weather->conditions()[0]->iconUrl()); @@ -185,7 +186,7 @@ public function testRejectsInvalidKnownFieldTypes( public static function invalidFields(): iterable { yield 'coordinates' => [['coord' => 'invalid'], 'coord', 'array', 'string']; - yield 'latitude' => [['coord' => ['lat' => '38.7']], 'coord.lat', 'int|float', 'string']; + yield 'latitude' => [['coord' => ['lat' => '38.7']], 'lat', 'int|float', 'string']; yield 'conditions' => [['weather' => 'Clouds'], 'weather', 'array', 'string']; yield 'condition member' => [['weather' => ['Clouds']], 'weather.0', 'array', 'string']; yield 'condition id' => [['weather' => [['id' => '802']]], 'id', 'int', 'string']; diff --git a/tests/Unit/Entity/Weather/ForecastTest.php b/tests/Unit/Entity/Weather/ForecastTest.php index 739c569..42e62da 100644 --- a/tests/Unit/Entity/Weather/ForecastTest.php +++ b/tests/Unit/Entity/Weather/ForecastTest.php @@ -30,8 +30,8 @@ public function testHydratesCapturedForecast(): void self::assertSame(6458923, $city?->id()); self::assertSame('Lisbon Municipality', $city?->name()); - self::assertSame(38.7223, $city?->latitude()); - self::assertSame(-9.1393, $city?->longitude()); + self::assertSame(38.7223, $city?->coordinates()?->latitude()); + self::assertSame(-9.1393, $city?->coordinates()?->longitude()); self::assertSame('PT', $city?->countryCode()); self::assertSame(0, $city?->population()); self::assertSame(3600, $city?->timezoneOffset()); @@ -60,6 +60,7 @@ public function testPropagatesHydrationContextToPeriods(): void public function testToleratesMissingNullUnknownAndPartialFields(): void { self::assertNull(Forecast::fromArray(['city' => null])->city()); + self::assertNull(Forecast::fromArray(['city' => []])->city()?->coordinates()); $forecast = Forecast::fromArray([ 'cod' => new \stdClass(), @@ -82,8 +83,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertSame([], $forecast->periods()); self::assertNull($forecast->city()?->id()); self::assertNull($forecast->city()?->name()); - self::assertNull($forecast->city()?->latitude()); - self::assertNull($forecast->city()?->longitude()); + self::assertNull($forecast->city()?->coordinates()?->latitude()); + self::assertNull($forecast->city()?->coordinates()?->longitude()); self::assertNull($forecast->city()?->countryCode()); self::assertNull($forecast->city()?->population()); self::assertNull($forecast->city()?->timezoneOffset()); @@ -118,7 +119,7 @@ public static function invalidFields(): iterable yield 'city id' => [['city' => ['id' => '1']], 'id', 'int', 'string']; yield 'city name' => [['city' => ['name' => 1]], 'name', 'string', 'int']; yield 'coordinates' => [['city' => ['coord' => 'invalid']], 'coord', 'array', 'string']; - yield 'latitude' => [['city' => ['coord' => ['lat' => '38.7']]], 'coord.lat', 'int|float', 'string']; + yield 'latitude' => [['city' => ['coord' => ['lat' => '38.7']]], 'lat', 'int|float', 'string']; yield 'country' => [['city' => ['country' => 1]], 'country', 'string', 'int']; yield 'population' => [['city' => ['population' => 1.5]], 'population', 'int', 'float']; yield 'timezone' => [['city' => ['timezone' => '3600']], 'timezone', 'int', 'string']; From a84fa0a2cd226fe9c5f58c81e8ba9614e5ee91f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 12:06:39 +0100 Subject: [PATCH 043/113] test(one-call): add current weather fixtures --- tests/Fixtures/one-call/current/rain.json | 35 +++++++++++++++++ .../Fixtures/one-call/current/rain.meta.json | 19 ++++++++++ tests/Fixtures/one-call/current/snow.json | 38 +++++++++++++++++++ .../Fixtures/one-call/current/snow.meta.json | 19 ++++++++++ tests/Fixtures/one-call/current/success.json | 32 ++++++++++++++++ .../one-call/current/success.meta.json | 19 ++++++++++ 6 files changed, 162 insertions(+) create mode 100644 tests/Fixtures/one-call/current/rain.json create mode 100644 tests/Fixtures/one-call/current/rain.meta.json create mode 100644 tests/Fixtures/one-call/current/snow.json create mode 100644 tests/Fixtures/one-call/current/snow.meta.json create mode 100644 tests/Fixtures/one-call/current/success.json create mode 100644 tests/Fixtures/one-call/current/success.meta.json diff --git a/tests/Fixtures/one-call/current/rain.json b/tests/Fixtures/one-call/current/rain.json new file mode 100644 index 0000000..6d4d03a --- /dev/null +++ b/tests/Fixtures/one-call/current/rain.json @@ -0,0 +1,35 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785668580, + "sunrise": 1785620365, + "sunset": 1785666311, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1006, + "humidity": 92, + "dew_point": 25.01, + "uvi": 0, + "clouds": 100, + "visibility": 7458, + "wind_speed": 1.79, + "wind_deg": 272, + "wind_gust": 2.68, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "rain": { + "1h": 0.13 + } + } + ] +} diff --git a/tests/Fixtures/one-call/current/rain.meta.json b/tests/Fixtures/one-call/current/rain.meta.json new file mode 100644 index 0000000..fe105b3 --- /dev/null +++ b/tests/Fixtures/one-call/current/rain.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:03:13Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/current/snow.json b/tests/Fixtures/one-call/current/snow.json new file mode 100644 index 0000000..4d1e999 --- /dev/null +++ b/tests/Fixtures/one-call/current/snow.json @@ -0,0 +1,38 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785668585, + "sunrise": 1785671200, + "sunset": 1785707904, + "temp": -2.31, + "feels_like": -6.94, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0, + "clouds": 100, + "visibility": 33, + "wind_speed": 3.77, + "wind_deg": 306, + "wind_gust": 14.85, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "snow": { + "1h": 4.86 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ] +} diff --git a/tests/Fixtures/one-call/current/snow.meta.json b/tests/Fixtures/one-call/current/snow.meta.json new file mode 100644 index 0000000..167c66e --- /dev/null +++ b/tests/Fixtures/one-call/current/snow.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:03:13Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/current/success.json b/tests/Fixtures/one-call/current/success.json new file mode 100644 index 0000000..d71d102 --- /dev/null +++ b/tests/Fixtures/one-call/current/success.json @@ -0,0 +1,32 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785668004, + "sunrise": 1785649113, + "sunset": 1785700020, + "temp": 24.34, + "feels_like": 24.68, + "pressure": 1016, + "humidity": 71, + "dew_point": 18.75, + "uvi": 7.53, + "clouds": 40, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 293, + "wind_gust": 5.36, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + } + ] +} diff --git a/tests/Fixtures/one-call/current/success.meta.json b/tests/Fixtures/one-call/current/success.meta.json new file mode 100644 index 0000000..dcece59 --- /dev/null +++ b/tests/Fixtures/one-call/current/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Current weather by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T10:53:24Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/current", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} From 14ec726d97b70f3dc43a3703b2fd89851f698fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 12:19:28 +0100 Subject: [PATCH 044/113] feat(one-call): add current weather entity --- src/Entity/OneCall/Current.php | 293 ++++++++++++++++++++++ tests/Unit/Entity/OneCall/CurrentTest.php | 248 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 src/Entity/OneCall/Current.php create mode 100644 tests/Unit/Entity/OneCall/CurrentTest.php diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php new file mode 100644 index 0000000..e63a65e --- /dev/null +++ b/src/Entity/OneCall/Current.php @@ -0,0 +1,293 @@ + $conditions + * @param list $alertIds + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?string $timezone, + private readonly ?int $timezoneOffset, + private readonly ?\DateTimeImmutable $observedAt, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?int $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPointTemperature, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly array $conditions, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + + // One Call wraps the single current observation in a one-item data list. + $observation = $reader->nullableArray('data.0') ?? []; + $conditions = []; + + foreach ($reader->nullableArray('data.0.weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.0.weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $alertIds = []; + + foreach ($reader->nullableArray('data.0.alerts') ?? [] as $index => $alertId) { + if (!is_string($alertId)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.0.alerts.%s', $index), + 'string', + $alertId, + ); + } + + $alertIds[] = $alertId; + } + + $rain = $reader->nullableArray('data.0.rain'); + $snow = $reader->nullableArray('data.0.snow'); + $hasWind = array_key_exists('wind_speed', $observation) + || array_key_exists('wind_deg', $observation) + || array_key_exists('wind_gust', $observation); + $hasClouds = array_key_exists('clouds', $observation); + + return new self( + coordinates: array_key_exists('lat', $data) || array_key_exists('lon', $data) + ? Coordinates::fromArray($data, $context) + : null, + timezone: $reader->nullableString('timezone'), + timezoneOffset: $reader->nullableInt('timezone_offset'), + observedAt: $reader->nullableTimestamp('data.0.dt'), + sunriseAt: $reader->nullableTimestamp('data.0.sunrise'), + sunsetAt: $reader->nullableTimestamp('data.0.sunset'), + temperature: $reader->nullableFloat('data.0.temp'), + feelsLikeTemperature: $reader->nullableFloat('data.0.feels_like'), + pressure: $reader->nullableInt('data.0.pressure'), + humidity: $reader->nullableInt('data.0.humidity'), + dewPointTemperature: $reader->nullableFloat('data.0.dew_point'), + ultravioletIndex: $reader->nullableFloat('data.0.uvi'), + visibility: $reader->nullableInt('data.0.visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('data.0.wind_speed'), + 'deg' => $reader->nullableInt('data.0.wind_deg'), + 'gust' => $reader->nullableFloat('data.0.wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('data.0.clouds'), + ], $context) + : null, + conditions: $conditions, + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + alertIds: $alertIds, + units: UnitsResolver::fromContext($context), + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?string + { + return $this->timezone; + } + + public function timezoneOffset(): ?int + { + return $this->timezoneOffset; + } + + public function observedAt(): ?\DateTimeImmutable + { + return $this->observedAt; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->feelsLikeTemperature, + $this->feelsLikeTemperatureUnit(), + ); + } + + public function pressure(): ?int + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPointTemperature(): ?float + { + return $this->dewPointTemperature; + } + + public function dewPointTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPointTemperature, + $this->dewPointTemperatureUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/tests/Unit/Entity/OneCall/CurrentTest.php b/tests/Unit/Entity/OneCall/CurrentTest.php new file mode 100644 index 0000000..697a158 --- /dev/null +++ b/tests/Unit/Entity/OneCall/CurrentTest.php @@ -0,0 +1,248 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $current->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $current->timezone()); + self::assertSame(3600, $current->timezoneOffset()); + self::assertSame(1785668004, $current->observedAt()?->getTimestamp()); + self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); + self::assertSame(1785649113, $current->sunriseAt()?->getTimestamp()); + self::assertSame(1785700020, $current->sunsetAt()?->getTimestamp()); + + self::assertSame(24.34, $current->temperature()); + self::assertSame(Unit::CELSIUS, $current->temperatureUnit()); + self::assertSame('24.34 °C', $current->temperatureWithUnit()); + self::assertSame(24.68, $current->feelsLikeTemperature()); + self::assertSame('24.68 °C', $current->feelsLikeTemperatureWithUnit()); + self::assertSame(1016, $current->pressure()); + self::assertSame(Unit::HECTOPASCAL, $current->pressureUnit()); + self::assertSame('1016 hPa', $current->pressureWithUnit()); + self::assertSame(71, $current->humidity()); + self::assertSame(Unit::PERCENT, $current->humidityUnit()); + self::assertSame('71 %', $current->humidityWithUnit()); + self::assertSame(18.75, $current->dewPointTemperature()); + self::assertSame(Unit::CELSIUS, $current->dewPointTemperatureUnit()); + self::assertSame('18.75 °C', $current->dewPointTemperatureWithUnit()); + self::assertSame(7.53, $current->ultravioletIndex()); + self::assertSame(40, $current->clouds()?->coverage()); + self::assertSame(Unit::PERCENT, $current->clouds()?->coverageUnit()); + self::assertSame('40 %', $current->clouds()?->coverageWithUnit()); + self::assertSame(10000, $current->visibility()); + self::assertSame(Unit::METER, $current->visibilityUnit()); + self::assertSame('10000 m', $current->visibilityWithUnit()); + self::assertSame(2.24, $current->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $current->wind()?->speedUnit()); + self::assertSame('2.24 m/s', $current->wind()?->speedWithUnit()); + self::assertSame(293, $current->wind()?->direction()); + self::assertSame(Unit::DEGREE, $current->wind()?->directionUnit()); + self::assertSame('293 °', $current->wind()?->directionWithUnit()); + self::assertSame(5.36, $current->wind()?->gust()); + self::assertSame(Unit::METERS_PER_SECOND, $current->wind()?->gustUnit()); + self::assertSame('5.36 m/s', $current->wind()?->gustWithUnit()); + + self::assertCount(1, $current->conditions()); + self::assertSame(802, $current->conditions()[0]->id()); + self::assertSame('Clouds', $current->conditions()[0]->group()); + self::assertSame('scattered clouds', $current->conditions()[0]->description()); + self::assertSame('03d', $current->conditions()[0]->icon()); + self::assertSame( + 'https://openweathermap.org/img/wn/03d@2x.png', + $current->conditions()[0]->iconUrl(), + ); + self::assertNull($current->rain()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + } + + public function testHydratesConditionalRain(): void + { + $current = Current::fromArray( + Fixture::json('one-call/current/rain.json'), + ); + + self::assertSame('Rain', $current->conditions()[0]->group()); + self::assertSame(0.13, $current->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $current->rain()?->lastHourUnit()); + self::assertSame('0.13 mm/h', $current->rain()?->lastHourWithUnit()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + } + + public function testHydratesConditionalSnowAndAlertIds(): void + { + $current = Current::fromArray( + Fixture::json('one-call/current/snow.json'), + ); + + self::assertSame('Snow', $current->conditions()[0]->group()); + self::assertSame(4.86, $current->snow()?->lastHour()); + self::assertSame('4.86 mm/h', $current->snow()?->lastHourWithUnit()); + self::assertNull($current->rain()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $current->alertIds()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $current = Current::fromArray([ + 'data' => [[ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ]], + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $current->temperatureUnit()); + self::assertSame('72.5 °F', $current->temperatureWithUnit()); + self::assertSame('71 °F', $current->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $current->dewPointTemperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $current->wind()?->speedUnit()); + self::assertSame('10 mph', $current->wind()?->speedWithUnit()); + self::assertSame('15 mph', $current->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Current::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertNull($missing->observedAt()); + self::assertNull($missing->temperature()); + self::assertNull($missing->temperatureWithUnit()); + self::assertNull($missing->dewPointTemperature()); + self::assertNull($missing->ultravioletIndex()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $current = Current::fromArray([ + 'lat' => null, + 'timezone' => null, + 'data' => [[ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($current->coordinates()?->latitude()); + self::assertNull($current->coordinates()?->longitude()); + self::assertCount(1, $current->conditions()); + self::assertNull($current->conditions()[0]->icon()); + self::assertNull($current->temperature()); + self::assertNull($current->wind()); + self::assertNull($current->clouds()); + self::assertNull($current->rain()?->lastHour()); + self::assertNull($current->snow()); + self::assertSame([], $current->alertIds()); + + self::assertNull(Current::fromArray(['data' => null])->observedAt()); + self::assertNull(Current::fromArray(['data' => []])->observedAt()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Current::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'data' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'data member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'observation time' => [ + ['data' => [['dt' => '1785668004']]], + '"data.0.dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['data' => [['temp' => '24.34']]], + '"data.0.temp" expected int|float, string received.', + ]; + yield 'pressure' => [ + ['data' => [['pressure' => 1016.5]]], + '"data.0.pressure" expected int, float received.', + ]; + yield 'wind speed' => [ + ['data' => [['wind_speed' => '2.24']]], + '"data.0.wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['data' => [['clouds' => 40.5]]], + '"data.0.clouds" expected int, float received.', + ]; + yield 'conditions' => [ + ['data' => [['weather' => 'Clouds']]], + '"data.0.weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['data' => [['weather' => ['Clouds']]]], + '"data.0.weather.0" expected array, string received.', + ]; + yield 'rain' => [ + ['data' => [['rain' => 'invalid']]], + '"data.0.rain" expected array, string received.', + ]; + yield 'alert IDs' => [ + ['data' => [['alerts' => 'invalid']]], + '"data.0.alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['data' => [['alerts' => [123]]]], + '"data.0.alerts.0" expected string, int received.', + ]; + } +} From 331f9f08263350cc24c3748c8da29344e26a303c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 12:48:54 +0100 Subject: [PATCH 045/113] test(one-call): add timeline and alert fixtures --- tests/Fixtures/one-call/alert/chile-rain.json | 16 + .../one-call/alert/chile-rain.meta.json | 14 + .../one-call/alert/houston-air-quality.json | 16 + .../alert/houston-air-quality.meta.json | 14 + .../one-call/alert/phoenix-extreme-heat.json | 16 + .../alert/phoenix-extreme-heat.meta.json | 14 + .../one-call/alert/tokyo-thunderstorm.json | 16 + .../alert/tokyo-thunderstorm.meta.json | 14 + .../one-call/fifteen-minute/pagination.json | 1110 +++++++++++++++ .../fifteen-minute/pagination.meta.json | 30 + .../one-call/fifteen-minute/rain.json | 1109 +++++++++++++++ .../one-call/fifteen-minute/rain.meta.json | 24 + .../one-call/fifteen-minute/snow-alerts.json | 1259 +++++++++++++++++ .../fifteen-minute/snow-alerts.meta.json | 24 + .../one-call/fifteen-minute/success.json | 1109 +++++++++++++++ .../one-call/fifteen-minute/success.meta.json | 24 + tests/Fixtures/one-call/one-day/history.json | 379 +++++ .../one-call/one-day/history.meta.json | 29 + tests/Fixtures/one-call/one-day/rain.json | 390 +++++ .../Fixtures/one-call/one-day/rain.meta.json | 28 + tests/Fixtures/one-call/one-day/snow.json | 390 +++++ .../Fixtures/one-call/one-day/snow.meta.json | 28 + tests/Fixtures/one-call/one-day/success.json | 381 +++++ .../one-call/one-day/success.meta.json | 28 + tests/Fixtures/one-call/one-hour/history.json | 450 ++++++ .../one-call/one-hour/history.meta.json | 29 + tests/Fixtures/one-call/one-hour/rain.json | 515 +++++++ .../Fixtures/one-call/one-hour/rain.meta.json | 28 + .../one-call/one-hour/snow-alerts.json | 572 ++++++++ .../one-call/one-hour/snow-alerts.meta.json | 28 + tests/Fixtures/one-call/one-hour/success.json | 470 ++++++ .../one-call/one-hour/success.meta.json | 28 + .../one-minute/precipitation-alerts.json | 428 ++++++ .../one-minute/precipitation-alerts.meta.json | 19 + .../Fixtures/one-call/one-minute/success.json | 248 ++++ .../one-call/one-minute/success.meta.json | 19 + 36 files changed, 9296 insertions(+) create mode 100644 tests/Fixtures/one-call/alert/chile-rain.json create mode 100644 tests/Fixtures/one-call/alert/chile-rain.meta.json create mode 100644 tests/Fixtures/one-call/alert/houston-air-quality.json create mode 100644 tests/Fixtures/one-call/alert/houston-air-quality.meta.json create mode 100644 tests/Fixtures/one-call/alert/phoenix-extreme-heat.json create mode 100644 tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json create mode 100644 tests/Fixtures/one-call/alert/tokyo-thunderstorm.json create mode 100644 tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/pagination.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/pagination.meta.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/rain.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/rain.meta.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/snow-alerts.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/success.json create mode 100644 tests/Fixtures/one-call/fifteen-minute/success.meta.json create mode 100644 tests/Fixtures/one-call/one-day/history.json create mode 100644 tests/Fixtures/one-call/one-day/history.meta.json create mode 100644 tests/Fixtures/one-call/one-day/rain.json create mode 100644 tests/Fixtures/one-call/one-day/rain.meta.json create mode 100644 tests/Fixtures/one-call/one-day/snow.json create mode 100644 tests/Fixtures/one-call/one-day/snow.meta.json create mode 100644 tests/Fixtures/one-call/one-day/success.json create mode 100644 tests/Fixtures/one-call/one-day/success.meta.json create mode 100644 tests/Fixtures/one-call/one-hour/history.json create mode 100644 tests/Fixtures/one-call/one-hour/history.meta.json create mode 100644 tests/Fixtures/one-call/one-hour/rain.json create mode 100644 tests/Fixtures/one-call/one-hour/rain.meta.json create mode 100644 tests/Fixtures/one-call/one-hour/snow-alerts.json create mode 100644 tests/Fixtures/one-call/one-hour/snow-alerts.meta.json create mode 100644 tests/Fixtures/one-call/one-hour/success.json create mode 100644 tests/Fixtures/one-call/one-hour/success.meta.json create mode 100644 tests/Fixtures/one-call/one-minute/precipitation-alerts.json create mode 100644 tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json create mode 100644 tests/Fixtures/one-call/one-minute/success.json create mode 100644 tests/Fixtures/one-call/one-minute/success.meta.json diff --git a/tests/Fixtures/one-call/alert/chile-rain.json b/tests/Fixtures/one-call/alert/chile-rain.json new file mode 100644 index 0000000..462d085 --- /dev/null +++ b/tests/Fixtures/one-call/alert/chile-rain.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0", + "sender_name": "Dirección Meteorológica de Chile", + "event": "", + "start": 1785578400, + "end": 1785708000, + "description": [ + { + "language": "es-CL", + "description": "Precipitaciones Normales a Moderadas en zonas de las regiones de La Araucanía, Los Ríos y Los Lagos" + } + ], + "tags": [ + "Rain" + ] +} diff --git a/tests/Fixtures/one-call/alert/chile-rain.meta.json b/tests/Fixtures/one-call/alert/chile-rain.meta.json new file mode 100644 index 0000000..42857ff --- /dev/null +++ b/tests/Fixtures/one-call/alert/chile-rain.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/houston-air-quality.json b/tests/Fixtures/one-call/alert/houston-air-quality.json new file mode 100644 index 0000000..921d3e3 --- /dev/null +++ b/tests/Fixtures/one-call/alert/houston-air-quality.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.1.840.0.ecea2e4e89531a971e82aa9bfb2483dd830a4738.001.1:7038934a0b3cdfbb142576a95acc4a0a", + "sender_name": "NWS Houston/Galveston TX", + "event": "", + "start": 1785614160, + "end": 1785715200, + "description": [ + { + "language": "en-US", + "description": "AQAHGX\n\nThe Texas Commission on Environmental Quality (TCEQ) has issued an\nOzone Action Day for the Houston, Galveston, and Brazoria area for\nSunday, August 2, 2026.\n\nAtmospheric conditions are expected to be favorable for producing\nhigh levels of ozone pollution in the Houston, Galveston, and\nsurrounding areas on Sunday. You can help prevent ozone pollution by\nsharing a ride, walking, riding a bicycle, taking your lunch to work,\navoiding drive through lanes, conserving energy and keeping your vehicle\nproperly tuned.\n\nFor more information on ozone:\nOzone: The Facts www.tceq.texas.gov/airquality/monops/ozonefacts.html\nEPA AirNow: www.airnow.gov/?city=Houston&state=TX&country=USA\nTake Care of Texas:\nwww.takecareoftexas.org/conservation-tips/keep-our-air-clean" + } + ], + "tags": [ + "Air quality" + ] +} diff --git a/tests/Fixtures/one-call/alert/houston-air-quality.meta.json b/tests/Fixtures/one-call/alert/houston-air-quality.meta.json new file mode 100644 index 0000000..f7bd129 --- /dev/null +++ b/tests/Fixtures/one-call/alert/houston-air-quality.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.1.840.0.ecea2e4e89531a971e82aa9bfb2483dd830a4738.001.1:7038934a0b3cdfbb142576a95acc4a0a", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json new file mode 100644 index 0000000..4f2afd8 --- /dev/null +++ b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.json @@ -0,0 +1,16 @@ +{ + "id": "urn:oid:2.49.0.1.840.0.cd17291384fae80fe8e7d9bdf80b7daacd96d2b2.003.1:42df637e7a40cbc59c30cd956a8a638b", + "sender_name": "NWS Phoenix AZ", + "event": "", + "start": 1785653280, + "end": 1785898800, + "description": [ + { + "language": "en-US", + "description": "* WHAT...Dangerously hot and humid conditions. Afternoon temperatures\n109 to 116. Major to Extreme Heat Risk.\n\n* WHERE...The Phoenix metropolitan area.\n\n* WHEN...Until 8 PM MST Tuesday.\n\n* IMPACTS...Increase in heat related illnesses, including heat\ncramps, heat exhaustion, and heat stroke. Heat stroke can lead to\ndeath.\n\n* ADDITIONAL DETAILS...In Maricopa County, Call 2-1-1 to find a free\ncooling center, transportation, water and more.\nhttps://www.maricopa.gov/heat" + } + ], + "tags": [ + "Extreme high temperature" + ] +} diff --git a/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json new file mode 100644 index 0000000..aaa42ba --- /dev/null +++ b/tests/Fixtures/one-call/alert/phoenix-extreme-heat.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/urn:oid:2.49.0.1.840.0.cd17291384fae80fe8e7d9bdf80b7daacd96d2b2.003.1:42df637e7a40cbc59c30cd956a8a638b", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json new file mode 100644 index 0000000..3235f20 --- /dev/null +++ b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.json @@ -0,0 +1,16 @@ +{ + "id": "VPWW54_JPTK_020757_02_202608020757550_001_55787:14:1311500:0:0:bd980f52b5594645c6a36b4016fdd359", + "sender_name": "JMA", + "event": "", + "start": 1785661201, + "end": 1785682800, + "description": [ + { + "language": "ja-JP", + "description": "突風\n突風\nひょう" + } + ], + "tags": [ + "Thunderstorm" + ] +} diff --git a/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json new file mode 100644 index 0000000..cb311de --- /dev/null +++ b/tests/Fixtures/one-call/alert/tokyo-thunderstorm.meta.json @@ -0,0 +1,14 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "Detailed weather alert by ID", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:44:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/alert/VPWW54_JPTK_020757_02_202608020757550_001_55787:14:1311500:0:0:bd980f52b5594645c6a36b4016fdd359", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/pagination.json b/tests/Fixtures/one-call/fifteen-minute/pagination.json new file mode 100644 index 0000000..cb4b42c --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/pagination.json @@ -0,0 +1,1110 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785715200, + "temp": 21.57, + "feels_like": 21.69, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.78, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.84, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785716100, + "temp": 21.52, + "feels_like": 21.64, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.71, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785717000, + "temp": 21.47, + "feels_like": 21.59, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.83, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.58, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785717900, + "temp": 21.41, + "feels_like": 21.53, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.86, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.44, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785718800, + "temp": 21.36, + "feels_like": 21.48, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.88, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 323, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785719700, + "temp": 21.28, + "feels_like": 21.41, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.32, + "wind_deg": 321, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785720600, + "temp": 21.2, + "feels_like": 21.34, + "pressure": 1014, + "humidity": 75, + "dew_point": 16.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.32, + "wind_deg": 320, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785721500, + "temp": 21.11, + "feels_like": 21.27, + "pressure": 1014, + "humidity": 76, + "dew_point": 16.16, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785722400, + "temp": 21.03, + "feels_like": 21.2, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.25, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785723300, + "temp": 20.94, + "feels_like": 21.12, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.3, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.19, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785724200, + "temp": 20.85, + "feels_like": 21.03, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.35, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.05, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785725100, + "temp": 20.76, + "feels_like": 20.95, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.4, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.91, + "wind_deg": 303, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785726000, + "temp": 20.67, + "feels_like": 20.86, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.45, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 298, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785726900, + "temp": 20.58, + "feels_like": 20.76, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.47, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 1.63, + "wind_deg": 297, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785727800, + "temp": 20.48, + "feels_like": 20.66, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.5, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.48, + "wind_deg": 296, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785728700, + "temp": 20.38, + "feels_like": 20.56, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.52, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 295, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785729600, + "temp": 20.29, + "feels_like": 20.46, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.54, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.19, + "wind_deg": 295, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785730500, + "temp": 20.33, + "feels_like": 20.51, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.57, + "uvi": 0, + "clouds": 96, + "visibility": 10000, + "wind_speed": 1.21, + "wind_deg": 293, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785731400, + "temp": 20.36, + "feels_like": 20.55, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.6, + "uvi": 0, + "clouds": 95, + "visibility": 10000, + "wind_speed": 1.22, + "wind_deg": 291, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785732300, + "temp": 20.39, + "feels_like": 20.6, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.63, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.23, + "wind_deg": 289, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785733200, + "temp": 20.43, + "feels_like": 20.64, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.66, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.25, + "wind_deg": 287, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785734100, + "temp": 20.64, + "feels_like": 20.87, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.67, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.29, + "wind_deg": 278, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785735000, + "temp": 20.85, + "feels_like": 21.1, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.67, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.33, + "wind_deg": 270, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785735900, + "temp": 21.05, + "feels_like": 21.33, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.36, + "wind_deg": 262, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785736800, + "temp": 21.26, + "feels_like": 21.56, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.4, + "wind_deg": 254, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785737700, + "temp": 21.46, + "feels_like": 21.76, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.67, + "uvi": 0.12, + "clouds": 95, + "visibility": 10000, + "wind_speed": 1.49, + "wind_deg": 253, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785738600, + "temp": 21.66, + "feels_like": 21.96, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.65, + "uvi": 0.23, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.59, + "wind_deg": 253, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785739500, + "temp": 21.85, + "feels_like": 22.15, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.64, + "uvi": 0.35, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.68, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785740400, + "temp": 22.05, + "feels_like": 22.35, + "pressure": 1014, + "humidity": 78, + "dew_point": 16.63, + "uvi": 0.46, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785741300, + "temp": 22.2, + "feels_like": 22.48, + "pressure": 1014.25, + "humidity": 76, + "dew_point": 16.59, + "uvi": 0.72, + "clouds": 88, + "visibility": 10000, + "wind_speed": 1.93, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785742200, + "temp": 22.35, + "feels_like": 22.6, + "pressure": 1014.5, + "humidity": 75, + "dew_point": 16.56, + "uvi": 0.98, + "clouds": 76, + "visibility": 10000, + "wind_speed": 2.09, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785743100, + "temp": 22.5, + "feels_like": 22.73, + "pressure": 1014.75, + "humidity": 73, + "dew_point": 16.52, + "uvi": 1.24, + "clouds": 64, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785744000, + "temp": 22.65, + "feels_like": 22.85, + "pressure": 1015, + "humidity": 72, + "dew_point": 16.48, + "uvi": 1.5, + "clouds": 52, + "visibility": 10000, + "wind_speed": 2.4, + "wind_deg": 252, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785744900, + "temp": 22.8, + "feels_like": 22.98, + "pressure": 1015, + "humidity": 70, + "dew_point": 16.48, + "uvi": 1.94, + "clouds": 47, + "visibility": 10000, + "wind_speed": 2.51, + "wind_deg": 250, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785745800, + "temp": 22.95, + "feels_like": 23.12, + "pressure": 1015, + "humidity": 69, + "dew_point": 16.47, + "uvi": 2.38, + "clouds": 43, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 248, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785746700, + "temp": 23.1, + "feels_like": 23.25, + "pressure": 1015, + "humidity": 68, + "dew_point": 16.47, + "uvi": 2.82, + "clouds": 39, + "visibility": 10000, + "wind_speed": 2.75, + "wind_deg": 246, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785747600, + "temp": 23.25, + "feels_like": 23.38, + "pressure": 1015, + "humidity": 67, + "dew_point": 16.46, + "uvi": 3.26, + "clouds": 35, + "visibility": 10000, + "wind_speed": 2.86, + "wind_deg": 245, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785748500, + "temp": 23.27, + "feels_like": 23.38, + "pressure": 1015, + "humidity": 66, + "dew_point": 16.47, + "uvi": 3.8, + "clouds": 32, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 244, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785749400, + "temp": 23.3, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 65, + "dew_point": 16.47, + "uvi": 4.34, + "clouds": 30, + "visibility": 10000, + "wind_speed": 3.16, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785750300, + "temp": 23.32, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 64, + "dew_point": 16.48, + "uvi": 4.88, + "clouds": 28, + "visibility": 10000, + "wind_speed": 3.31, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785751200, + "temp": 23.34, + "feels_like": 23.37, + "pressure": 1015, + "humidity": 63, + "dew_point": 16.48, + "uvi": 5.42, + "clouds": 26, + "visibility": 10000, + "wind_speed": 3.46, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785752100, + "temp": 23.56, + "feels_like": 23.59, + "pressure": 1015, + "humidity": 62, + "dew_point": 16.52, + "uvi": 5.94, + "clouds": 24, + "visibility": 10000, + "wind_speed": 3.6, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785753000, + "temp": 23.77, + "feels_like": 23.81, + "pressure": 1015, + "humidity": 61, + "dew_point": 16.57, + "uvi": 6.47, + "clouds": 23, + "visibility": 10000, + "wind_speed": 3.74, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785753900, + "temp": 23.99, + "feels_like": 24.02, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.61, + "uvi": 6.99, + "clouds": 22, + "visibility": 10000, + "wind_speed": 3.87, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785754800, + "temp": 24.2, + "feels_like": 24.24, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.65, + "uvi": 7.51, + "clouds": 21, + "visibility": 10000, + "wind_speed": 4.01, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785755700, + "temp": 24.49, + "feels_like": 24.56, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.78, + "uvi": 7.8, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.22, + "wind_deg": 240, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785756600, + "temp": 24.77, + "feels_like": 24.87, + "pressure": 1015, + "humidity": 60, + "dew_point": 16.91, + "uvi": 8.09, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.43, + "wind_deg": 241, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785757500, + "temp": 25.06, + "feels_like": 25.18, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.03, + "uvi": 8.38, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.63, + "wind_deg": 242, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785758400, + "temp": 25.34, + "feels_like": 25.5, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.16, + "uvi": 8.67, + "clouds": 20, + "visibility": 10000, + "wind_speed": 4.84, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + }, + { + "dt": 1785759300, + "temp": 25.53, + "feels_like": 25.65, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.3, + "uvi": 8.47, + "clouds": 22, + "visibility": 10000, + "wind_speed": 4.94, + "wind_deg": 243, + "pop": 0, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785670200&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785760200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json b/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json new file mode 100644 index 0000000..396cc10 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/pagination.meta.json @@ -0,0 +1,30 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:25:45Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "cnt": 50, + "lat": 38.7223, + "lon": -9.1393, + "start": 1785715200, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/rain.json b/tests/Fixtures/one-call/fifteen-minute/rain.json new file mode 100644 index 0000000..60d4b91 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/rain.json @@ -0,0 +1,1109 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785670200, + "temp": 26.7, + "feels_like": 28.55, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.29, + "uvi": 0, + "clouds": 100, + "visibility": 8729, + "wind_speed": 1.98, + "wind_deg": 270, + "pop": 0.9, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785671100, + "temp": 26.6, + "feels_like": 27.53, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.19, + "uvi": 0, + "clouds": 100, + "visibility": 9364, + "wind_speed": 2.07, + "wind_deg": 269, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785672000, + "temp": 26.51, + "feels_like": 26.51, + "pressure": 1007, + "humidity": 92, + "dew_point": 25.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.16, + "wind_deg": 268, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785672900, + "temp": 26.4, + "feels_like": 26.4, + "pressure": 1007, + "humidity": 91, + "dew_point": 24.9, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 276, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785673800, + "temp": 26.3, + "feels_like": 26.3, + "pressure": 1007, + "humidity": 91, + "dew_point": 24.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 284, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785674700, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1007, + "humidity": 90, + "dew_point": 24.51, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.41, + "wind_deg": 292, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785675600, + "temp": 26.08, + "feels_like": 26.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 24.31, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.49, + "wind_deg": 301, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785676500, + "temp": 25.97, + "feels_like": 26.19, + "pressure": 1007.25, + "humidity": 89, + "dew_point": 24.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.46, + "wind_deg": 305, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785677400, + "temp": 25.86, + "feels_like": 26.3, + "pressure": 1007.5, + "humidity": 88, + "dew_point": 23.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.43, + "wind_deg": 309, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785678300, + "temp": 25.74, + "feels_like": 26.41, + "pressure": 1007.75, + "humidity": 87, + "dew_point": 23.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.4, + "wind_deg": 313, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785679200, + "temp": 25.63, + "feels_like": 26.52, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.3, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.37, + "wind_deg": 318, + "pop": 0.78, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785680100, + "temp": 25.59, + "feels_like": 26.47, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.22, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 321, + "pop": 0.79, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785681000, + "temp": 25.55, + "feels_like": 26.42, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.13, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.26, + "wind_deg": 325, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785681900, + "temp": 25.51, + "feels_like": 26.37, + "pressure": 1008, + "humidity": 86, + "dew_point": 23.05, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.2, + "wind_deg": 328, + "pop": 0.81, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785682800, + "temp": 25.47, + "feels_like": 26.32, + "pressure": 1008, + "humidity": 86, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.14, + "wind_deg": 332, + "pop": 0.82, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785683700, + "temp": 25.33, + "feels_like": 26.18, + "pressure": 1008, + "humidity": 86, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 328, + "pop": 0.87, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785684600, + "temp": 25.2, + "feels_like": 26.05, + "pressure": 1008, + "humidity": 87, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.48, + "wind_deg": 325, + "pop": 0.91, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785685500, + "temp": 25.06, + "feels_like": 25.91, + "pressure": 1008, + "humidity": 87, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.65, + "wind_deg": 321, + "pop": 0.96, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785686400, + "temp": 24.92, + "feels_like": 25.77, + "pressure": 1008, + "humidity": 88, + "dew_point": 22.96, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.82, + "wind_deg": 318, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785687300, + "temp": 24.68, + "feels_like": 25.51, + "pressure": 1007.75, + "humidity": 88, + "dew_point": 22.99, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.85, + "wind_deg": 310, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785688200, + "temp": 24.44, + "feels_like": 25.24, + "pressure": 1007.5, + "humidity": 88, + "dew_point": 23.02, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.87, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785689100, + "temp": 24.2, + "feels_like": 24.98, + "pressure": 1007.25, + "humidity": 88, + "dew_point": 23.04, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.9, + "wind_deg": 295, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785690000, + "temp": 23.96, + "feels_like": 24.71, + "pressure": 1007, + "humidity": 88, + "dew_point": 23.07, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.92, + "wind_deg": 288, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785690900, + "temp": 23.93, + "feels_like": 24.67, + "pressure": 1006.75, + "humidity": 87, + "dew_point": 22.99, + "uvi": 0, + "clouds": 100, + "visibility": 9787, + "wind_speed": 2.91, + "wind_deg": 283, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785691800, + "temp": 23.89, + "feels_like": 24.62, + "pressure": 1006.5, + "humidity": 87, + "dew_point": 22.92, + "uvi": 0, + "clouds": 100, + "visibility": 9574, + "wind_speed": 2.9, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785692700, + "temp": 23.86, + "feels_like": 24.58, + "pressure": 1006.25, + "humidity": 87, + "dew_point": 22.84, + "uvi": 0, + "clouds": 100, + "visibility": 9361, + "wind_speed": 2.89, + "wind_deg": 274, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785693600, + "temp": 23.82, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.76, + "uvi": 0, + "clouds": 100, + "visibility": 9149, + "wind_speed": 2.88, + "wind_deg": 270, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785694500, + "temp": 23.81, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.8, + "uvi": 0, + "clouds": 100, + "visibility": 9361, + "wind_speed": 2.82, + "wind_deg": 267, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785695400, + "temp": 23.79, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.85, + "uvi": 0, + "clouds": 100, + "visibility": 9574, + "wind_speed": 2.76, + "wind_deg": 264, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785696300, + "temp": 23.78, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.89, + "uvi": 0, + "clouds": 100, + "visibility": 9787, + "wind_speed": 2.69, + "wind_deg": 261, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785697200, + "temp": 23.76, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 258, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785698100, + "temp": 23.85, + "feels_like": 24.61, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.76, + "wind_deg": 255, + "pop": 1, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785699000, + "temp": 23.93, + "feels_like": 24.71, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.88, + "wind_deg": 252, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785699900, + "temp": 24.02, + "feels_like": 24.8, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 249, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785700800, + "temp": 24.1, + "feels_like": 24.89, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 247, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785701700, + "temp": 24.19, + "feels_like": 24.98, + "pressure": 1006, + "humidity": 88, + "dew_point": 22.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.36, + "wind_deg": 246, + "pop": 0.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ] + }, + { + "dt": 1785702600, + "temp": 24.28, + "feels_like": 25.08, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.01, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.59, + "wind_deg": 246, + "pop": 0.9, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785703500, + "temp": 24.37, + "feels_like": 25.17, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.06, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.81, + "wind_deg": 246, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785704400, + "temp": 24.46, + "feels_like": 25.26, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.04, + "wind_deg": 246, + "pop": 0.8, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785705300, + "temp": 24.43, + "feels_like": 25.24, + "pressure": 1006.25, + "humidity": 88, + "dew_point": 23.11, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.61, + "wind_deg": 245, + "pop": 0.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785706200, + "temp": 24.41, + "feels_like": 25.21, + "pressure": 1006.5, + "humidity": 88, + "dew_point": 23.12, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.17, + "wind_deg": 244, + "pop": 0.9, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785707100, + "temp": 24.38, + "feels_like": 25.19, + "pressure": 1006.75, + "humidity": 88, + "dew_point": 23.14, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 243, + "pop": 0.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785708000, + "temp": 24.35, + "feels_like": 25.16, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.15, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 242, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785708900, + "temp": 24.33, + "feels_like": 25.14, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.16, + "uvi": 0.02, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.46, + "wind_deg": 239, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785709800, + "temp": 24.3, + "feels_like": 25.12, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.17, + "uvi": 0.04, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.62, + "wind_deg": 237, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785710700, + "temp": 24.27, + "feels_like": 25.1, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.17, + "uvi": 0.06, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.78, + "wind_deg": 235, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785711600, + "temp": 24.25, + "feels_like": 25.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.18, + "uvi": 0.08, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 233, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785712500, + "temp": 24.5, + "feels_like": 25.35, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.27, + "uvi": 0.14, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 232, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785713400, + "temp": 24.74, + "feels_like": 25.62, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.37, + "uvi": 0.2, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.93, + "wind_deg": 232, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + { + "dt": 1785714300, + "temp": 24.99, + "feels_like": 25.89, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.46, + "uvi": 0.26, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.93, + "wind_deg": 231, + "pop": 1, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=14.5995&lon=120.9842&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/rain.meta.json b/tests/Fixtures/one-call/fifteen-minute/rain.meta.json new file mode 100644 index 0000000..408f5fc --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/rain.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:25:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json new file mode 100644 index 0000000..cb1b8f2 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.json @@ -0,0 +1,1259 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785670200, + "temp": -2.3, + "feels_like": -6.89, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 79, + "wind_speed": 3.72, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671100, + "temp": -2.3, + "feels_like": -6.87, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 102, + "wind_speed": 3.69, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "temp": -2.3, + "feels_like": -6.84, + "pressure": 1012, + "humidity": 100, + "dew_point": -2.3, + "uvi": 0, + "clouds": 100, + "visibility": 126, + "wind_speed": 3.66, + "wind_deg": 305, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672900, + "temp": -2.31, + "feels_like": -6.8, + "pressure": 1012.25, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0.01, + "clouds": 100, + "visibility": 94, + "wind_speed": 3.59, + "wind_deg": 304, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673800, + "temp": -2.32, + "feels_like": -6.76, + "pressure": 1012.5, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.02, + "clouds": 100, + "visibility": 63, + "wind_speed": 3.53, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785674700, + "temp": -2.34, + "feels_like": -6.72, + "pressure": 1012.75, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.03, + "clouds": 100, + "visibility": 31, + "wind_speed": 3.46, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785675600, + "temp": -2.35, + "feels_like": -6.68, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.35, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785676500, + "temp": -2.34, + "feels_like": -6.64, + "pressure": 1013.25, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.06, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.36, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785677400, + "temp": -2.33, + "feels_like": -6.6, + "pressure": 1013.5, + "humidity": 100, + "dew_point": -2.33, + "uvi": 0.08, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.33, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785678300, + "temp": -2.32, + "feels_like": -6.56, + "pressure": 1013.75, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.1, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.3, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785679200, + "temp": -2.31, + "feels_like": -6.52, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.31, + "uvi": 0.12, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.27, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785680100, + "temp": -2.32, + "feels_like": -6.51, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.16, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.24, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785681000, + "temp": -2.34, + "feels_like": -6.51, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.34, + "uvi": 0.19, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.22, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785681900, + "temp": -2.36, + "feels_like": -6.5, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.36, + "uvi": 0.23, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.19, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785682800, + "temp": -2.37, + "feels_like": -6.5, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.37, + "uvi": 0.26, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.16, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785683700, + "temp": -2.35, + "feels_like": -6.48, + "pressure": 1014.25, + "humidity": 100, + "dew_point": -1.8, + "uvi": 0.29, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.17, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785684600, + "temp": -2.33, + "feels_like": -6.46, + "pressure": 1014.5, + "humidity": 100, + "dew_point": -1.23, + "uvi": 0.32, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.17, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785685500, + "temp": -2.31, + "feels_like": -6.44, + "pressure": 1014.75, + "humidity": 100, + "dew_point": -0.65, + "uvi": 0.34, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.18, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785686400, + "temp": -2.29, + "feels_like": -6.42, + "pressure": 1015, + "humidity": 100, + "dew_point": -0.08, + "uvi": 0.37, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.18, + "wind_deg": 299, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785687300, + "temp": -2.23, + "feels_like": -6.4, + "pressure": 1015, + "humidity": 100, + "dew_point": -0.02, + "uvi": 0.39, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.23, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785688200, + "temp": -2.18, + "feels_like": -6.38, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.04, + "uvi": 0.4, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.29, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785689100, + "temp": -2.13, + "feels_like": -6.36, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.09, + "uvi": 0.42, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.34, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690000, + "temp": -2.07, + "feels_like": -6.34, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.15, + "uvi": 0.43, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 303, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690900, + "temp": -2.03, + "feels_like": -6.31, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.21, + "uvi": 0.39, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.42, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785691800, + "temp": -1.99, + "feels_like": -6.28, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.27, + "uvi": 0.34, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.44, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785692700, + "temp": -1.94, + "feels_like": -6.25, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.3, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.47, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785693600, + "temp": -1.9, + "feels_like": -6.22, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.25, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.49, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785694500, + "temp": -1.84, + "feels_like": -6.1, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.43, + "uvi": 0.22, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.44, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785695400, + "temp": -1.77, + "feels_like": -5.98, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.48, + "uvi": 0.18, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.39, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785696300, + "temp": -1.71, + "feels_like": -5.86, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.52, + "uvi": 0.14, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.33, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785697200, + "temp": -1.65, + "feels_like": -5.74, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.56, + "uvi": 0.11, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.28, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785698100, + "temp": -1.71, + "feels_like": -5.68, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.5, + "uvi": 0.1, + "clouds": 100, + "visibility": 0, + "wind_speed": 3.14, + "wind_deg": 300, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785699000, + "temp": -1.77, + "feels_like": -5.61, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.45, + "uvi": 0.08, + "clouds": 100, + "visibility": 0, + "wind_speed": 3, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785699900, + "temp": -1.83, + "feels_like": -5.55, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.07, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.86, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785700800, + "temp": -1.89, + "feels_like": -5.49, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.05, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.72, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785701700, + "temp": -1.92, + "feels_like": -5.52, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.28, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.71, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785702600, + "temp": -1.96, + "feels_like": -5.56, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.23, + "uvi": 0.04, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.71, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785703500, + "temp": -2, + "feels_like": -5.59, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.18, + "uvi": 0.03, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.7, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785704400, + "temp": -2.03, + "feels_like": -5.63, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.13, + "uvi": 0.02, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.69, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785705300, + "temp": -2.08, + "feels_like": -5.63, + "pressure": 1015.25, + "humidity": 100, + "dew_point": 0.11, + "uvi": 0.02, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.63, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785706200, + "temp": -2.14, + "feels_like": -5.63, + "pressure": 1015.5, + "humidity": 100, + "dew_point": 0.1, + "uvi": 0.01, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.57, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785707100, + "temp": -2.19, + "feels_like": -5.62, + "pressure": 1015.75, + "humidity": 100, + "dew_point": 0.08, + "uvi": 0.01, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.5, + "wind_deg": 301, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708000, + "temp": -2.25, + "feels_like": -5.62, + "pressure": 1016, + "humidity": 100, + "dew_point": 0.06, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.44, + "wind_deg": 302, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708900, + "temp": -2.26, + "feels_like": -5.57, + "pressure": 1016.25, + "humidity": 100, + "dew_point": 0.04, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.39, + "wind_deg": 294, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785709800, + "temp": -2.26, + "feels_like": -5.51, + "pressure": 1016.5, + "humidity": 100, + "dew_point": 0.01, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.34, + "wind_deg": 287, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785710700, + "temp": -2.27, + "feels_like": -5.46, + "pressure": 1016.75, + "humidity": 100, + "dew_point": -0.01, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.28, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785711600, + "temp": -2.28, + "feels_like": -5.41, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.04, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.23, + "wind_deg": 272, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785712500, + "temp": -2.29, + "feels_like": -5.4, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.05, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.22, + "wind_deg": 279, + "pop": 1, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785713400, + "temp": -2.29, + "feels_like": -5.4, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.06, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.21, + "wind_deg": 286, + "pop": 1, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785714300, + "temp": -2.3, + "feels_like": -5.39, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.08, + "uvi": 0, + "clouds": 100, + "visibility": 0, + "wind_speed": 2.2, + "wind_deg": 293, + "pop": 1, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=-38.4000&lon=-71.5800&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json new file mode 100644 index 0000000..becea9b --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/snow-alerts.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:24:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/fifteen-minute/success.json b/tests/Fixtures/one-call/fifteen-minute/success.json new file mode 100644 index 0000000..bea4e05 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/success.json @@ -0,0 +1,1109 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785670200, + "temp": 25.19, + "feels_like": 25.49, + "pressure": 1016, + "humidity": 66, + "dew_point": 18.37, + "uvi": 8.05, + "clouds": 41, + "visibility": 10000, + "wind_speed": 3.92, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785671100, + "temp": 25.25, + "feels_like": 25.51, + "pressure": 1016, + "humidity": 64, + "dew_point": 18.06, + "uvi": 8.31, + "clouds": 41, + "visibility": 10000, + "wind_speed": 3.94, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785672000, + "temp": 25.3, + "feels_like": 25.53, + "pressure": 1016, + "humidity": 63, + "dew_point": 17.75, + "uvi": 8.57, + "clouds": 42, + "visibility": 10000, + "wind_speed": 3.96, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785672900, + "temp": 25.47, + "feels_like": 25.64, + "pressure": 1016, + "humidity": 61, + "dew_point": 17.51, + "uvi": 8.5, + "clouds": 47, + "visibility": 10000, + "wind_speed": 4.24, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ] + }, + { + "dt": 1785673800, + "temp": 25.63, + "feels_like": 25.75, + "pressure": 1016, + "humidity": 60, + "dew_point": 17.27, + "uvi": 8.43, + "clouds": 53, + "visibility": 10000, + "wind_speed": 4.52, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785674700, + "temp": 25.8, + "feels_like": 25.85, + "pressure": 1016, + "humidity": 58, + "dew_point": 17.03, + "uvi": 8.35, + "clouds": 58, + "visibility": 10000, + "wind_speed": 4.79, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785675600, + "temp": 25.96, + "feels_like": 25.96, + "pressure": 1016, + "humidity": 57, + "dew_point": 16.79, + "uvi": 8.28, + "clouds": 64, + "visibility": 10000, + "wind_speed": 5.07, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785676500, + "temp": 26.06, + "feels_like": 26.06, + "pressure": 1015.75, + "humidity": 55, + "dew_point": 16.45, + "uvi": 8.06, + "clouds": 67, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785677400, + "temp": 26.16, + "feels_like": 26.16, + "pressure": 1015.5, + "humidity": 54, + "dew_point": 16.1, + "uvi": 7.85, + "clouds": 70, + "visibility": 10000, + "wind_speed": 5.35, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785678300, + "temp": 26.25, + "feels_like": 26.25, + "pressure": 1015.25, + "humidity": 52, + "dew_point": 15.75, + "uvi": 7.63, + "clouds": 73, + "visibility": 10000, + "wind_speed": 5.48, + "wind_deg": 306, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785679200, + "temp": 26.35, + "feels_like": 26.35, + "pressure": 1015, + "humidity": 51, + "dew_point": 15.41, + "uvi": 7.41, + "clouds": 76, + "visibility": 10000, + "wind_speed": 5.62, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785680100, + "temp": 26.31, + "feels_like": 26.31, + "pressure": 1014.75, + "humidity": 49, + "dew_point": 14.89, + "uvi": 6.91, + "clouds": 79, + "visibility": 10000, + "wind_speed": 5.65, + "wind_deg": 307, + "pop": 0, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785681000, + "temp": 26.27, + "feels_like": 26.27, + "pressure": 1014.5, + "humidity": 48, + "dew_point": 14.37, + "uvi": 6.41, + "clouds": 82, + "visibility": 10000, + "wind_speed": 5.68, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785681900, + "temp": 26.23, + "feels_like": 26.23, + "pressure": 1014.25, + "humidity": 46, + "dew_point": 13.85, + "uvi": 5.9, + "clouds": 85, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785682800, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1014, + "humidity": 45, + "dew_point": 13.33, + "uvi": 5.4, + "clouds": 88, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785683700, + "temp": 26.13, + "feels_like": 26.13, + "pressure": 1014, + "humidity": 44, + "dew_point": 13.02, + "uvi": 4.88, + "clouds": 91, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785684600, + "temp": 26.08, + "feels_like": 26.08, + "pressure": 1014, + "humidity": 43, + "dew_point": 12.71, + "uvi": 4.36, + "clouds": 94, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785685500, + "temp": 26.03, + "feels_like": 26.03, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.4, + "uvi": 3.83, + "clouds": 97, + "visibility": 10000, + "wind_speed": 5.72, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785686400, + "temp": 25.97, + "feels_like": 25.97, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.09, + "uvi": 3.31, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785687300, + "temp": 25.61, + "feels_like": 25.53, + "pressure": 1014, + "humidity": 42, + "dew_point": 12.22, + "uvi": 2.87, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.68, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785688200, + "temp": 25.25, + "feels_like": 25.09, + "pressure": 1014, + "humidity": 43, + "dew_point": 12.34, + "uvi": 2.44, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.65, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785689100, + "temp": 24.88, + "feels_like": 24.64, + "pressure": 1014, + "humidity": 44, + "dew_point": 12.47, + "uvi": 2, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.61, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785690000, + "temp": 24.52, + "feels_like": 24.2, + "pressure": 1014, + "humidity": 45, + "dew_point": 12.59, + "uvi": 1.56, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.58, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785690900, + "temp": 24.41, + "feels_like": 24.1, + "pressure": 1014, + "humidity": 46, + "dew_point": 12.72, + "uvi": 1.31, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.49, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785691800, + "temp": 24.3, + "feels_like": 24, + "pressure": 1014, + "humidity": 47, + "dew_point": 12.85, + "uvi": 1.05, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.4, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785692700, + "temp": 24.18, + "feels_like": 23.91, + "pressure": 1014, + "humidity": 48, + "dew_point": 12.98, + "uvi": 0.8, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.3, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785693600, + "temp": 24.07, + "feels_like": 23.81, + "pressure": 1014, + "humidity": 49, + "dew_point": 13.11, + "uvi": 0.54, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785694500, + "temp": 23.85, + "feels_like": 23.62, + "pressure": 1014, + "humidity": 51, + "dew_point": 13.33, + "uvi": 0.44, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.1, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785695400, + "temp": 23.64, + "feels_like": 23.43, + "pressure": 1014, + "humidity": 53, + "dew_point": 13.55, + "uvi": 0.33, + "clouds": 99, + "visibility": 10000, + "wind_speed": 4.98, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785696300, + "temp": 23.42, + "feels_like": 23.25, + "pressure": 1014, + "humidity": 55, + "dew_point": 13.76, + "uvi": 0.22, + "clouds": 99, + "visibility": 10000, + "wind_speed": 4.87, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785697200, + "temp": 23.2, + "feels_like": 23.06, + "pressure": 1014, + "humidity": 57, + "dew_point": 13.98, + "uvi": 0.12, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.75, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785698100, + "temp": 23.04, + "feels_like": 22.93, + "pressure": 1014, + "humidity": 59, + "dew_point": 14.17, + "uvi": 0.09, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.58, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ] + }, + { + "dt": 1785699000, + "temp": 22.87, + "feels_like": 22.81, + "pressure": 1014, + "humidity": 61, + "dew_point": 14.36, + "uvi": 0.06, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.41, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785699900, + "temp": 22.71, + "feels_like": 22.68, + "pressure": 1014, + "humidity": 63, + "dew_point": 14.54, + "uvi": 0.03, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.24, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785700800, + "temp": 22.54, + "feels_like": 22.55, + "pressure": 1014, + "humidity": 65, + "dew_point": 14.73, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 319, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785701700, + "temp": 22.5, + "feels_like": 22.53, + "pressure": 1014.25, + "humidity": 66, + "dew_point": 14.89, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 318, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785702600, + "temp": 22.45, + "feels_like": 22.51, + "pressure": 1014.5, + "humidity": 67, + "dew_point": 15.05, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.06, + "wind_deg": 317, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785703500, + "temp": 22.41, + "feels_like": 22.5, + "pressure": 1014.75, + "humidity": 68, + "dew_point": 15.2, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.06, + "wind_deg": 316, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785704400, + "temp": 22.36, + "feels_like": 22.48, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.36, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.05, + "wind_deg": 315, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785705300, + "temp": 22.37, + "feels_like": 22.49, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.41, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.91, + "wind_deg": 314, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785706200, + "temp": 22.37, + "feels_like": 22.5, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.46, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.76, + "wind_deg": 313, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785707100, + "temp": 22.37, + "feels_like": 22.52, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.5, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.62, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785708000, + "temp": 22.38, + "feels_like": 22.53, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.48, + "wind_deg": 312, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785708900, + "temp": 22.33, + "feels_like": 22.49, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.62, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.5, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785709800, + "temp": 22.28, + "feels_like": 22.45, + "pressure": 1015, + "humidity": 72, + "dew_point": 15.68, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.51, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785710700, + "temp": 22.23, + "feels_like": 22.4, + "pressure": 1015, + "humidity": 72, + "dew_point": 15.75, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.53, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785711600, + "temp": 22.18, + "feels_like": 22.36, + "pressure": 1015, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.54, + "wind_deg": 311, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785712500, + "temp": 22.03, + "feels_like": 22.19, + "pressure": 1014.75, + "humidity": 73, + "dew_point": 15.8, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.37, + "wind_deg": 310, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785713400, + "temp": 21.87, + "feels_like": 22.02, + "pressure": 1014.5, + "humidity": 73, + "dew_point": 15.8, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.19, + "wind_deg": 309, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + }, + { + "dt": 1785714300, + "temp": 21.72, + "feels_like": 21.86, + "pressure": 1014.25, + "humidity": 73, + "dew_point": 15.79, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.01, + "wind_deg": 308, + "pop": 0, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ] + } + ], + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/15min?cnt=50&lat=38.7223&lon=-9.1393&start=1785715200&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/fifteen-minute/success.meta.json b/tests/Fixtures/one-call/fifteen-minute/success.meta.json new file mode 100644 index 0000000..a6e3667 --- /dev/null +++ b/tests/Fixtures/one-call/fifteen-minute/success.meta.json @@ -0,0 +1,24 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "15-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:24:40Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/15min", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/history.json b/tests/Fixtures/one-call/one-day/history.json new file mode 100644 index 0000000..5b400ea --- /dev/null +++ b/tests/Fixtures/one-call/one-day/history.json @@ -0,0 +1,379 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785456000, + "sunrise": 1785476207, + "sunset": 1785527338, + "moonrise": 1785531300, + "moonset": 1785482220, + "moon_phase": 0.56, + "temp": { + "day": 24.68, + "min": 18.34, + "max": 27.68, + "night": 19.91, + "eve": 26.98, + "morn": 18.38 + }, + "feels_like": { + "day": 24.68, + "night": 19.91, + "eve": 26.98, + "morn": 18.38 + }, + "pressure": 1019, + "humidity": 69, + "wind_speed": 4.92, + "wind_deg": 5, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "uvi": 0 + }, + { + "dt": 1785542400, + "sunrise": 1785562660, + "sunset": 1785613679, + "moonrise": 1785619080, + "moonset": 1785572460, + "moon_phase": 0.59, + "temp": { + "day": 26.04, + "min": 18.96, + "max": 30.07, + "night": 20.82, + "eve": 27.39, + "morn": 18.97 + }, + "feels_like": { + "day": 26.04, + "night": 20.82, + "eve": 27.39, + "morn": 18.97 + }, + "pressure": 1015, + "humidity": 70, + "wind_speed": 6.26, + "wind_deg": 317, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 38, + "uvi": 0 + }, + { + "dt": 1785628800, + "sunrise": 1785649113, + "sunset": 1785700020, + "moonrise": 1785706800, + "moonset": 1785662640, + "moon_phase": 0.62, + "temp": { + "day": 25.53, + "min": 18.73, + "max": 26.99, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "feels_like": { + "day": 25.53, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "pressure": 1015.44, + "humidity": 48, + "wind_speed": 6.17, + "wind_deg": 307, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 41, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785735567, + "sunset": 1785786359, + "moonrise": 1785794640, + "moonset": 1785753000, + "moon_phase": 0.66, + "temp": { + "day": 24.47, + "min": 19.72, + "max": 25.34, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "feels_like": { + "day": 24.47, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "pressure": 1015.12, + "humidity": 62, + "wind_speed": 5.87, + "wind_deg": 246, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 22, + "pop": 0, + "rain": 0.09, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785822021, + "sunset": 1785872696, + "moonrise": 1785882660, + "moonset": 1785843420, + "moon_phase": 0.69, + "temp": { + "day": 25.65, + "min": 19.98, + "max": 26.79, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "feels_like": { + "day": 25.65, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "pressure": 1018.73, + "humidity": 52, + "wind_speed": 6.12, + "wind_deg": 287, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785908475, + "sunset": 1785959032, + "moonrise": 0, + "moonset": 1785934020, + "moon_phase": 0.73, + "temp": { + "day": 26.19, + "min": 19.81, + "max": 27.07, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "feels_like": { + "day": 26.19, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "pressure": 1020.18, + "humidity": 51, + "wind_speed": 8.17, + "wind_deg": 341, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785994929, + "sunset": 1786045367, + "moonrise": 1785970920, + "moonset": 1786024800, + "moon_phase": 0.75, + "temp": { + "day": 26.03, + "min": 18.89, + "max": 27.46, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "feels_like": { + "day": 26.03, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "pressure": 1019.85, + "humidity": 42, + "wind_speed": 8.77, + "wind_deg": 340, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786081384, + "sunset": 1786131700, + "moonrise": 1786059600, + "moonset": 1786115640, + "moon_phase": 0.8, + "temp": { + "day": 26.16, + "min": 18.76, + "max": 27.59, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "feels_like": { + "day": 26.16, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "pressure": 1016.4, + "humidity": 44, + "wind_speed": 7.45, + "wind_deg": 331, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786167838, + "sunset": 1786218032, + "moonrise": 1786148880, + "moonset": 1786206360, + "moon_phase": 0.84, + "temp": { + "day": 25.26, + "min": 18.6, + "max": 26.53, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "feels_like": { + "day": 25.26, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "pressure": 1016.06, + "humidity": 48, + "wind_speed": 7.13, + "wind_deg": 327, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786254293, + "sunset": 1786304363, + "moonrise": 1786238880, + "moonset": 1786296480, + "moon_phase": 0.87, + "temp": { + "day": 23.74, + "min": 18.37, + "max": 25.29, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "feels_like": { + "day": 23.74, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "pressure": 1017.37, + "humidity": 56, + "wind_speed": 7.94, + "wind_deg": 339, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1784592000&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1786320000&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/history.meta.json b/tests/Fixtures/one-call/one-day/history.meta.json new file mode 100644 index 0000000..2da7798 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/history.meta.json @@ -0,0 +1,29 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:35:19Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1785456000, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/rain.json b/tests/Fixtures/one-call/one-day/rain.json new file mode 100644 index 0000000..c40d066 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/rain.json @@ -0,0 +1,390 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785620365, + "sunset": 1785666311, + "moonrise": 1785675120, + "moonset": 1785630420, + "moon_phase": 0.61, + "temp": { + "day": 28.37, + "min": 25.42, + "max": 29.32, + "night": 26.91, + "eve": 25.49, + "morn": 26.06 + }, + "feels_like": { + "day": 28.37, + "night": 26.91, + "eve": 25.49, + "morn": 26.06 + }, + "pressure": 1006.71, + "humidity": 79, + "wind_speed": 3.69, + "wind_deg": 359, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 17.92, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785706779, + "sunset": 1785752690, + "moonrise": 1785763800, + "moonset": 1785719880, + "moon_phase": 0.65, + "temp": { + "day": 25.11, + "min": 24.31, + "max": 25.54, + "night": 24.99, + "eve": 24.87, + "morn": 24.75 + }, + "feels_like": { + "day": 25.11, + "night": 24.99, + "eve": 24.87, + "morn": 24.75 + }, + "pressure": 1007.73, + "humidity": 90, + "wind_speed": 8.27, + "wind_deg": 242, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 30.77, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785793194, + "sunset": 1785839067, + "moonrise": 1785852540, + "moonset": 1785809340, + "moon_phase": 0.68, + "temp": { + "day": 27.95, + "min": 25.25, + "max": 28.02, + "night": 25.31, + "eve": 26.29, + "morn": 25.98 + }, + "feels_like": { + "day": 27.95, + "night": 25.31, + "eve": 26.29, + "morn": 25.98 + }, + "pressure": 1007.03, + "humidity": 74, + "wind_speed": 10.11, + "wind_deg": 248, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 0.96, + "rain": 11.54, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785879608, + "sunset": 1785925444, + "moonrise": 1785941520, + "moonset": 1785899040, + "moon_phase": 0.72, + "temp": { + "day": 27.57, + "min": 25.75, + "max": 27.57, + "night": 26.81, + "eve": 26.76, + "morn": 25.75 + }, + "feels_like": { + "day": 27.57, + "night": 26.81, + "eve": 26.76, + "morn": 25.75 + }, + "pressure": 1005.81, + "humidity": 82, + "wind_speed": 9.36, + "wind_deg": 244, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 25.92, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785966021, + "sunset": 1786011820, + "moonrise": 1786030860, + "moonset": 1785988920, + "moon_phase": 0.75, + "temp": { + "day": 27.53, + "min": 25.75, + "max": 28.08, + "night": 26.9, + "eve": 26.4, + "morn": 26.75 + }, + "feels_like": { + "day": 27.53, + "night": 26.9, + "eve": 26.4, + "morn": 26.75 + }, + "pressure": 1003.96, + "humidity": 86, + "wind_speed": 8.72, + "wind_deg": 244, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 57.96, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786052434, + "sunset": 1786098195, + "moonrise": 0, + "moonset": 1786079040, + "moon_phase": 0.79, + "temp": { + "day": 29.02, + "min": 25.97, + "max": 29.07, + "night": 26.15, + "eve": 27.38, + "morn": 26.45 + }, + "feels_like": { + "day": 29.02, + "night": 26.15, + "eve": 27.38, + "morn": 26.45 + }, + "pressure": 1003.18, + "humidity": 80, + "wind_speed": 9.03, + "wind_deg": 254, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 78, + "pop": 0.95, + "rain": 95.22, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786138847, + "sunset": 1786184569, + "moonrise": 1786120500, + "moonset": 1786169340, + "moon_phase": 0.83, + "temp": { + "day": 28.27, + "min": 26.62, + "max": 28.59, + "night": 26.68, + "eve": 27.29, + "morn": 27.41 + }, + "feels_like": { + "day": 28.27, + "night": 26.68, + "eve": 27.29, + "morn": 27.41 + }, + "pressure": 1004.12, + "humidity": 83, + "wind_speed": 10.8, + "wind_deg": 254, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 141.21, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786225260, + "sunset": 1786270942, + "moonrise": 1786210620, + "moonset": 1786259700, + "moon_phase": 0.86, + "temp": { + "day": 27.18, + "min": 27.02, + "max": 27.52, + "night": 27.07, + "eve": 27.19, + "morn": 27.27 + }, + "feels_like": { + "day": 27.18, + "night": 27.07, + "eve": 27.19, + "morn": 27.27 + }, + "pressure": 1002.91, + "humidity": 89, + "wind_speed": 11.31, + "wind_deg": 244, + "weather": [ + { + "id": 503, + "main": "Rain", + "description": "very heavy rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 269.27, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786311672, + "sunset": 1786357315, + "moonrise": 1786300920, + "moonset": 1786349820, + "moon_phase": 0.9, + "temp": { + "day": 27.97, + "min": 27.41, + "max": 28.57, + "night": 27.46, + "eve": 27.54, + "morn": 27.53 + }, + "feels_like": { + "day": 27.97, + "night": 27.46, + "eve": 27.54, + "morn": 27.53 + }, + "pressure": 1001.69, + "humidity": 86, + "wind_speed": 11.38, + "wind_deg": 235, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 100, + "pop": 1, + "rain": 149.4, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786398084, + "sunset": 1786443686, + "moonrise": 1786391400, + "moonset": 1786439640, + "moon_phase": 0.94, + "temp": { + "day": 28.5, + "min": 27.17, + "max": 28.58, + "night": 27.33, + "eve": 27.56, + "morn": 27.37 + }, + "feels_like": { + "day": 28.5, + "night": 27.33, + "eve": 27.56, + "morn": 27.37 + }, + "pressure": 1001.12, + "humidity": 85, + "wind_speed": 11.08, + "wind_deg": 239, + "weather": [ + { + "id": 502, + "main": "Rain", + "description": "heavy intensity rain", + "icon": "10d" + } + ], + "clouds": 99, + "pop": 1, + "rain": 130.08, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=14.5995&lon=120.9842&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=14.5995&lon=120.9842&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/rain.meta.json b/tests/Fixtures/one-call/one-day/rain.meta.json new file mode 100644 index 0000000..78e358e --- /dev/null +++ b/tests/Fixtures/one-call/one-day/rain.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:34:55Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/snow.json b/tests/Fixtures/one-call/one-day/snow.json new file mode 100644 index 0000000..4f88ba3 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/snow.json @@ -0,0 +1,390 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785671200, + "sunset": 1785707904, + "moonrise": 1785633300, + "moonset": 1785590340, + "moon_phase": 0.6, + "temp": { + "day": -2.28, + "min": -2.9, + "max": -1.84, + "night": -2.51, + "eve": -2.35, + "morn": -2.17 + }, + "feels_like": { + "day": -2.28, + "night": -2.51, + "eve": -2.35, + "morn": -2.17 + }, + "pressure": 1015.3, + "humidity": 99, + "wind_speed": 3.91, + "wind_deg": 316, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 60.98, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785757540, + "sunset": 1785794355, + "moonrise": 1785723540, + "moonset": 1785678120, + "moon_phase": 0.63, + "temp": { + "day": -2.09, + "min": -3.01, + "max": -0.99, + "night": -2.93, + "eve": -2.13, + "morn": -2.86 + }, + "feels_like": { + "day": -2.09, + "night": -2.93, + "eve": -2.13, + "morn": -2.86 + }, + "pressure": 1022.42, + "humidity": 99, + "wind_speed": 1.96, + "wind_deg": 305, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 22.08, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785843880, + "sunset": 1785880807, + "moonrise": 1785813960, + "moonset": 1785765900, + "moon_phase": 0.67, + "temp": { + "day": -0.5, + "min": -2.78, + "max": 0.14, + "night": -2.06, + "eve": -1.52, + "morn": -1.68 + }, + "feels_like": { + "day": -0.5, + "night": -2.06, + "eve": -1.52, + "morn": -1.68 + }, + "pressure": 1020.64, + "humidity": 96, + "wind_speed": 2, + "wind_deg": 283, + "weather": [ + { + "id": 600, + "main": "Snow", + "description": "light snow", + "icon": "13d" + } + ], + "clouds": 99, + "pop": 0.36, + "snow": 1.01, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785930218, + "sunset": 1785967259, + "moonrise": 0, + "moonset": 1785853800, + "moon_phase": 0.7, + "temp": { + "day": 0.01, + "min": -2.87, + "max": 0.52, + "night": -2.66, + "eve": -1.02, + "morn": -2.23 + }, + "feels_like": { + "day": 0.01, + "night": -2.66, + "eve": -1.02, + "morn": -2.23 + }, + "pressure": 1014.13, + "humidity": 95, + "wind_speed": 2.1, + "wind_deg": 337, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 0.72, + "snow": 4.9, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1786016554, + "sunset": 1786053711, + "moonrise": 1785904440, + "moonset": 1785942000, + "moon_phase": 0.75, + "temp": { + "day": -3.34, + "min": -5.41, + "max": -0.38, + "night": -0.78, + "eve": -4.84, + "morn": -2.7 + }, + "feels_like": { + "day": -3.34, + "night": -0.78, + "eve": -4.84, + "morn": -2.7 + }, + "pressure": 1009.7, + "humidity": 98, + "wind_speed": 5.53, + "wind_deg": 280, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 73.78, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786102890, + "sunset": 1786140164, + "moonrise": 1785995220, + "moonset": 1786030560, + "moon_phase": 0.77, + "temp": { + "day": -6, + "min": -7.83, + "max": -4.7, + "night": -4.87, + "eve": -7.47, + "morn": -4.82 + }, + "feels_like": { + "day": -6, + "night": -4.87, + "eve": -7.47, + "morn": -4.82 + }, + "pressure": 1013.16, + "humidity": 97, + "wind_speed": 7.06, + "wind_deg": 265, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 257.03, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786189224, + "sunset": 1786226616, + "moonrise": 1786086060, + "moonset": 1786119600, + "moon_phase": 0.81, + "temp": { + "day": -5.83, + "min": -7.18, + "max": -3.82, + "night": -7.18, + "eve": -4.63, + "morn": -6.95 + }, + "feels_like": { + "day": -5.83, + "night": -7.18, + "eve": -4.63, + "morn": -6.95 + }, + "pressure": 1021.05, + "humidity": 98, + "wind_speed": 5.9, + "wind_deg": 290, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 237.96, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786275556, + "sunset": 1786313069, + "moonrise": 1786176780, + "moonset": 1786209420, + "moon_phase": 0.84, + "temp": { + "day": -4.05, + "min": -4.67, + "max": -3.24, + "night": -3.92, + "eve": -3.28, + "morn": -4.55 + }, + "feels_like": { + "day": -4.05, + "night": -3.92, + "eve": -3.28, + "morn": -4.55 + }, + "pressure": 1021.17, + "humidity": 99, + "wind_speed": 4.62, + "wind_deg": 292, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 271.81, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786361888, + "sunset": 1786399521, + "moonrise": 1786267140, + "moonset": 1786299840, + "moon_phase": 0.88, + "temp": { + "day": -3.12, + "min": -7.68, + "max": -3.07, + "night": -3.43, + "eve": -4.67, + "morn": -3.66 + }, + "feels_like": { + "day": -3.12, + "night": -3.43, + "eve": -4.67, + "morn": -3.66 + }, + "pressure": 1025.25, + "humidity": 99, + "wind_speed": 2.64, + "wind_deg": 286, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 0.97, + "snow": 50.39, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786448218, + "sunset": 1786485974, + "moonrise": 1786356900, + "moonset": 1786390800, + "moon_phase": 0.92, + "temp": { + "day": -3.15, + "min": -7.31, + "max": -3.14, + "night": -7.31, + "eve": -3.42, + "morn": -5.8 + }, + "feels_like": { + "day": -3.15, + "night": -7.31, + "eve": -3.42, + "morn": -5.8 + }, + "pressure": 1021.44, + "humidity": 99, + "wind_speed": 1.67, + "wind_deg": 335, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13d" + } + ], + "clouds": 100, + "pop": 1, + "snow": 55.84, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=-38.4000&lon=-71.5800&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=-38.4000&lon=-71.5800&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/snow.meta.json b/tests/Fixtures/one-call/one-day/snow.meta.json new file mode 100644 index 0000000..417f39f --- /dev/null +++ b/tests/Fixtures/one-call/one-day/snow.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:35:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-day/success.json b/tests/Fixtures/one-call/one-day/success.json new file mode 100644 index 0000000..4859af5 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/success.json @@ -0,0 +1,381 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785628800, + "sunrise": 1785649113, + "sunset": 1785700020, + "moonrise": 1785706800, + "moonset": 1785662640, + "moon_phase": 0.62, + "temp": { + "day": 25.53, + "min": 18.73, + "max": 26.99, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "feels_like": { + "day": 25.53, + "night": 19.4, + "eve": 24.82, + "morn": 18.74 + }, + "pressure": 1015.44, + "humidity": 48, + "wind_speed": 6.17, + "wind_deg": 307, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 41, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785715200, + "sunrise": 1785735567, + "sunset": 1785786359, + "moonrise": 1785794640, + "moonset": 1785753000, + "moon_phase": 0.66, + "temp": { + "day": 24.47, + "min": 19.72, + "max": 25.34, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "feels_like": { + "day": 24.47, + "night": 20.48, + "eve": 24.34, + "morn": 19.72 + }, + "pressure": 1015.12, + "humidity": 62, + "wind_speed": 5.87, + "wind_deg": 246, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 22, + "pop": 0, + "rain": 0.09, + "uvi": 0 + }, + { + "dt": 1785801600, + "sunrise": 1785822021, + "sunset": 1785872696, + "moonrise": 1785882660, + "moonset": 1785843420, + "moon_phase": 0.69, + "temp": { + "day": 25.65, + "min": 19.98, + "max": 26.79, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "feels_like": { + "day": 25.65, + "night": 20.69, + "eve": 25.3, + "morn": 19.98 + }, + "pressure": 1018.73, + "humidity": 52, + "wind_speed": 6.12, + "wind_deg": 287, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785888000, + "sunrise": 1785908475, + "sunset": 1785959032, + "moonrise": 0, + "moonset": 1785934020, + "moon_phase": 0.73, + "temp": { + "day": 26.19, + "min": 19.81, + "max": 27.07, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "feels_like": { + "day": 26.19, + "night": 20.81, + "eve": 25.09, + "morn": 19.95 + }, + "pressure": 1020.18, + "humidity": 51, + "wind_speed": 8.17, + "wind_deg": 341, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 1, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1785974400, + "sunrise": 1785994929, + "sunset": 1786045367, + "moonrise": 1785970920, + "moonset": 1786024800, + "moon_phase": 0.75, + "temp": { + "day": 26.03, + "min": 18.89, + "max": 27.46, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "feels_like": { + "day": 26.03, + "night": 19.54, + "eve": 25.3, + "morn": 18.97 + }, + "pressure": 1019.85, + "humidity": 42, + "wind_speed": 8.77, + "wind_deg": 340, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786060800, + "sunrise": 1786081384, + "sunset": 1786131700, + "moonrise": 1786059600, + "moonset": 1786115640, + "moon_phase": 0.8, + "temp": { + "day": 26.16, + "min": 18.76, + "max": 27.59, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "feels_like": { + "day": 26.16, + "night": 19.59, + "eve": 25.13, + "morn": 18.81 + }, + "pressure": 1016.4, + "humidity": 44, + "wind_speed": 7.45, + "wind_deg": 331, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786147200, + "sunrise": 1786167838, + "sunset": 1786218032, + "moonrise": 1786148880, + "moonset": 1786206360, + "moon_phase": 0.84, + "temp": { + "day": 25.26, + "min": 18.6, + "max": 26.53, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "feels_like": { + "day": 25.26, + "night": 19.45, + "eve": 24.46, + "morn": 18.66 + }, + "pressure": 1016.06, + "humidity": 48, + "wind_speed": 7.13, + "wind_deg": 327, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786233600, + "sunrise": 1786254293, + "sunset": 1786304363, + "moonrise": 1786238880, + "moonset": 1786296480, + "moon_phase": 0.87, + "temp": { + "day": 23.74, + "min": 18.37, + "max": 25.29, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "feels_like": { + "day": 23.74, + "night": 19.5, + "eve": 23.55, + "morn": 18.46 + }, + "pressure": 1017.37, + "humidity": 56, + "wind_speed": 7.94, + "wind_deg": 339, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786320000, + "sunrise": 1786340747, + "sunset": 1786390693, + "moonrise": 1786329540, + "moonset": 1786386060, + "moon_phase": 0.91, + "temp": { + "day": 24.6, + "min": 17.73, + "max": 26.46, + "night": 18.78, + "eve": 24.15, + "morn": 17.82 + }, + "feels_like": { + "day": 24.6, + "night": 18.78, + "eve": 24.15, + "morn": 17.82 + }, + "pressure": 1016.51, + "humidity": 49, + "wind_speed": 7.39, + "wind_deg": 322, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 0 + }, + { + "dt": 1786406400, + "sunrise": 1786427202, + "sunset": 1786477021, + "moonrise": 1786420560, + "moonset": 1786474920, + "moon_phase": 0.95, + "temp": { + "day": 24.33, + "min": 18.08, + "max": 27.43, + "night": 19.15, + "eve": 25.5, + "morn": 18.24 + }, + "feels_like": { + "day": 24.33, + "night": 19.15, + "eve": 25.5, + "morn": 18.24 + }, + "pressure": 1016.12, + "humidity": 55, + "wind_speed": 4.77, + "wind_deg": 242, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "clouds": 32, + "pop": 0, + "uvi": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1784764800&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1day?cnt=10&lat=38.7223&lon=-9.1393&start=1786492800&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-day/success.meta.json b/tests/Fixtures/one-call/one-day/success.meta.json new file mode 100644 index 0000000..ed9b991 --- /dev/null +++ b/tests/Fixtures/one-call/one-day/success.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-day timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:34:29Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1day", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/history.json b/tests/Fixtures/one-call/one-hour/history.json new file mode 100644 index 0000000..5da7b44 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/history.json @@ -0,0 +1,450 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785495600, + "temp": 24.19, + "feels_like": 24.31, + "pressure": 1018, + "humidity": 63, + "dew_point": 16.71, + "uvi": 7.52, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 303, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785499200, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1018, + "humidity": 56, + "dew_point": 16.94, + "uvi": 8.79, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 86, + "wind_gust": 3.58, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785502800, + "temp": 26.97, + "feels_like": 27.51, + "pressure": 1018, + "humidity": 52, + "dew_point": 16.28, + "uvi": 8.82, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 1, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785506400, + "temp": 28.08, + "feels_like": 28.73, + "pressure": 1016, + "humidity": 52, + "dew_point": 17.31, + "uvi": 7.76, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 329, + "wind_gust": 7.6, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785510000, + "temp": 28.21, + "feels_like": 29.2, + "pressure": 1016, + "humidity": 55, + "dew_point": 18.32, + "uvi": 5.84, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 312, + "wind_gust": 6.26, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785513600, + "temp": 27.66, + "feels_like": 28.5, + "pressure": 1016, + "humidity": 55, + "dew_point": 17.81, + "uvi": 3.65, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 344, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785517200, + "temp": 27.03, + "feels_like": 27.98, + "pressure": 1015, + "humidity": 58, + "dew_point": 18.06, + "uvi": 1.83, + "clouds": 5, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 345, + "wind_gust": 7.6, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785520800, + "temp": 25.88, + "feels_like": 26.17, + "pressure": 1015, + "humidity": 63, + "dew_point": 18.3, + "uvi": 0.67, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.47, + "wind_deg": 190, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785524400, + "temp": 24.21, + "feels_like": 24.41, + "pressure": 1015, + "humidity": 66, + "dew_point": 17.46, + "uvi": 0.14, + "clouds": 5, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 190, + "wind_gust": 6.71, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + { + "dt": 1785528000, + "temp": 23.07, + "feels_like": 23.21, + "pressure": 1016, + "humidity": 68, + "dew_point": 16.85, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 4.02, + "wind_deg": 335, + "wind_gust": 7.15, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785531600, + "temp": 21.96, + "feels_like": 22.07, + "pressure": 1016, + "humidity": 71, + "dew_point": 16.47, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 4.92, + "wind_deg": 5, + "wind_gust": 8.05, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785535200, + "temp": 21.44, + "feels_like": 21.55, + "pressure": 1016, + "humidity": 73, + "dew_point": 16.4, + "uvi": 0, + "clouds": 4, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 336, + "wind_gust": 5.81, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785538800, + "temp": 20.86, + "feels_like": 20.99, + "pressure": 1016, + "humidity": 76, + "dew_point": 16.48, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 318, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785542400, + "temp": 20.31, + "feels_like": 20.41, + "pressure": 1016, + "humidity": 77, + "dew_point": 16.15, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.79, + "wind_deg": 334, + "wind_gust": 4.02, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785546000, + "temp": 19.75, + "feels_like": 19.84, + "pressure": 1016, + "humidity": 79, + "dew_point": 16.01, + "uvi": 0, + "clouds": 5, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 5, + "wind_gust": 4.02, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785549600, + "temp": 19.75, + "feels_like": 19.9, + "pressure": 1015, + "humidity": 81, + "dew_point": 16.4, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 1.34, + "wind_deg": 337, + "wind_gust": 4.47, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785553200, + "temp": 19.19, + "feels_like": 19.33, + "pressure": 1015, + "humidity": 83, + "dew_point": 16.23, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 297, + "wind_gust": 5.36, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785556800, + "temp": 19.19, + "feels_like": 19.33, + "pressure": 1015, + "humidity": 83, + "dew_point": 16.23, + "uvi": 0, + "clouds": 2, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 328, + "wind_gust": 4.92, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785560400, + "temp": 18.64, + "feels_like": 18.75, + "pressure": 1015, + "humidity": 84, + "dew_point": 15.88, + "uvi": 0, + "clouds": 0, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 308, + "wind_gust": 4.92, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01n" + } + ] + }, + { + "dt": 1785564000, + "temp": 19.19, + "feels_like": 19.36, + "pressure": 1015, + "humidity": 84, + "dew_point": 16.42, + "uvi": 0, + "clouds": 0, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 322, + "wind_gust": 5.36, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785423600&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785567600&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/history.meta.json b/tests/Fixtures/one-call/one-hour/history.meta.json new file mode 100644 index 0000000..23494b2 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/history.meta.json @@ -0,0 +1,29 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:32Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "start": 1785495600, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/rain.json b/tests/Fixtures/one-call/one-hour/rain.json new file mode 100644 index 0000000..115436b --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/rain.json @@ -0,0 +1,515 @@ +{ + "lat": 14.5995, + "lon": 120.9842, + "timezone": "Asia/Manila", + "timezone_offset": 28800, + "data": [ + { + "dt": 1785668400, + "temp": 26.19, + "feels_like": 26.19, + "pressure": 1007, + "humidity": 93, + "dew_point": 24.97, + "uvi": 0, + "clouds": 100, + "visibility": 7458, + "wind_speed": 1.79, + "wind_deg": 272, + "wind_gust": 2.68, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.72 + } + }, + { + "dt": 1785672000, + "temp": 26.42, + "feels_like": 26.42, + "pressure": 1007, + "humidity": 93, + "dew_point": 25.2, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.16, + "wind_deg": 268, + "wind_gust": 3.24, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785675600, + "temp": 26.11, + "feels_like": 26.11, + "pressure": 1007, + "humidity": 92, + "dew_point": 24.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.49, + "wind_deg": 301, + "wind_gust": 4.7, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785679200, + "temp": 25.77, + "feels_like": 26.73, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.82, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.37, + "wind_deg": 318, + "wind_gust": 3.77, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.78 + }, + { + "dt": 1785682800, + "temp": 25.64, + "feels_like": 26.53, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.31, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.14, + "wind_deg": 332, + "wind_gust": 4.65, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.82 + }, + { + "dt": 1785686400, + "temp": 25.22, + "feels_like": 26.1, + "pressure": 1008, + "humidity": 88, + "dew_point": 23.09, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.82, + "wind_deg": 318, + "wind_gust": 4.91, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.32 + } + }, + { + "dt": 1785690000, + "temp": 23.96, + "feels_like": 24.71, + "pressure": 1007, + "humidity": 88, + "dew_point": 23.07, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.92, + "wind_deg": 288, + "wind_gust": 5.72, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.09 + } + }, + { + "dt": 1785693600, + "temp": 23.82, + "feels_like": 24.53, + "pressure": 1006, + "humidity": 87, + "dew_point": 22.76, + "uvi": 0, + "clouds": 100, + "visibility": 9149, + "wind_speed": 2.88, + "wind_deg": 270, + "wind_gust": 5.81, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.58 + } + }, + { + "dt": 1785697200, + "temp": 23.76, + "feels_like": 24.52, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.63, + "wind_deg": 258, + "wind_gust": 4.7, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 1.99 + } + }, + { + "dt": 1785700800, + "temp": 24.1, + "feels_like": 24.89, + "pressure": 1006, + "humidity": 89, + "dew_point": 22.93, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.13, + "wind_deg": 247, + "wind_gust": 5.13, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10n" + } + ], + "pop": 1, + "rain": { + "1h": 0.57 + } + }, + { + "dt": 1785704400, + "temp": 24.46, + "feels_like": 25.26, + "pressure": 1006, + "humidity": 88, + "dew_point": 23.1, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.04, + "wind_deg": 246, + "wind_gust": 6.52, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0.8 + }, + { + "dt": 1785708000, + "temp": 24.35, + "feels_like": 25.16, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.15, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 242, + "wind_gust": 7.95, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.26 + } + }, + { + "dt": 1785711600, + "temp": 24.25, + "feels_like": 25.08, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.18, + "uvi": 0.08, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.94, + "wind_deg": 233, + "wind_gust": 8.22, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.66 + } + }, + { + "dt": 1785715200, + "temp": 25.23, + "feels_like": 26.16, + "pressure": 1007, + "humidity": 90, + "dew_point": 23.55, + "uvi": 0.32, + "clouds": 100, + "visibility": 10000, + "wind_speed": 6.92, + "wind_deg": 231, + "wind_gust": 8.26, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 0.67 + } + }, + { + "dt": 1785718800, + "temp": 25.52, + "feels_like": 26.45, + "pressure": 1007, + "humidity": 89, + "dew_point": 23.77, + "uvi": 0.7, + "clouds": 100, + "visibility": 9530, + "wind_speed": 5.28, + "wind_deg": 253, + "wind_gust": 6.85, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.57 + } + }, + { + "dt": 1785722400, + "temp": 25.51, + "feels_like": 26.44, + "pressure": 1008, + "humidity": 89, + "dew_point": 23.6, + "uvi": 1.23, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.09, + "wind_deg": 258, + "wind_gust": 7.3, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.74 + } + }, + { + "dt": 1785726000, + "temp": 25.59, + "feels_like": 26.48, + "pressure": 1008, + "humidity": 87, + "dew_point": 23.43, + "uvi": 1.71, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.73, + "wind_deg": 253, + "wind_gust": 7.83, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.6 + } + }, + { + "dt": 1785729600, + "temp": 25.43, + "feels_like": 26.35, + "pressure": 1008, + "humidity": 89, + "dew_point": 23.62, + "uvi": 2.39, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.95, + "wind_deg": 249, + "wind_gust": 7.85, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.12 + } + }, + { + "dt": 1785733200, + "temp": 25.2, + "feels_like": 26.18, + "pressure": 1007, + "humidity": 92, + "dew_point": 23.86, + "uvi": 1.39, + "clouds": 100, + "visibility": 8050, + "wind_speed": 6.88, + "wind_deg": 242, + "wind_gust": 7.87, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 1.28 + } + }, + { + "dt": 1785736800, + "temp": 25.14, + "feels_like": 26.11, + "pressure": 1007, + "humidity": 92, + "dew_point": 23.89, + "uvi": 1.2, + "clouds": 100, + "visibility": 5699, + "wind_speed": 8.25, + "wind_deg": 236, + "wind_gust": 10.35, + "weather": [ + { + "id": 501, + "main": "Rain", + "description": "moderate rain", + "icon": "10d" + } + ], + "pop": 1, + "rain": { + "1h": 2.96 + } + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=14.5995&lon=120.9842&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=14.5995&lon=120.9842&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/rain.meta.json b/tests/Fixtures/one-call/one-hour/rain.meta.json new file mode 100644 index 0000000..7af6593 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/rain.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:15Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 14.5995, + "lon": 120.9842, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/snow-alerts.json b/tests/Fixtures/one-call/one-hour/snow-alerts.json new file mode 100644 index 0000000..262b08a --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/snow-alerts.json @@ -0,0 +1,572 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785668400, + "temp": -2.25, + "feels_like": -6.87, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.25, + "uvi": 0, + "clouds": 100, + "visibility": 33, + "wind_speed": 3.77, + "wind_deg": 306, + "wind_gust": 14.85, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.54 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "temp": -2.24, + "feels_like": -6.77, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.24, + "uvi": 0, + "clouds": 100, + "visibility": 126, + "wind_speed": 3.66, + "wind_deg": 305, + "wind_gust": 13.45, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 3.65 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785675600, + "temp": -2.27, + "feels_like": -6.58, + "pressure": 1013, + "humidity": 100, + "dew_point": -2.27, + "uvi": 0.04, + "clouds": 100, + "wind_speed": 3.39, + "wind_deg": 301, + "wind_gust": 13.12, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.57 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785679200, + "temp": -2.27, + "feels_like": -6.48, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.27, + "uvi": 0.12, + "clouds": 100, + "wind_speed": 3.27, + "wind_deg": 301, + "wind_gust": 12.86, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.75 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785682800, + "temp": -2.32, + "feels_like": -6.44, + "pressure": 1014, + "humidity": 100, + "dew_point": -2.32, + "uvi": 0.26, + "clouds": 100, + "wind_speed": 3.16, + "wind_deg": 300, + "wind_gust": 12.96, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.5 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785686400, + "temp": -2.28, + "feels_like": -6.41, + "pressure": 1015, + "humidity": 100, + "dew_point": -2.28, + "uvi": 0.37, + "clouds": 100, + "wind_speed": 3.18, + "wind_deg": 299, + "wind_gust": 13.04, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.45 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785690000, + "temp": -2.07, + "feels_like": -6.34, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.15, + "uvi": 0.43, + "clouds": 100, + "wind_speed": 3.39, + "wind_deg": 303, + "wind_gust": 13.5, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.22 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785693600, + "temp": -1.9, + "feels_like": -6.22, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.39, + "uvi": 0.25, + "clouds": 100, + "wind_speed": 3.49, + "wind_deg": 302, + "wind_gust": 14.65, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.21 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785697200, + "temp": -1.65, + "feels_like": -5.74, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.56, + "uvi": 0.11, + "clouds": 100, + "wind_speed": 3.28, + "wind_deg": 300, + "wind_gust": 14.22, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 2.48 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785700800, + "temp": -1.89, + "feels_like": -5.49, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.33, + "uvi": 0.05, + "clouds": 100, + "wind_speed": 2.72, + "wind_deg": 302, + "wind_gust": 9.86, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 3.41 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785704400, + "temp": -2.03, + "feels_like": -5.63, + "pressure": 1015, + "humidity": 100, + "dew_point": 0.13, + "uvi": 0.02, + "clouds": 100, + "wind_speed": 2.69, + "wind_deg": 301, + "wind_gust": 7.87, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13d" + } + ], + "pop": 1, + "snow": { + "1h": 4.32 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785708000, + "temp": -2.25, + "feels_like": -5.62, + "pressure": 1016, + "humidity": 100, + "dew_point": 0.06, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.44, + "wind_deg": 302, + "wind_gust": 6.6, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.65 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785711600, + "temp": -2.28, + "feels_like": -5.41, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.04, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.23, + "wind_deg": 272, + "wind_gust": 5.66, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.07 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785715200, + "temp": -2.31, + "feels_like": -5.39, + "pressure": 1017, + "humidity": 100, + "dew_point": -0.09, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.19, + "wind_deg": 300, + "wind_gust": 5.37, + "weather": [ + { + "id": 602, + "main": "Snow", + "description": "heavy snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 5.06 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785718800, + "temp": -2.52, + "feels_like": -5.4, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.25, + "uvi": 0, + "clouds": 100, + "wind_speed": 2.01, + "wind_deg": 296, + "wind_gust": 5.22, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 4.49 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785722400, + "temp": -2.62, + "feels_like": -5.15, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.48, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.75, + "wind_deg": 308, + "wind_gust": 4.18, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 3.25 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785726000, + "temp": -2.86, + "feels_like": -5.17, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.66, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.59, + "wind_deg": 296, + "wind_gust": 4.16, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.58 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785729600, + "temp": -2.88, + "feels_like": -5.02, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.69, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.49, + "wind_deg": 310, + "wind_gust": 4.08, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.86 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785733200, + "temp": -2.84, + "feels_like": -5.13, + "pressure": 1018, + "humidity": 100, + "dew_point": -0.69, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.58, + "wind_deg": 310, + "wind_gust": 4.56, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.99 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785736800, + "temp": -2.89, + "feels_like": -5.47, + "pressure": 1019, + "humidity": 100, + "dew_point": -0.73, + "uvi": 0, + "clouds": 100, + "wind_speed": 1.76, + "wind_deg": 306, + "wind_gust": 4.73, + "weather": [ + { + "id": 601, + "main": "Snow", + "description": "snow", + "icon": "13n" + } + ], + "pop": 1, + "snow": { + "1h": 2.99 + }, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=-38.4000&lon=-71.5800&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=-38.4000&lon=-71.5800&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json b/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json new file mode 100644 index 0000000..819787d --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/snow-alerts.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:08Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-hour/success.json b/tests/Fixtures/one-call/one-hour/success.json new file mode 100644 index 0000000..f031103 --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/success.json @@ -0,0 +1,470 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785668400, + "temp": 25.05, + "feels_like": 25.23, + "pressure": 1015, + "humidity": 62, + "dew_point": 17.27, + "uvi": 7.53, + "clouds": 48, + "visibility": 10000, + "wind_speed": 3.87, + "wind_deg": 317, + "wind_gust": 4.47, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1785672000, + "temp": 25.05, + "feels_like": 25.33, + "pressure": 1015, + "humidity": 66, + "dew_point": 18.26, + "uvi": 8.57, + "clouds": 50, + "visibility": 10000, + "wind_speed": 3.96, + "wind_deg": 312, + "wind_gust": 4.13, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1785675600, + "temp": 25.49, + "feels_like": 25.66, + "pressure": 1015, + "humidity": 60, + "dew_point": 17.16, + "uvi": 8.28, + "clouds": 60, + "visibility": 10000, + "wind_speed": 5.07, + "wind_deg": 306, + "wind_gust": 4.89, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785679200, + "temp": 25.91, + "feels_like": 25.99, + "pressure": 1015, + "humidity": 55, + "dew_point": 16.18, + "uvi": 7.41, + "clouds": 70, + "visibility": 10000, + "wind_speed": 5.62, + "wind_deg": 307, + "wind_gust": 5.67, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785682800, + "temp": 25.9, + "feels_like": 25.85, + "pressure": 1014, + "humidity": 50, + "dew_point": 14.69, + "uvi": 5.4, + "clouds": 80, + "visibility": 10000, + "wind_speed": 5.74, + "wind_deg": 310, + "wind_gust": 6.15, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785686400, + "temp": 25.79, + "feels_like": 25.63, + "pressure": 1014, + "humidity": 46, + "dew_point": 13.3, + "uvi": 3.31, + "clouds": 90, + "visibility": 10000, + "wind_speed": 5.71, + "wind_deg": 311, + "wind_gust": 6.63, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785690000, + "temp": 24.52, + "feels_like": 24.2, + "pressure": 1014, + "humidity": 45, + "dew_point": 12.59, + "uvi": 1.56, + "clouds": 100, + "visibility": 10000, + "wind_speed": 5.58, + "wind_deg": 315, + "wind_gust": 7.14, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785693600, + "temp": 24.07, + "feels_like": 23.81, + "pressure": 1014, + "humidity": 49, + "dew_point": 13.11, + "uvi": 0.54, + "clouds": 99, + "visibility": 10000, + "wind_speed": 5.21, + "wind_deg": 315, + "wind_gust": 7.12, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785697200, + "temp": 23.2, + "feels_like": 23.06, + "pressure": 1014, + "humidity": 57, + "dew_point": 13.98, + "uvi": 0.12, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.75, + "wind_deg": 317, + "wind_gust": 7.22, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1785700800, + "temp": 22.54, + "feels_like": 22.55, + "pressure": 1014, + "humidity": 65, + "dew_point": 14.73, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.07, + "wind_deg": 319, + "wind_gust": 7.05, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785704400, + "temp": 22.36, + "feels_like": 22.48, + "pressure": 1015, + "humidity": 70, + "dew_point": 15.36, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 4.05, + "wind_deg": 315, + "wind_gust": 7.17, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785708000, + "temp": 22.38, + "feels_like": 22.53, + "pressure": 1015, + "humidity": 71, + "dew_point": 15.55, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.48, + "wind_deg": 312, + "wind_gust": 6.32, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785711600, + "temp": 22.18, + "feels_like": 22.36, + "pressure": 1015, + "humidity": 73, + "dew_point": 15.81, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.54, + "wind_deg": 311, + "wind_gust": 6.86, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785715200, + "temp": 21.57, + "feels_like": 21.69, + "pressure": 1014, + "humidity": 73, + "dew_point": 15.78, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.84, + "wind_deg": 308, + "wind_gust": 5.34, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785718800, + "temp": 21.36, + "feels_like": 21.48, + "pressure": 1014, + "humidity": 74, + "dew_point": 15.88, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 323, + "wind_gust": 4.42, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785722400, + "temp": 21.03, + "feels_like": 21.2, + "pressure": 1014, + "humidity": 77, + "dew_point": 16.25, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.33, + "wind_deg": 318, + "wind_gust": 4.84, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785726000, + "temp": 20.67, + "feels_like": 20.86, + "pressure": 1014, + "humidity": 79, + "dew_point": 16.45, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.77, + "wind_deg": 298, + "wind_gust": 3.61, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785729600, + "temp": 20.29, + "feels_like": 20.46, + "pressure": 1014, + "humidity": 80, + "dew_point": 16.54, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.19, + "wind_deg": 295, + "wind_gust": 2.42, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785733200, + "temp": 20.43, + "feels_like": 20.64, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.66, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 1.25, + "wind_deg": 287, + "wind_gust": 2.28, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1785736800, + "temp": 21.26, + "feels_like": 21.56, + "pressure": 1014, + "humidity": 81, + "dew_point": 16.68, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 1.4, + "wind_deg": 254, + "wind_gust": 2.25, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + } + ], + "prev": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785596400&appid={API key}&units=metric&lang=en", + "next": "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?cnt=20&lat=38.7223&lon=-9.1393&start=1785740400&appid={API key}&units=metric&lang=en" +} diff --git a/tests/Fixtures/one-call/one-hour/success.meta.json b/tests/Fixtures/one-call/one-hour/success.meta.json new file mode 100644 index 0000000..32719cd --- /dev/null +++ b/tests/Fixtures/one-call/one-hour/success.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-hour timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:31:00Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1h", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [ + { + "path": "$.prev", + "action": "replaced appid query value with {API key}" + }, + { + "path": "$.next", + "action": "replaced appid query value with {API key}" + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/precipitation-alerts.json b/tests/Fixtures/one-call/one-minute/precipitation-alerts.json new file mode 100644 index 0000000..2b6c572 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/precipitation-alerts.json @@ -0,0 +1,428 @@ +{ + "lat": -38.4, + "lon": -71.58, + "timezone": "America/Santiago", + "timezone_offset": -14400, + "data": [ + { + "dt": 1785669780, + "precipitation": 5.9626, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669840, + "precipitation": 5.7888, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669900, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785669960, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670020, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670080, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670140, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670200, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670260, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670320, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670380, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670440, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670500, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670560, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670620, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670680, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670740, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670800, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670860, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670920, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785670980, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671040, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671100, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671160, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671220, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671280, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671340, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671400, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671460, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671520, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671580, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671640, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671700, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671760, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671820, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671880, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785671940, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672000, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672060, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672120, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672180, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672240, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672300, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672360, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672420, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672480, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672540, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672600, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672660, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672720, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672780, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672840, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672900, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785672960, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673020, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673080, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673140, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673200, + "precipitation": 5.615, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673260, + "precipitation": 5.4644, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + }, + { + "dt": 1785673320, + "precipitation": 5.3138, + "alerts": [ + "urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0" + ] + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json b/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json new file mode 100644 index 0000000..8b00749 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/precipitation-alerts.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:22:14Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1min", + "query": { + "lat": -38.4, + "lon": -71.58, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} diff --git a/tests/Fixtures/one-call/one-minute/success.json b/tests/Fixtures/one-call/one-minute/success.json new file mode 100644 index 0000000..2c7ceac --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/success.json @@ -0,0 +1,248 @@ +{ + "lat": 38.7223, + "lon": -9.1393, + "timezone": "Europe/Lisbon", + "timezone_offset": 3600, + "data": [ + { + "dt": 1785669780, + "precipitation": 0 + }, + { + "dt": 1785669840, + "precipitation": 0 + }, + { + "dt": 1785669900, + "precipitation": 0 + }, + { + "dt": 1785669960, + "precipitation": 0 + }, + { + "dt": 1785670020, + "precipitation": 0 + }, + { + "dt": 1785670080, + "precipitation": 0 + }, + { + "dt": 1785670140, + "precipitation": 0 + }, + { + "dt": 1785670200, + "precipitation": 0 + }, + { + "dt": 1785670260, + "precipitation": 0 + }, + { + "dt": 1785670320, + "precipitation": 0 + }, + { + "dt": 1785670380, + "precipitation": 0 + }, + { + "dt": 1785670440, + "precipitation": 0 + }, + { + "dt": 1785670500, + "precipitation": 0 + }, + { + "dt": 1785670560, + "precipitation": 0 + }, + { + "dt": 1785670620, + "precipitation": 0 + }, + { + "dt": 1785670680, + "precipitation": 0 + }, + { + "dt": 1785670740, + "precipitation": 0 + }, + { + "dt": 1785670800, + "precipitation": 0 + }, + { + "dt": 1785670860, + "precipitation": 0 + }, + { + "dt": 1785670920, + "precipitation": 0 + }, + { + "dt": 1785670980, + "precipitation": 0 + }, + { + "dt": 1785671040, + "precipitation": 0 + }, + { + "dt": 1785671100, + "precipitation": 0 + }, + { + "dt": 1785671160, + "precipitation": 0 + }, + { + "dt": 1785671220, + "precipitation": 0 + }, + { + "dt": 1785671280, + "precipitation": 0 + }, + { + "dt": 1785671340, + "precipitation": 0 + }, + { + "dt": 1785671400, + "precipitation": 0 + }, + { + "dt": 1785671460, + "precipitation": 0 + }, + { + "dt": 1785671520, + "precipitation": 0 + }, + { + "dt": 1785671580, + "precipitation": 0 + }, + { + "dt": 1785671640, + "precipitation": 0 + }, + { + "dt": 1785671700, + "precipitation": 0 + }, + { + "dt": 1785671760, + "precipitation": 0 + }, + { + "dt": 1785671820, + "precipitation": 0 + }, + { + "dt": 1785671880, + "precipitation": 0 + }, + { + "dt": 1785671940, + "precipitation": 0 + }, + { + "dt": 1785672000, + "precipitation": 0 + }, + { + "dt": 1785672060, + "precipitation": 0 + }, + { + "dt": 1785672120, + "precipitation": 0 + }, + { + "dt": 1785672180, + "precipitation": 0 + }, + { + "dt": 1785672240, + "precipitation": 0 + }, + { + "dt": 1785672300, + "precipitation": 0 + }, + { + "dt": 1785672360, + "precipitation": 0 + }, + { + "dt": 1785672420, + "precipitation": 0 + }, + { + "dt": 1785672480, + "precipitation": 0 + }, + { + "dt": 1785672540, + "precipitation": 0 + }, + { + "dt": 1785672600, + "precipitation": 0 + }, + { + "dt": 1785672660, + "precipitation": 0 + }, + { + "dt": 1785672720, + "precipitation": 0 + }, + { + "dt": 1785672780, + "precipitation": 0 + }, + { + "dt": 1785672840, + "precipitation": 0 + }, + { + "dt": 1785672900, + "precipitation": 0 + }, + { + "dt": 1785672960, + "precipitation": 0 + }, + { + "dt": 1785673020, + "precipitation": 0 + }, + { + "dt": 1785673080, + "precipitation": 0 + }, + { + "dt": 1785673140, + "precipitation": 0 + }, + { + "dt": 1785673200, + "precipitation": 0 + }, + { + "dt": 1785673260, + "precipitation": 0 + }, + { + "dt": 1785673320, + "precipitation": 0 + } + ] +} diff --git a/tests/Fixtures/one-call/one-minute/success.meta.json b/tests/Fixtures/one-call/one-minute/success.meta.json new file mode 100644 index 0000000..cef3e55 --- /dev/null +++ b/tests/Fixtures/one-call/one-minute/success.meta.json @@ -0,0 +1,19 @@ +{ + "provenance": "captured", + "product": "One Call API", + "endpoint": "1-minute timeline by coordinates", + "apiVersion": "4.0", + "capturedAt": "2026-08-02T11:22:06Z", + "httpStatus": 200, + "request": { + "method": "GET", + "path": "/data/4.0/onecall/timeline/1min", + "query": { + "lat": 38.7223, + "lon": -9.1393, + "units": "metric", + "lang": "en" + } + }, + "sanitization": [] +} From a8289037daed36080f7af6edb341d74a973eb6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 16:08:41 +0100 Subject: [PATCH 046/113] feat(one-call): add minute timeline entity --- src/Entity/OneCall/Current.php | 21 ++-- src/Entity/OneCall/MinuteTimeline.php | 70 +++++++++++ src/Entity/OneCall/MinuteTimeline/Period.php | 75 +++++++++++ src/Entity/OneCall/Timezone.php | 35 ++++++ tests/Unit/Entity/OneCall/CurrentTest.php | 6 +- .../OneCall/MinuteTimeline/PeriodTest.php | 93 ++++++++++++++ .../Entity/OneCall/MinuteTimelineTest.php | 118 ++++++++++++++++++ tests/Unit/Entity/OneCall/TimezoneTest.php | 58 +++++++++ 8 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 src/Entity/OneCall/MinuteTimeline.php create mode 100644 src/Entity/OneCall/MinuteTimeline/Period.php create mode 100644 src/Entity/OneCall/Timezone.php create mode 100644 tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php create mode 100644 tests/Unit/Entity/OneCall/MinuteTimelineTest.php create mode 100644 tests/Unit/Entity/OneCall/TimezoneTest.php diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php index e63a65e..a3150e1 100644 --- a/src/Entity/OneCall/Current.php +++ b/src/Entity/OneCall/Current.php @@ -24,8 +24,7 @@ final class Current implements EntityInterface */ private function __construct( private readonly ?Coordinates $coordinates, - private readonly ?string $timezone, - private readonly ?int $timezoneOffset, + private readonly ?Timezone $timezone, private readonly ?\DateTimeImmutable $observedAt, private readonly ?\DateTimeImmutable $sunriseAt, private readonly ?\DateTimeImmutable $sunsetAt, @@ -83,17 +82,18 @@ public static function fromArray(array $data, ?Context $context = null): static $rain = $reader->nullableArray('data.0.rain'); $snow = $reader->nullableArray('data.0.snow'); + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); $hasWind = array_key_exists('wind_speed', $observation) || array_key_exists('wind_deg', $observation) || array_key_exists('wind_gust', $observation); $hasClouds = array_key_exists('clouds', $observation); return new self( - coordinates: array_key_exists('lat', $data) || array_key_exists('lon', $data) - ? Coordinates::fromArray($data, $context) - : null, - timezone: $reader->nullableString('timezone'), - timezoneOffset: $reader->nullableInt('timezone_offset'), + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, observedAt: $reader->nullableTimestamp('data.0.dt'), sunriseAt: $reader->nullableTimestamp('data.0.sunrise'), sunsetAt: $reader->nullableTimestamp('data.0.sunset'), @@ -129,16 +129,11 @@ public function coordinates(): ?Coordinates return $this->coordinates; } - public function timezone(): ?string + public function timezone(): ?Timezone { return $this->timezone; } - public function timezoneOffset(): ?int - { - return $this->timezoneOffset; - } - public function observedAt(): ?\DateTimeImmutable { return $this->observedAt; diff --git a/src/Entity/OneCall/MinuteTimeline.php b/src/Entity/OneCall/MinuteTimeline.php new file mode 100644 index 0000000..3b8ff35 --- /dev/null +++ b/src/Entity/OneCall/MinuteTimeline.php @@ -0,0 +1,70 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } +} diff --git a/src/Entity/OneCall/MinuteTimeline/Period.php b/src/Entity/OneCall/MinuteTimeline/Period.php new file mode 100644 index 0000000..113d59e --- /dev/null +++ b/src/Entity/OneCall/MinuteTimeline/Period.php @@ -0,0 +1,75 @@ + $alertIds + */ + private function __construct( + private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?float $precipitation, + private readonly array $alertIds, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $alertIds = []; + + foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { + if (!is_string($alertId)) { + throw HydrationException::invalidType( + self::class, + sprintf('alerts.%s', $index), + 'string', + $alertId, + ); + } + + $alertIds[] = $alertId; + } + + return new self( + forecastAt: $reader->nullableTimestamp('dt'), + precipitation: $reader->nullableFloat('precipitation'), + alertIds: $alertIds, + ); + } + + public function forecastAt(): ?\DateTimeImmutable + { + return $this->forecastAt; + } + + public function precipitation(): ?float + { + return $this->precipitation; + } + + public function precipitationUnit(): Unit + { + return Unit::MILLIMETERS_PER_HOUR; + } + + public function precipitationWithUnit(): ?string + { + return MeasurementFormatter::format($this->precipitation, $this->precipitationUnit()); + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/Timezone.php b/src/Entity/OneCall/Timezone.php new file mode 100644 index 0000000..73886d1 --- /dev/null +++ b/src/Entity/OneCall/Timezone.php @@ -0,0 +1,35 @@ +nullableString('timezone'), + offsetSeconds: $reader->nullableInt('timezone_offset'), + ); + } + + public function identifier(): ?string + { + return $this->identifier; + } + + public function offsetSeconds(): ?int + { + return $this->offsetSeconds; + } +} diff --git a/tests/Unit/Entity/OneCall/CurrentTest.php b/tests/Unit/Entity/OneCall/CurrentTest.php index 697a158..56568ec 100644 --- a/tests/Unit/Entity/OneCall/CurrentTest.php +++ b/tests/Unit/Entity/OneCall/CurrentTest.php @@ -23,8 +23,8 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(38.7223, $current->coordinates()?->latitude()); self::assertSame(-9.1393, $current->coordinates()?->longitude()); - self::assertSame('Europe/Lisbon', $current->timezone()); - self::assertSame(3600, $current->timezoneOffset()); + self::assertSame('Europe/Lisbon', $current->timezone()?->identifier()); + self::assertSame(3600, $current->timezone()?->offsetSeconds()); self::assertSame(1785668004, $current->observedAt()?->getTimestamp()); self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); self::assertSame(1785649113, $current->sunriseAt()?->getTimestamp()); @@ -164,6 +164,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($current->coordinates()?->latitude()); self::assertNull($current->coordinates()?->longitude()); + self::assertNull($current->timezone()?->identifier()); + self::assertNull($current->timezone()?->offsetSeconds()); self::assertCount(1, $current->conditions()); self::assertNull($current->conditions()[0]->icon()); self::assertNull($current->temperature()); diff --git a/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php new file mode 100644 index 0000000..96bdb34 --- /dev/null +++ b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php @@ -0,0 +1,93 @@ +forecastAt()?->getTimestamp()); + self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(0.0, $period->precipitation()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->precipitationUnit()); + self::assertSame('0 mm/h', $period->precipitationWithUnit()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedPrecipitationAndAlertIds(): void + { + $period = self::fromFixture('one-call/one-minute/precipitation-alerts.json'); + + self::assertSame(5.9626, $period->precipitation()); + self::assertSame('5.9626 mm/h', $period->precipitationWithUnit()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->forecastAt()); + self::assertNull($missing->precipitation()); + self::assertNull($missing->precipitationWithUnit()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'precipitation' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->forecastAt()); + self::assertNull($period->precipitation()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [ + ['dt' => '1785669780'], + '"dt" expected int, string received.', + ]; + yield 'precipitation' => [ + ['precipitation' => '5.9626'], + '"precipitation" expected int|float, string received.', + ]; + yield 'alert IDs' => [ + ['alerts' => 'invalid'], + '"alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][0]); + } +} diff --git a/tests/Unit/Entity/OneCall/MinuteTimelineTest.php b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php new file mode 100644 index 0000000..e0913dc --- /dev/null +++ b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php @@ -0,0 +1,118 @@ +coordinates()?->latitude()); + self::assertSame($longitude, $timeline->coordinates()?->longitude()); + self::assertSame($timezone, $timeline->timezone()?->identifier()); + self::assertSame($timezoneOffset, $timeline->timezone()?->offsetSeconds()); + self::assertCount(60, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertSame(1785669780, $timeline->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(1785673320, $timeline->periods()[59]->forecastAt()?->getTimestamp()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = MinuteTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + + $timeline = MinuteTimeline::fromArray([ + 'lat' => null, + 'timezone' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->forecastAt()); + self::assertNull($timeline->periods()[1]->precipitation()); + + self::assertSame([], MinuteTimeline::fromArray(['data' => null])->periods()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + MinuteTimeline::fromArray($data); + } + + public static function capturedTimelines(): iterable + { + yield 'dry' => [ + 'one-call/one-minute/success.json', + 38.7223, + -9.1393, + 'Europe/Lisbon', + 3600, + ]; + yield 'precipitation with alerts' => [ + 'one-call/one-minute/precipitation-alerts.json', + -38.4, + -71.58, + 'America/Santiago', + -14400, + ]; + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'timezone offset' => [ + ['timezone_offset' => '3600'], + '"timezone_offset" expected int, string received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['precipitation' => '0.5']]], + '"precipitation" expected int|float, string received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/TimezoneTest.php b/tests/Unit/Entity/OneCall/TimezoneTest.php new file mode 100644 index 0000000..5d382ca --- /dev/null +++ b/tests/Unit/Entity/OneCall/TimezoneTest.php @@ -0,0 +1,58 @@ +identifier()); + self::assertSame(3600, $timezone->offsetSeconds()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Timezone::fromArray([]); + + self::assertNull($missing->identifier()); + self::assertNull($missing->offsetSeconds()); + + $timezone = Timezone::fromArray([ + 'timezone' => null, + 'timezone_offset' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timezone->identifier()); + self::assertNull($timezone->offsetSeconds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Timezone::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'identifier' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'offset' => [ + ['timezone_offset' => '3600'], + '"timezone_offset" expected int, string received.', + ]; + } +} From 70dcf02294f371c6f98515d5477006c4ed9c26b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 16:35:04 +0100 Subject: [PATCH 047/113] feat(one-call): add fifteen-minute timeline entity --- src/Entity/OneCall/FifteenMinuteTimeline.php | 106 +++++++ .../OneCall/FifteenMinuteTimeline/Period.php | 259 ++++++++++++++++++ .../OneCallPaginationUrlNormalizer.php | 75 +++++ .../FifteenMinuteTimeline/PeriodTest.php | 227 +++++++++++++++ .../OneCall/FifteenMinuteTimelineTest.php | 145 ++++++++++ 5 files changed, 812 insertions(+) create mode 100644 src/Entity/OneCall/FifteenMinuteTimeline.php create mode 100644 src/Entity/OneCall/FifteenMinuteTimeline/Period.php create mode 100644 src/Hydration/OneCallPaginationUrlNormalizer.php create mode 100644 tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php create mode 100644 tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php new file mode 100644 index 0000000..8b389b1 --- /dev/null +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -0,0 +1,106 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + private readonly ?string $previousPageUrl, + private readonly ?string $nextPageUrl, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + $previousPageUrl = $reader->nullableString('prev'); + $nextPageUrl = $reader->nullableString('next'); + + $previousPageUrl = $previousPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $previousPageUrl, + self::class, + 'prev', + self::ENDPOINT_PATH, + ); + $nextPageUrl = $nextPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $nextPageUrl, + self::class, + 'next', + self::ENDPOINT_PATH, + ); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + previousPageUrl: $previousPageUrl, + nextPageUrl: $nextPageUrl, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + public function previousPageUrl(): ?string + { + return $this->previousPageUrl; + } + + public function nextPageUrl(): ?string + { + return $this->nextPageUrl; + } +} diff --git a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php new file mode 100644 index 0000000..59e5dc9 --- /dev/null +++ b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php @@ -0,0 +1,259 @@ + $conditions + * @param list $alertIds + */ + private function __construct( + private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPointTemperature, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?float $precipitationProbability, + private readonly array $conditions, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $alertIds = []; + + foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { + if (!is_string($alertId)) { + throw HydrationException::invalidType( + self::class, + sprintf('alerts.%s', $index), + 'string', + $alertId, + ); + } + + $alertIds[] = $alertId; + } + + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + $hasWind = array_key_exists('wind_speed', $data) + || array_key_exists('wind_deg', $data) + || array_key_exists('wind_gust', $data); + $hasClouds = array_key_exists('clouds', $data); + + return new self( + forecastAt: $reader->nullableTimestamp('dt'), + temperature: $reader->nullableFloat('temp'), + feelsLikeTemperature: $reader->nullableFloat('feels_like'), + pressure: $reader->nullableFloat('pressure'), + humidity: $reader->nullableInt('humidity'), + dewPointTemperature: $reader->nullableFloat('dew_point'), + ultravioletIndex: $reader->nullableFloat('uvi'), + visibility: $reader->nullableInt('visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('wind_speed'), + 'deg' => $reader->nullableInt('wind_deg'), + 'gust' => $reader->nullableFloat('wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('clouds'), + ], $context) + : null, + precipitationProbability: $reader->nullableFloat('pop'), + conditions: $conditions, + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + alertIds: $alertIds, + units: UnitsResolver::fromContext($context), + ); + } + + public function forecastAt(): ?\DateTimeImmutable + { + return $this->forecastAt; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->feelsLikeTemperature, + $this->feelsLikeTemperatureUnit(), + ); + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPointTemperature(): ?float + { + return $this->dewPointTemperature; + } + + public function dewPointTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPointTemperature, + $this->dewPointTemperatureUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function precipitationProbability(): ?float + { + return $this->precipitationProbability; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Hydration/OneCallPaginationUrlNormalizer.php b/src/Hydration/OneCallPaginationUrlNormalizer.php new file mode 100644 index 0000000..187cd80 --- /dev/null +++ b/src/Hydration/OneCallPaginationUrlNormalizer.php @@ -0,0 +1,75 @@ +forecastAt()?->getTimestamp()); + self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(26.06, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('26.06 °C', $period->temperatureWithUnit()); + self::assertSame(26.06, $period->feelsLikeTemperature()); + self::assertSame('26.06 °C', $period->feelsLikeTemperatureWithUnit()); + self::assertSame(1015.75, $period->pressure()); + self::assertSame(Unit::HECTOPASCAL, $period->pressureUnit()); + self::assertSame('1015.75 hPa', $period->pressureWithUnit()); + self::assertSame(55, $period->humidity()); + self::assertSame(Unit::PERCENT, $period->humidityUnit()); + self::assertSame('55 %', $period->humidityWithUnit()); + self::assertSame(16.45, $period->dewPointTemperature()); + self::assertSame(Unit::CELSIUS, $period->dewPointTemperatureUnit()); + self::assertSame('16.45 °C', $period->dewPointTemperatureWithUnit()); + self::assertSame(8.06, $period->ultravioletIndex()); + self::assertSame(10000, $period->visibility()); + self::assertSame(Unit::METER, $period->visibilityUnit()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + self::assertSame(5.21, $period->wind()?->speed()); + self::assertSame('5.21 m/s', $period->wind()?->speedWithUnit()); + self::assertSame(306, $period->wind()?->direction()); + self::assertNull($period->wind()?->gust()); + self::assertSame(67, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedRainConditionWithoutAmount(): void + { + $period = self::fromFixture('one-call/fifteen-minute/rain.json', 16); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(0.91, $period->precipitationProbability()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + } + + public function testHydratesCapturedSnowConditionAndAlertIdsWithoutAmount(): void + { + $period = self::fromFixture('one-call/fifteen-minute/snow-alerts.json'); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(1.0, $period->precipitationProbability()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + } + + public function testHydratesDocumentedConditionalPrecipitation(): void + { + $period = Period::fromArray([ + 'rain' => ['1h' => 1.25], + 'snow' => ['1h' => 0.5], + ]); + + self::assertSame(1.25, $period->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->rain()?->lastHourUnit()); + self::assertSame('1.25 mm/h', $period->rain()?->lastHourWithUnit()); + self::assertSame(0.5, $period->snow()?->lastHour()); + self::assertSame('0.5 mm/h', $period->snow()?->lastHourWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('15 mph', $period->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->forecastAt()); + self::assertNull($missing->temperature()); + self::assertNull($missing->temperatureWithUnit()); + self::assertNull($missing->pressure()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->forecastAt()); + self::assertNull($period->temperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()?->lastHour()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'forecast time' => [ + ['dt' => '1785676500'], + '"dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['temp' => '26.06'], + '"temp" expected int|float, string received.', + ]; + yield 'pressure' => [ + ['pressure' => '1015.75'], + '"pressure" expected int|float, string received.', + ]; + yield 'humidity' => [ + ['humidity' => 55.5], + '"humidity" expected int, float received.', + ]; + yield 'visibility' => [ + ['visibility' => 10000.5], + '"visibility" expected int, float received.', + ]; + yield 'wind speed' => [ + ['wind_speed' => '5.21'], + '"wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['clouds' => 67.5], + '"clouds" expected int, float received.', + ]; + yield 'precipitation probability' => [ + ['pop' => '0.5'], + '"pop" expected int|float, string received.', + ]; + yield 'conditions' => [ + ['weather' => 'Clouds'], + '"weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['weather' => ['Clouds']], + '"weather.0" expected array, string received.', + ]; + yield 'rain' => [ + ['rain' => 'invalid'], + '"rain" expected array, string received.', + ]; + yield 'rain amount' => [ + ['rain' => ['1h' => '1.25']], + '"1h" expected int|float, string received.', + ]; + yield 'alert IDs' => [ + ['alerts' => 'invalid'], + '"alerts" expected array, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php new file mode 100644 index 0000000..3493d79 --- /dev/null +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -0,0 +1,145 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(50, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertSame(1785670200, $timeline->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(1785714300, $timeline->periods()[49]->forecastAt()?->getTimestamp()); + self::assertNull($timeline->previousPageUrl()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785715200&units=metric&lang=en', + $timeline->nextPageUrl(), + ); + } + + public function testNormalizesCapturedBidirectionalPagination(): void + { + $timeline = FifteenMinuteTimeline::fromArray( + Fixture::json('one-call/fifteen-minute/pagination.json'), + ); + + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785670200&units=metric&lang=en', + $timeline->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785760200&units=metric&lang=en', + $timeline->nextPageUrl(), + ); + self::assertStringNotContainsString( + 'appid', + $timeline->previousPageUrl() ?? '', + ); + self::assertStringNotContainsString( + 'appid', + $timeline->nextPageUrl() ?? '', + ); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = FifteenMinuteTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->previousPageUrl()); + self::assertNull($missing->nextPageUrl()); + + $timeline = FifteenMinuteTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->forecastAt()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + FifteenMinuteTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['pressure' => '1015.75']]], + '"pressure" expected int|float, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'next page URL type' => [ + ['next' => []], + '"next" expected string, array received.', + ]; + yield 'malformed page URL' => [ + ['next' => 'not a URL'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + yield 'unexpected page host' => [ + ['next' => 'https://example.com/page?appid=secret'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + yield 'unexpected endpoint path' => [ + ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + } +} From e728a6f21a02a79f6f1aa4ae343be37aa50501fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 16:43:28 +0100 Subject: [PATCH 048/113] refactor(entities): standardize primary date-time getters --- docs/air-pollution.md | 6 +++--- docs/weather.md | 2 +- src/Entity/AirPollution/Current.php | 8 ++++---- src/Entity/AirPollution/Forecast/Period.php | 8 ++++---- src/Entity/AirPollution/History/Period.php | 8 ++++---- src/Entity/OneCall/Current.php | 8 ++++---- src/Entity/OneCall/FifteenMinuteTimeline/Period.php | 8 ++++---- src/Entity/OneCall/MinuteTimeline/Period.php | 8 ++++---- src/Entity/Weather/Current.php | 8 ++++---- src/Entity/Weather/Forecast/Period.php | 8 ++++---- tests/Unit/Entity/AirPollution/CurrentTest.php | 12 ++++++------ .../Unit/Entity/AirPollution/Forecast/PeriodTest.php | 8 ++++---- tests/Unit/Entity/AirPollution/ForecastTest.php | 6 +++--- .../Unit/Entity/AirPollution/History/PeriodTest.php | 8 ++++---- tests/Unit/Entity/AirPollution/HistoryTest.php | 6 +++--- tests/Unit/Entity/OneCall/CurrentTest.php | 10 +++++----- .../OneCall/FifteenMinuteTimeline/PeriodTest.php | 8 ++++---- .../Entity/OneCall/FifteenMinuteTimelineTest.php | 6 +++--- .../Entity/OneCall/MinuteTimeline/PeriodTest.php | 8 ++++---- tests/Unit/Entity/OneCall/MinuteTimelineTest.php | 6 +++--- tests/Unit/Entity/Weather/CurrentTest.php | 6 +++--- tests/Unit/Entity/Weather/Forecast/PeriodTest.php | 6 +++--- tests/Unit/Entity/Weather/ForecastTest.php | 2 +- tests/Unit/Resource/AirPollutionTest.php | 4 ++-- 24 files changed, 84 insertions(+), 84 deletions(-) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index 55be940..c8dcab0 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -29,7 +29,7 @@ response property may be absent or explicitly `null`. ```php echo $current->coordinates()?->latitude(); echo $current->coordinates()?->longitude(); -echo $current->observedAt()?->format(DATE_ATOM); +echo $current->dateTime()?->format(DATE_ATOM); echo $current->airQualityIndex()?->value; ``` @@ -91,7 +91,7 @@ echo $forecast->coordinates()?->latitude(); echo $forecast->coordinates()?->longitude(); foreach ($forecast->periods() as $period) { - echo $period->forecastAt()?->format(DATE_ATOM); + echo $period->dateTime()?->format(DATE_ATOM); echo $period->airQualityIndex()?->value; echo $period->components()?->fineParticulateMatter(); } @@ -131,7 +131,7 @@ echo $history->coordinates()?->latitude(); echo $history->coordinates()?->longitude(); foreach ($history->periods() as $period) { - echo $period->observedAt()?->format(DATE_ATOM); + echo $period->dateTime()?->format(DATE_ATOM); echo $period->airQualityIndex()?->value; echo $period->components()?->fineParticulateMatter(); } diff --git a/docs/weather.md b/docs/weather.md index 0dcd378..75185f1 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -96,7 +96,7 @@ metadata. Missing or `null` period lists become empty arrays. use ProgrammatorDev\OpenWeatherMap\Enum\PartOfDay; foreach ($forecast->periods() as $period) { - echo $period->forecastAt()?->format(DATE_ATOM); + echo $period->dateTime()?->format(DATE_ATOM); echo $period->temperature(); echo $period->precipitationProbability(); echo $period->wind()?->speed(); diff --git a/src/Entity/AirPollution/Current.php b/src/Entity/AirPollution/Current.php index 9deaec8..dec36d9 100644 --- a/src/Entity/AirPollution/Current.php +++ b/src/Entity/AirPollution/Current.php @@ -14,7 +14,7 @@ final class Current implements EntityInterface private function __construct( private readonly ?Coordinates $coordinates, - private readonly ?\DateTimeImmutable $observedAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly AirQuality $airQuality, ) {} @@ -30,7 +30,7 @@ public static function fromArray(array $data, ?Context $context = null): static coordinates: $coordinates === null ? null : Coordinates::fromArray($coordinates, $context), - observedAt: $reader->nullableTimestamp('list.0.dt'), + dateTime: $reader->nullableTimestamp('list.0.dt'), airQuality: AirQuality::fromArray($observation, $context), ); } @@ -40,8 +40,8 @@ public function coordinates(): ?Coordinates return $this->coordinates; } - public function observedAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->observedAt; + return $this->dateTime; } } diff --git a/src/Entity/AirPollution/Forecast/Period.php b/src/Entity/AirPollution/Forecast/Period.php index 3c9100e..6d1fe82 100644 --- a/src/Entity/AirPollution/Forecast/Period.php +++ b/src/Entity/AirPollution/Forecast/Period.php @@ -13,7 +13,7 @@ final class Period implements EntityInterface use HasAirQuality; private function __construct( - private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly AirQuality $airQuality, ) {} @@ -22,13 +22,13 @@ public static function fromArray(array $data, ?Context $context = null): static $reader = PayloadReader::from($data, self::class); return new self( - forecastAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), airQuality: AirQuality::fromArray($data, $context), ); } - public function forecastAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->forecastAt; + return $this->dateTime; } } diff --git a/src/Entity/AirPollution/History/Period.php b/src/Entity/AirPollution/History/Period.php index 25d7597..b3af983 100644 --- a/src/Entity/AirPollution/History/Period.php +++ b/src/Entity/AirPollution/History/Period.php @@ -13,7 +13,7 @@ final class Period implements EntityInterface use HasAirQuality; private function __construct( - private readonly ?\DateTimeImmutable $observedAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly AirQuality $airQuality, ) {} @@ -22,13 +22,13 @@ public static function fromArray(array $data, ?Context $context = null): static $reader = PayloadReader::from($data, self::class); return new self( - observedAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), airQuality: AirQuality::fromArray($data, $context), ); } - public function observedAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->observedAt; + return $this->dateTime; } } diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php index a3150e1..b39efd2 100644 --- a/src/Entity/OneCall/Current.php +++ b/src/Entity/OneCall/Current.php @@ -25,7 +25,7 @@ final class Current implements EntityInterface private function __construct( private readonly ?Coordinates $coordinates, private readonly ?Timezone $timezone, - private readonly ?\DateTimeImmutable $observedAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly ?\DateTimeImmutable $sunriseAt, private readonly ?\DateTimeImmutable $sunsetAt, private readonly ?float $temperature, @@ -94,7 +94,7 @@ public static function fromArray(array $data, ?Context $context = null): static return new self( coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, - observedAt: $reader->nullableTimestamp('data.0.dt'), + dateTime: $reader->nullableTimestamp('data.0.dt'), sunriseAt: $reader->nullableTimestamp('data.0.sunrise'), sunsetAt: $reader->nullableTimestamp('data.0.sunset'), temperature: $reader->nullableFloat('data.0.temp'), @@ -134,9 +134,9 @@ public function timezone(): ?Timezone return $this->timezone; } - public function observedAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->observedAt; + return $this->dateTime; } public function sunriseAt(): ?\DateTimeImmutable diff --git a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php index 59e5dc9..298ee24 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php @@ -22,7 +22,7 @@ final class Period implements EntityInterface * @param list $alertIds */ private function __construct( - private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly ?float $temperature, private readonly ?float $feelsLikeTemperature, private readonly ?float $pressure, @@ -81,7 +81,7 @@ public static function fromArray(array $data, ?Context $context = null): static $hasClouds = array_key_exists('clouds', $data); return new self( - forecastAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), temperature: $reader->nullableFloat('temp'), feelsLikeTemperature: $reader->nullableFloat('feels_like'), pressure: $reader->nullableFloat('pressure'), @@ -110,9 +110,9 @@ public static function fromArray(array $data, ?Context $context = null): static ); } - public function forecastAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->forecastAt; + return $this->dateTime; } public function temperature(): ?float diff --git a/src/Entity/OneCall/MinuteTimeline/Period.php b/src/Entity/OneCall/MinuteTimeline/Period.php index 113d59e..d06be77 100644 --- a/src/Entity/OneCall/MinuteTimeline/Period.php +++ b/src/Entity/OneCall/MinuteTimeline/Period.php @@ -15,7 +15,7 @@ final class Period implements EntityInterface * @param list $alertIds */ private function __construct( - private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly ?float $precipitation, private readonly array $alertIds, ) {} @@ -39,15 +39,15 @@ public static function fromArray(array $data, ?Context $context = null): static } return new self( - forecastAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), precipitation: $reader->nullableFloat('precipitation'), alertIds: $alertIds, ); } - public function forecastAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->forecastAt; + return $this->dateTime; } public function precipitation(): ?float diff --git a/src/Entity/Weather/Current.php b/src/Entity/Weather/Current.php index dd1363c..81f7823 100644 --- a/src/Entity/Weather/Current.php +++ b/src/Entity/Weather/Current.php @@ -35,7 +35,7 @@ private function __construct( private readonly ?Clouds $clouds, private readonly ?Precipitation $rain, private readonly ?Precipitation $snow, - private readonly ?\DateTimeImmutable $observedAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly ?string $countryCode, private readonly ?\DateTimeImmutable $sunriseAt, private readonly ?\DateTimeImmutable $sunsetAt, @@ -87,7 +87,7 @@ public static function fromArray(array $data, ?Context $context = null): static clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), - observedAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), countryCode: $reader->nullableString('sys.country'), sunriseAt: $reader->nullableTimestamp('sys.sunrise'), sunsetAt: $reader->nullableTimestamp('sys.sunset'), @@ -131,9 +131,9 @@ public function snow(): ?Precipitation return $this->snow; } - public function observedAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->observedAt; + return $this->dateTime; } public function countryCode(): ?string diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php index 3c99b4a..fb374af 100644 --- a/src/Entity/Weather/Forecast/Period.php +++ b/src/Entity/Weather/Forecast/Period.php @@ -24,7 +24,7 @@ final class Period implements EntityInterface * @param list $conditions */ private function __construct( - private readonly ?\DateTimeImmutable $forecastAt, + private readonly ?\DateTimeImmutable $dateTime, private readonly ?float $temperature, private readonly ?float $feelsLikeTemperature, private readonly ?float $minimumTemperature, @@ -80,7 +80,7 @@ public static function fromArray(array $data, ?Context $context = null): static } return new self( - forecastAt: $reader->nullableTimestamp('dt'), + dateTime: $reader->nullableTimestamp('dt'), temperature: $reader->nullableFloat('main.temp'), feelsLikeTemperature: $reader->nullableFloat('main.feels_like'), minimumTemperature: $reader->nullableFloat('main.temp_min'), @@ -106,9 +106,9 @@ public static function fromArray(array $data, ?Context $context = null): static ); } - public function forecastAt(): ?\DateTimeImmutable + public function dateTime(): ?\DateTimeImmutable { - return $this->forecastAt; + return $this->dateTime; } public function dewPoint(): ?float diff --git a/tests/Unit/Entity/AirPollution/CurrentTest.php b/tests/Unit/Entity/AirPollution/CurrentTest.php index 155fb3b..a43c406 100644 --- a/tests/Unit/Entity/AirPollution/CurrentTest.php +++ b/tests/Unit/Entity/AirPollution/CurrentTest.php @@ -19,8 +19,8 @@ public function testHydratesCapturedCurrentAirPollution(): void self::assertSame(-33.8679, $current->coordinates()?->latitude()); self::assertSame(151.2073, $current->coordinates()?->longitude()); - self::assertSame(1785616883, $current->observedAt()?->getTimestamp()); - self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); + self::assertSame(1785616883, $current->dateTime()?->getTimestamp()); + self::assertSame('UTC', $current->dateTime()?->getTimezone()->getName()); self::assertSame(AirQualityIndex::GOOD, $current->airQualityIndex()); self::assertSame(96.56, $current->components()?->carbonMonoxide()); @@ -32,7 +32,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void $missing = Current::fromArray([]); self::assertNull($missing->coordinates()); - self::assertNull($missing->observedAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->airQualityIndex()); self::assertNull($missing->components()); @@ -52,13 +52,13 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($current->coordinates()?->latitude()); self::assertNull($current->coordinates()?->longitude()); - self::assertNull($current->observedAt()); + self::assertNull($current->dateTime()); self::assertNull($current->airQualityIndex()); self::assertNull($current->components()?->carbonMonoxide()); self::assertNull($current->components()?->nitrogenMonoxide()); - self::assertNull(Current::fromArray(['list' => null])->observedAt()); - self::assertNull(Current::fromArray(['list' => []])->observedAt()); + self::assertNull(Current::fromArray(['list' => null])->dateTime()); + self::assertNull(Current::fromArray(['list' => []])->dateTime()); } #[DataProvider('invalidFields')] diff --git a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php index 2a6a878..8555fdd 100644 --- a/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/AirPollution/Forecast/PeriodTest.php @@ -15,8 +15,8 @@ public function testHydratesCapturedForecastPeriod(): void $response = Fixture::json('air-pollution/forecast/good-to-moderate.json'); $period = Period::fromArray($response['list'][0]); - self::assertSame(1785614400, $period->forecastAt()?->getTimestamp()); - self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(1785614400, $period->dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); self::assertSame(AirQualityIndex::MODERATE, $period->airQualityIndex()); self::assertSame(414.72, $period->components()?->carbonMonoxide()); self::assertSame(36.74, $period->components()?->fineParticulateMatter()); @@ -27,7 +27,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = Period::fromArray([]); - self::assertNull($missing->forecastAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->airQualityIndex()); self::assertNull($missing->components()); @@ -41,7 +41,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($period->forecastAt()); + self::assertNull($period->dateTime()); self::assertNull($period->airQualityIndex()); self::assertNull($period->components()?->carbonMonoxide()); self::assertNull($period->components()?->nitrogenDioxide()); diff --git a/tests/Unit/Entity/AirPollution/ForecastTest.php b/tests/Unit/Entity/AirPollution/ForecastTest.php index d8c43f9..f3bf127 100644 --- a/tests/Unit/Entity/AirPollution/ForecastTest.php +++ b/tests/Unit/Entity/AirPollution/ForecastTest.php @@ -24,8 +24,8 @@ public function testHydratesCompleteCapturedForecasts( self::assertSame($longitude, $forecast->coordinates()?->longitude()); self::assertCount(96, $forecast->periods()); self::assertContainsOnlyInstancesOf(Period::class, $forecast->periods()); - self::assertSame(1785614400, $forecast->periods()[0]->forecastAt()?->getTimestamp()); - self::assertSame(1785956400, $forecast->periods()[95]->forecastAt()?->getTimestamp()); + self::assertSame(1785614400, $forecast->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785956400, $forecast->periods()[95]->dateTime()?->getTimestamp()); $actualAirQualityIndexes = array_values(array_unique(array_map( static fn (Period $period): ?int => $period->airQualityIndex()?->value, @@ -58,7 +58,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($forecast->coordinates()?->latitude()); self::assertNull($forecast->coordinates()?->longitude()); self::assertCount(2, $forecast->periods()); - self::assertNull($forecast->periods()[0]->forecastAt()); + self::assertNull($forecast->periods()[0]->dateTime()); self::assertNull($forecast->periods()[1]->airQualityIndex()); self::assertSame([], Forecast::fromArray(['list' => null])->periods()); diff --git a/tests/Unit/Entity/AirPollution/History/PeriodTest.php b/tests/Unit/Entity/AirPollution/History/PeriodTest.php index d67e754..8f80554 100644 --- a/tests/Unit/Entity/AirPollution/History/PeriodTest.php +++ b/tests/Unit/Entity/AirPollution/History/PeriodTest.php @@ -15,8 +15,8 @@ public function testHydratesCapturedHistoricalPeriod(): void $response = Fixture::json('air-pollution/history/success.json'); $period = Period::fromArray($response['list'][0]); - self::assertSame(1782864000, $period->observedAt()?->getTimestamp()); - self::assertSame('UTC', $period->observedAt()?->getTimezone()->getName()); + self::assertSame(1782864000, $period->dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); self::assertSame(AirQualityIndex::FAIR, $period->airQualityIndex()); self::assertSame(80.71, $period->components()?->carbonMonoxide()); self::assertSame(6.86, $period->components()?->fineParticulateMatter()); @@ -27,7 +27,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = Period::fromArray([]); - self::assertNull($missing->observedAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->airQualityIndex()); self::assertNull($missing->components()); @@ -41,7 +41,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($period->observedAt()); + self::assertNull($period->dateTime()); self::assertNull($period->airQualityIndex()); self::assertNull($period->components()?->carbonMonoxide()); self::assertNull($period->components()?->nitrogenDioxide()); diff --git a/tests/Unit/Entity/AirPollution/HistoryTest.php b/tests/Unit/Entity/AirPollution/HistoryTest.php index b350319..c3e559c 100644 --- a/tests/Unit/Entity/AirPollution/HistoryTest.php +++ b/tests/Unit/Entity/AirPollution/HistoryTest.php @@ -22,8 +22,8 @@ public function testHydratesCompleteCapturedHistory(): void self::assertSame(-9.1393, $history->coordinates()?->longitude()); self::assertCount(25, $history->periods()); self::assertContainsOnlyInstancesOf(Period::class, $history->periods()); - self::assertSame(1782864000, $history->periods()[0]->observedAt()?->getTimestamp()); - self::assertSame(1782950400, $history->periods()[24]->observedAt()?->getTimestamp()); + self::assertSame(1782864000, $history->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1782950400, $history->periods()[24]->dateTime()?->getTimestamp()); self::assertSame(AirQualityIndex::FAIR, $history->periods()[0]->airQualityIndex()); self::assertSame(80.71, $history->periods()[0]->components()?->carbonMonoxide()); } @@ -61,7 +61,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($history->coordinates()?->latitude()); self::assertNull($history->coordinates()?->longitude()); self::assertCount(2, $history->periods()); - self::assertNull($history->periods()[0]->observedAt()); + self::assertNull($history->periods()[0]->dateTime()); self::assertNull($history->periods()[1]->airQualityIndex()); self::assertSame([], History::fromArray(['list' => null])->periods()); diff --git a/tests/Unit/Entity/OneCall/CurrentTest.php b/tests/Unit/Entity/OneCall/CurrentTest.php index 56568ec..13e76a4 100644 --- a/tests/Unit/Entity/OneCall/CurrentTest.php +++ b/tests/Unit/Entity/OneCall/CurrentTest.php @@ -25,8 +25,8 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(-9.1393, $current->coordinates()?->longitude()); self::assertSame('Europe/Lisbon', $current->timezone()?->identifier()); self::assertSame(3600, $current->timezone()?->offsetSeconds()); - self::assertSame(1785668004, $current->observedAt()?->getTimestamp()); - self::assertSame('UTC', $current->observedAt()?->getTimezone()->getName()); + self::assertSame(1785668004, $current->dateTime()?->getTimestamp()); + self::assertSame('UTC', $current->dateTime()?->getTimezone()->getName()); self::assertSame(1785649113, $current->sunriseAt()?->getTimestamp()); self::assertSame(1785700020, $current->sunsetAt()?->getTimestamp()); @@ -135,7 +135,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); - self::assertNull($missing->observedAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->temperature()); self::assertNull($missing->temperatureWithUnit()); self::assertNull($missing->dewPointTemperature()); @@ -175,8 +175,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($current->snow()); self::assertSame([], $current->alertIds()); - self::assertNull(Current::fromArray(['data' => null])->observedAt()); - self::assertNull(Current::fromArray(['data' => []])->observedAt()); + self::assertNull(Current::fromArray(['data' => null])->dateTime()); + self::assertNull(Current::fromArray(['data' => []])->dateTime()); } #[DataProvider('invalidFields')] diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php index 31aa105..9bc0885 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php @@ -19,8 +19,8 @@ public function testHydratesCapturedForecastPeriod(): void { $period = self::fromFixture('one-call/fifteen-minute/success.json', 7); - self::assertSame(1785676500, $period->forecastAt()?->getTimestamp()); - self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(1785676500, $period->dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); self::assertSame(26.06, $period->temperature()); self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); self::assertSame('26.06 °C', $period->temperatureWithUnit()); @@ -115,7 +115,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = Period::fromArray([]); - self::assertNull($missing->forecastAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->temperature()); self::assertNull($missing->temperatureWithUnit()); self::assertNull($missing->pressure()); @@ -138,7 +138,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($period->forecastAt()); + self::assertNull($period->dateTime()); self::assertNull($period->temperature()); self::assertCount(1, $period->conditions()); self::assertNull($period->conditions()[0]->icon()); diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php index 3493d79..b8b1f05 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -23,8 +23,8 @@ public function testHydratesCapturedTimeline(): void self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); self::assertCount(50, $timeline->periods()); self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); - self::assertSame(1785670200, $timeline->periods()[0]->forecastAt()?->getTimestamp()); - self::assertSame(1785714300, $timeline->periods()[49]->forecastAt()?->getTimestamp()); + self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785714300, $timeline->periods()[49]->dateTime()?->getTimestamp()); self::assertNull($timeline->previousPageUrl()); self::assertSame( 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' @@ -86,7 +86,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($timeline->timezone()?->identifier()); self::assertNull($timeline->timezone()?->offsetSeconds()); self::assertCount(2, $timeline->periods()); - self::assertNull($timeline->periods()[0]->forecastAt()); + self::assertNull($timeline->periods()[0]->dateTime()); self::assertNull($timeline->periods()[1]->temperature()); } diff --git a/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php index 96bdb34..5dbcb7f 100644 --- a/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/MinuteTimeline/PeriodTest.php @@ -15,8 +15,8 @@ public function testHydratesCapturedDryPeriod(): void { $period = self::fromFixture('one-call/one-minute/success.json'); - self::assertSame(1785669780, $period->forecastAt()?->getTimestamp()); - self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(1785669780, $period->dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); self::assertSame(0.0, $period->precipitation()); self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->precipitationUnit()); self::assertSame('0 mm/h', $period->precipitationWithUnit()); @@ -38,7 +38,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = Period::fromArray([]); - self::assertNull($missing->forecastAt()); + self::assertNull($missing->dateTime()); self::assertNull($missing->precipitation()); self::assertNull($missing->precipitationWithUnit()); self::assertSame([], $missing->alertIds()); @@ -50,7 +50,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($period->forecastAt()); + self::assertNull($period->dateTime()); self::assertNull($period->precipitation()); self::assertSame([], $period->alertIds()); } diff --git a/tests/Unit/Entity/OneCall/MinuteTimelineTest.php b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php index e0913dc..d718440 100644 --- a/tests/Unit/Entity/OneCall/MinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/MinuteTimelineTest.php @@ -27,8 +27,8 @@ public function testHydratesCapturedTimeline( self::assertSame($timezoneOffset, $timeline->timezone()?->offsetSeconds()); self::assertCount(60, $timeline->periods()); self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); - self::assertSame(1785669780, $timeline->periods()[0]->forecastAt()?->getTimestamp()); - self::assertSame(1785673320, $timeline->periods()[59]->forecastAt()?->getTimestamp()); + self::assertSame(1785669780, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785673320, $timeline->periods()[59]->dateTime()?->getTimestamp()); } public function testToleratesMissingNullUnknownAndPartialFields(): void @@ -55,7 +55,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($timeline->timezone()?->identifier()); self::assertNull($timeline->timezone()?->offsetSeconds()); self::assertCount(2, $timeline->periods()); - self::assertNull($timeline->periods()[0]->forecastAt()); + self::assertNull($timeline->periods()[0]->dateTime()); self::assertNull($timeline->periods()[1]->precipitation()); self::assertSame([], MinuteTimeline::fromArray(['data' => null])->periods()); diff --git a/tests/Unit/Entity/Weather/CurrentTest.php b/tests/Unit/Entity/Weather/CurrentTest.php index 42bc12c..5a246ef 100644 --- a/tests/Unit/Entity/Weather/CurrentTest.php +++ b/tests/Unit/Entity/Weather/CurrentTest.php @@ -67,8 +67,8 @@ public function testHydratesCapturedCurrentWeather(): void self::assertNull($weather->rain()); self::assertNull($weather->snow()); - self::assertSame(1785573885, $weather->observedAt()?->getTimestamp()); - self::assertSame('UTC', $weather->observedAt()?->getTimezone()->getName()); + self::assertSame(1785573885, $weather->dateTime()?->getTimestamp()); + self::assertSame('UTC', $weather->dateTime()?->getTimezone()->getName()); self::assertSame('PT', $weather->countryCode()); self::assertSame(1785562660, $weather->sunriseAt()?->getTimestamp()); self::assertSame(1785613679, $weather->sunsetAt()?->getTimestamp()); @@ -159,7 +159,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($weather->rain()?->lastHour()); self::assertNull($weather->rain()?->lastHourWithUnit()); self::assertNull($weather->snow()); - self::assertNull($weather->observedAt()); + self::assertNull($weather->dateTime()); self::assertNull($weather->countryCode()); self::assertNull($weather->sunriseAt()); self::assertNull($weather->name()); diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php index 9660c7b..6cf02a6 100644 --- a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -20,8 +20,8 @@ public function testHydratesCapturedForecastPeriod(): void { $period = self::fromFixture('weather/forecast/success.json'); - self::assertSame(1785574800, $period->forecastAt()?->getTimestamp()); - self::assertSame('UTC', $period->forecastAt()?->getTimezone()->getName()); + self::assertSame(1785574800, $period->dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); self::assertSame(22.54, $period->temperature()); self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); self::assertSame('22.54 °C', $period->temperatureWithUnit()); @@ -120,7 +120,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void 'unknown' => new \stdClass(), ]); - self::assertNull($period->forecastAt()); + self::assertNull($period->dateTime()); self::assertNull($period->temperature()); self::assertNull($period->temperatureWithUnit()); self::assertNull($period->feelsLikeTemperature()); diff --git a/tests/Unit/Entity/Weather/ForecastTest.php b/tests/Unit/Entity/Weather/ForecastTest.php index 42e62da..cb8c8af 100644 --- a/tests/Unit/Entity/Weather/ForecastTest.php +++ b/tests/Unit/Entity/Weather/ForecastTest.php @@ -23,7 +23,7 @@ public function testHydratesCapturedForecast(): void self::assertSame(40, $forecast->count()); self::assertCount(40, $forecast->periods()); - self::assertSame(1785574800, $forecast->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(1785574800, $forecast->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(22.54, $forecast->periods()[0]->temperature()); $city = $forecast->city(); diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php index 287f35d..0d6def2 100644 --- a/tests/Unit/Resource/AirPollutionTest.php +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -45,7 +45,7 @@ public function testGetsAirPollutionForecastByCoordinates(): void self::assertInstanceOf(Forecast::class, $forecast); self::assertCount(96, $forecast->periods()); - self::assertSame(1785614400, $forecast->periods()[0]->forecastAt()?->getTimestamp()); + self::assertSame(1785614400, $forecast->periods()[0]->dateTime()?->getTimestamp()); self::assertSame('GET', $request->getMethod()); self::assertSame( '/data/2.5/air_pollution/forecast', @@ -72,7 +72,7 @@ public function testGetsHistoricalAirPollutionByCoordinatesAndDateRange(): void self::assertInstanceOf(History::class, $history); self::assertCount(25, $history->periods()); - self::assertSame(1782864000, $history->periods()[0]->observedAt()?->getTimestamp()); + self::assertSame(1782864000, $history->periods()[0]->dateTime()?->getTimestamp()); self::assertSame('GET', $request->getMethod()); self::assertSame( '/data/2.5/air_pollution/history', From 4954c802e4b10c8e5069c6d3fe9d65c8d385cd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 16:49:47 +0100 Subject: [PATCH 049/113] feat(one-call): add one-hour timeline entity --- .../OneCall/FifteenMinuteTimeline/Period.php | 256 +---------------- src/Entity/OneCall/OneHourTimeline.php | 106 +++++++ src/Entity/OneCall/OneHourTimeline/Period.php | 7 + src/Entity/OneCall/Timeline/WeatherPeriod.php | 262 ++++++++++++++++++ .../OneCall/OneHourTimeline/PeriodTest.php | 170 ++++++++++++ .../Entity/OneCall/OneHourTimelineTest.php | 119 ++++++++ 6 files changed, 666 insertions(+), 254 deletions(-) create mode 100644 src/Entity/OneCall/OneHourTimeline.php create mode 100644 src/Entity/OneCall/OneHourTimeline/Period.php create mode 100644 src/Entity/OneCall/Timeline/WeatherPeriod.php create mode 100644 tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php create mode 100644 tests/Unit/Entity/OneCall/OneHourTimelineTest.php diff --git a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php index 298ee24..0719af7 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline/Period.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline/Period.php @@ -2,258 +2,6 @@ namespace ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; -use ProgrammatorDev\Api\Context\Context; -use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Clouds; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Condition; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current\Precipitation; -use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Wind; -use ProgrammatorDev\OpenWeatherMap\Enum\Unit; -use ProgrammatorDev\OpenWeatherMap\Enum\Units; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Formatting\MeasurementFormatter; -use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; -use ProgrammatorDev\OpenWeatherMap\Hydration\UnitsResolver; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\WeatherPeriod; -final class Period implements EntityInterface -{ - /** - * @param list $conditions - * @param list $alertIds - */ - private function __construct( - private readonly ?\DateTimeImmutable $dateTime, - private readonly ?float $temperature, - private readonly ?float $feelsLikeTemperature, - private readonly ?float $pressure, - private readonly ?int $humidity, - private readonly ?float $dewPointTemperature, - private readonly ?float $ultravioletIndex, - private readonly ?int $visibility, - private readonly ?Wind $wind, - private readonly ?Clouds $clouds, - private readonly ?float $precipitationProbability, - private readonly array $conditions, - private readonly ?Precipitation $rain, - private readonly ?Precipitation $snow, - private readonly array $alertIds, - private readonly Units $units, - ) {} - - public static function fromArray(array $data, ?Context $context = null): static - { - $reader = PayloadReader::from($data, self::class); - $conditions = []; - - foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { - if (!is_array($condition)) { - throw HydrationException::invalidType( - self::class, - sprintf('weather.%s', $index), - 'array', - $condition, - ); - } - - $conditions[] = Condition::fromArray($condition, $context); - } - - $alertIds = []; - - foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { - if (!is_string($alertId)) { - throw HydrationException::invalidType( - self::class, - sprintf('alerts.%s', $index), - 'string', - $alertId, - ); - } - - $alertIds[] = $alertId; - } - - $rain = $reader->nullableArray('rain'); - $snow = $reader->nullableArray('snow'); - $hasWind = array_key_exists('wind_speed', $data) - || array_key_exists('wind_deg', $data) - || array_key_exists('wind_gust', $data); - $hasClouds = array_key_exists('clouds', $data); - - return new self( - dateTime: $reader->nullableTimestamp('dt'), - temperature: $reader->nullableFloat('temp'), - feelsLikeTemperature: $reader->nullableFloat('feels_like'), - pressure: $reader->nullableFloat('pressure'), - humidity: $reader->nullableInt('humidity'), - dewPointTemperature: $reader->nullableFloat('dew_point'), - ultravioletIndex: $reader->nullableFloat('uvi'), - visibility: $reader->nullableInt('visibility'), - wind: $hasWind - ? Wind::fromArray([ - 'speed' => $reader->nullableFloat('wind_speed'), - 'deg' => $reader->nullableInt('wind_deg'), - 'gust' => $reader->nullableFloat('wind_gust'), - ], $context) - : null, - clouds: $hasClouds - ? Clouds::fromArray([ - 'all' => $reader->nullableInt('clouds'), - ], $context) - : null, - precipitationProbability: $reader->nullableFloat('pop'), - conditions: $conditions, - rain: $rain === null ? null : Precipitation::fromArray($rain, $context), - snow: $snow === null ? null : Precipitation::fromArray($snow, $context), - alertIds: $alertIds, - units: UnitsResolver::fromContext($context), - ); - } - - public function dateTime(): ?\DateTimeImmutable - { - return $this->dateTime; - } - - public function temperature(): ?float - { - return $this->temperature; - } - - public function temperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function temperatureWithUnit(): ?string - { - return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); - } - - public function feelsLikeTemperature(): ?float - { - return $this->feelsLikeTemperature; - } - - public function feelsLikeTemperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function feelsLikeTemperatureWithUnit(): ?string - { - return MeasurementFormatter::format( - $this->feelsLikeTemperature, - $this->feelsLikeTemperatureUnit(), - ); - } - - public function pressure(): ?float - { - return $this->pressure; - } - - public function pressureUnit(): Unit - { - return Unit::HECTOPASCAL; - } - - public function pressureWithUnit(): ?string - { - return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); - } - - public function humidity(): ?int - { - return $this->humidity; - } - - public function humidityUnit(): Unit - { - return Unit::PERCENT; - } - - public function humidityWithUnit(): ?string - { - return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); - } - - public function dewPointTemperature(): ?float - { - return $this->dewPointTemperature; - } - - public function dewPointTemperatureUnit(): Unit - { - return $this->units->temperatureUnit(); - } - - public function dewPointTemperatureWithUnit(): ?string - { - return MeasurementFormatter::format( - $this->dewPointTemperature, - $this->dewPointTemperatureUnit(), - ); - } - - public function ultravioletIndex(): ?float - { - return $this->ultravioletIndex; - } - - public function visibility(): ?int - { - return $this->visibility; - } - - public function visibilityUnit(): Unit - { - return Unit::METER; - } - - public function visibilityWithUnit(): ?string - { - return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); - } - - public function wind(): ?Wind - { - return $this->wind; - } - - public function clouds(): ?Clouds - { - return $this->clouds; - } - - public function precipitationProbability(): ?float - { - return $this->precipitationProbability; - } - - /** - * @return list - */ - public function conditions(): array - { - return $this->conditions; - } - - public function rain(): ?Precipitation - { - return $this->rain; - } - - public function snow(): ?Precipitation - { - return $this->snow; - } - - /** - * @return list - */ - public function alertIds(): array - { - return $this->alertIds; - } -} +final class Period extends WeatherPeriod {} diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php new file mode 100644 index 0000000..6b7a2fa --- /dev/null +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -0,0 +1,106 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + private readonly ?string $previousPageUrl, + private readonly ?string $nextPageUrl, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + self::class, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = Period::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + $previousPageUrl = $reader->nullableString('prev'); + $nextPageUrl = $reader->nullableString('next'); + + $previousPageUrl = $previousPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $previousPageUrl, + self::class, + 'prev', + self::ENDPOINT_PATH, + ); + $nextPageUrl = $nextPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $nextPageUrl, + self::class, + 'next', + self::ENDPOINT_PATH, + ); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + previousPageUrl: $previousPageUrl, + nextPageUrl: $nextPageUrl, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + public function previousPageUrl(): ?string + { + return $this->previousPageUrl; + } + + public function nextPageUrl(): ?string + { + return $this->nextPageUrl; + } +} diff --git a/src/Entity/OneCall/OneHourTimeline/Period.php b/src/Entity/OneCall/OneHourTimeline/Period.php new file mode 100644 index 0000000..8a43721 --- /dev/null +++ b/src/Entity/OneCall/OneHourTimeline/Period.php @@ -0,0 +1,7 @@ + $conditions + * @param list $alertIds + */ + protected function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?float $temperature, + private readonly ?float $feelsLikeTemperature, + private readonly ?float $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPointTemperature, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?float $precipitationProbability, + private readonly array $conditions, + private readonly ?Precipitation $rain, + private readonly ?Precipitation $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, static::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + static::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $alertIds = []; + + foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { + if (!is_string($alertId)) { + throw HydrationException::invalidType( + static::class, + sprintf('alerts.%s', $index), + 'string', + $alertId, + ); + } + + $alertIds[] = $alertId; + } + + $rain = $reader->nullableArray('rain'); + $snow = $reader->nullableArray('snow'); + $hasWind = array_key_exists('wind_speed', $data) + || array_key_exists('wind_deg', $data) + || array_key_exists('wind_gust', $data); + $hasClouds = array_key_exists('clouds', $data); + + return new static( + dateTime: $reader->nullableTimestamp('dt'), + temperature: $reader->nullableFloat('temp'), + feelsLikeTemperature: $reader->nullableFloat('feels_like'), + pressure: $reader->nullableFloat('pressure'), + humidity: $reader->nullableInt('humidity'), + dewPointTemperature: $reader->nullableFloat('dew_point'), + ultravioletIndex: $reader->nullableFloat('uvi'), + visibility: $reader->nullableInt('visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('wind_speed'), + 'deg' => $reader->nullableInt('wind_deg'), + 'gust' => $reader->nullableFloat('wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('clouds'), + ], $context) + : null, + precipitationProbability: $reader->nullableFloat('pop'), + conditions: $conditions, + rain: $rain === null ? null : Precipitation::fromArray($rain, $context), + snow: $snow === null ? null : Precipitation::fromArray($snow, $context), + alertIds: $alertIds, + units: UnitsResolver::fromContext($context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function temperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function temperatureWithUnit(): ?string + { + return MeasurementFormatter::format($this->temperature, $this->temperatureUnit()); + } + + public function feelsLikeTemperature(): ?float + { + return $this->feelsLikeTemperature; + } + + public function feelsLikeTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function feelsLikeTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->feelsLikeTemperature, + $this->feelsLikeTemperatureUnit(), + ); + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPointTemperature(): ?float + { + return $this->dewPointTemperature; + } + + public function dewPointTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPointTemperature, + $this->dewPointTemperatureUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function precipitationProbability(): ?float + { + return $this->precipitationProbability; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?Precipitation + { + return $this->rain; + } + + public function snow(): ?Precipitation + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php new file mode 100644 index 0000000..b41b69b --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php @@ -0,0 +1,170 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(25.05, $period->temperature()); + self::assertSame(Unit::CELSIUS, $period->temperatureUnit()); + self::assertSame('25.05 °C', $period->temperatureWithUnit()); + self::assertSame(25.23, $period->feelsLikeTemperature()); + self::assertSame(1015.0, $period->pressure()); + self::assertSame('1015 hPa', $period->pressureWithUnit()); + self::assertSame(62, $period->humidity()); + self::assertSame(17.27, $period->dewPointTemperature()); + self::assertSame(7.53, $period->ultravioletIndex()); + self::assertSame(10000, $period->visibility()); + self::assertSame(3.87, $period->wind()?->speed()); + self::assertSame(317, $period->wind()?->direction()); + self::assertSame(4.47, $period->wind()?->gust()); + self::assertSame(48, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedRain(): void + { + $period = self::fromFixture('one-call/one-hour/rain.json'); + + self::assertSame('Rain', $period->conditions()[0]->group()); + self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(1.72, $period->rain()?->lastHour()); + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->rain()?->lastHourUnit()); + self::assertSame('1.72 mm/h', $period->rain()?->lastHourWithUnit()); + self::assertNull($period->snow()); + } + + public function testHydratesCapturedSnowAndAlerts(): void + { + $period = self::fromFixture('one-call/one-hour/snow-alerts.json'); + $periodWithoutVisibility = self::fromFixture( + 'one-call/one-hour/snow-alerts.json', + 19, + ); + + self::assertSame('Snow', $period->conditions()[0]->group()); + self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(2.54, $period->snow()?->lastHour()); + self::assertSame('2.54 mm/h', $period->snow()?->lastHourWithUnit()); + self::assertNull($period->rain()); + self::assertSame([ + 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:f1076d7511a15522d5a6e41917020bc0', + ], $period->alertIds()); + self::assertNull($periodWithoutVisibility->visibility()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => 72.5, + 'feels_like' => 71, + 'dew_point' => 60, + 'wind_speed' => 10, + 'wind_gust' => 15, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); + self::assertSame('72.5 °F', $period->temperatureWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + self::assertSame('15 mph', $period->wind()?->gustWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => ['1h' => null, 'unknown' => true], + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()?->lastHour()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'date time' => [ + ['dt' => '1785668400'], + '"dt" expected int, string received.', + ]; + yield 'temperature' => [ + ['temp' => '25.05'], + '"temp" expected int|float, string received.', + ]; + yield 'rain amount' => [ + ['rain' => ['1h' => '1.72']], + '"1h" expected int|float, string received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php new file mode 100644 index 0000000..05ce937 --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -0,0 +1,119 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(20, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785736800, $timeline->periods()[19]->dateTime()?->getTimestamp()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785596400&units=metric&lang=en', + $timeline->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785740400&units=metric&lang=en', + $timeline->nextPageUrl(), + ); + } + + public function testHydratesCapturedHistoricalTimeline(): void + { + $timeline = OneHourTimeline::fromArray( + Fixture::json('one-call/one-hour/history.json'), + ); + + self::assertCount(20, $timeline->periods()); + self::assertSame(1785495600, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1785564000, $timeline->periods()[19]->dateTime()?->getTimestamp()); + self::assertNull($timeline->periods()[0]->precipitationProbability()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = OneHourTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->previousPageUrl()); + self::assertNull($missing->nextPageUrl()); + + $timeline = OneHourTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + OneHourTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['temp' => '25.05']]], + '"temp" expected int|float, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'unexpected page host' => [ + ['next' => 'https://example.com/page?appid=secret'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + yield 'unexpected endpoint path' => [ + ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + } +} From d1410496f0ffd4b17550dff47a0087c21b515475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 17:00:24 +0100 Subject: [PATCH 050/113] feat(one-call): add one-day timeline period --- .../OneDayTimeline/FeelsLikeTemperature.php | 100 +++++++ src/Entity/OneCall/OneDayTimeline/Period.php | 276 ++++++++++++++++++ .../OneCall/OneDayTimeline/Temperature.php | 134 +++++++++ .../FeelsLikeTemperatureTest.php | 86 ++++++ .../OneCall/OneDayTimeline/PeriodTest.php | 238 +++++++++++++++ .../OneDayTimeline/TemperatureTest.php | 96 ++++++ 6 files changed, 930 insertions(+) create mode 100644 src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php create mode 100644 src/Entity/OneCall/OneDayTimeline/Period.php create mode 100644 src/Entity/OneCall/OneDayTimeline/Temperature.php create mode 100644 tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php create mode 100644 tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php create mode 100644 tests/Unit/Entity/OneCall/OneDayTimeline/TemperatureTest.php diff --git a/src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php b/src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php new file mode 100644 index 0000000..f206717 --- /dev/null +++ b/src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php @@ -0,0 +1,100 @@ +nullableFloat('day'), + night: $reader->nullableFloat('night'), + evening: $reader->nullableFloat('eve'), + morning: $reader->nullableFloat('morn'), + units: UnitsResolver::fromContext($context), + ); + } + + public function day(): ?float + { + return $this->day; + } + + public function dayUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dayWithUnit(): ?string + { + return $this->format($this->day); + } + + public function night(): ?float + { + return $this->night; + } + + public function nightUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function nightWithUnit(): ?string + { + return $this->format($this->night); + } + + public function evening(): ?float + { + return $this->evening; + } + + public function eveningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function eveningWithUnit(): ?string + { + return $this->format($this->evening); + } + + public function morning(): ?float + { + return $this->morning; + } + + public function morningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function morningWithUnit(): ?string + { + return $this->format($this->morning); + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } +} diff --git a/src/Entity/OneCall/OneDayTimeline/Period.php b/src/Entity/OneCall/OneDayTimeline/Period.php new file mode 100644 index 0000000..bf5a86b --- /dev/null +++ b/src/Entity/OneCall/OneDayTimeline/Period.php @@ -0,0 +1,276 @@ + $conditions + * @param list $alertIds + */ + private function __construct( + private readonly ?\DateTimeImmutable $dateTime, + private readonly ?\DateTimeImmutable $sunriseAt, + private readonly ?\DateTimeImmutable $sunsetAt, + private readonly ?\DateTimeImmutable $moonriseAt, + private readonly ?\DateTimeImmutable $moonsetAt, + private readonly ?float $moonPhase, + private readonly ?Temperature $temperature, + private readonly ?FeelsLikeTemperature $feelsLikeTemperature, + private readonly ?float $pressure, + private readonly ?int $humidity, + private readonly ?float $dewPointTemperature, + private readonly ?float $ultravioletIndex, + private readonly ?int $visibility, + private readonly ?Wind $wind, + private readonly ?Clouds $clouds, + private readonly ?float $precipitationProbability, + private readonly array $conditions, + private readonly ?float $rain, + private readonly ?float $snow, + private readonly array $alertIds, + private readonly Units $units, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $conditions = []; + + foreach ($reader->nullableArray('weather') ?? [] as $index => $condition) { + if (!is_array($condition)) { + throw HydrationException::invalidType( + self::class, + sprintf('weather.%s', $index), + 'array', + $condition, + ); + } + + $conditions[] = Condition::fromArray($condition, $context); + } + + $alertIds = []; + + foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { + if (!is_string($alertId)) { + throw HydrationException::invalidType( + self::class, + sprintf('alerts.%s', $index), + 'string', + $alertId, + ); + } + + $alertIds[] = $alertId; + } + + $temperature = $reader->nullableArray('temp'); + $feelsLikeTemperature = $reader->nullableArray('feels_like'); + $hasWind = array_key_exists('wind_speed', $data) + || array_key_exists('wind_deg', $data) + || array_key_exists('wind_gust', $data); + $hasClouds = array_key_exists('clouds', $data); + + return new self( + dateTime: $reader->nullableTimestamp('dt'), + sunriseAt: $reader->nullableTimestamp('sunrise'), + sunsetAt: $reader->nullableTimestamp('sunset'), + moonriseAt: $reader->nullableTimestamp('moonrise'), + moonsetAt: $reader->nullableTimestamp('moonset'), + moonPhase: $reader->nullableFloat('moon_phase'), + temperature: $temperature === null + ? null + : Temperature::fromArray($temperature, $context), + feelsLikeTemperature: $feelsLikeTemperature === null + ? null + : FeelsLikeTemperature::fromArray($feelsLikeTemperature, $context), + pressure: $reader->nullableFloat('pressure'), + humidity: $reader->nullableInt('humidity'), + dewPointTemperature: $reader->nullableFloat('dew_point'), + ultravioletIndex: $reader->nullableFloat('uvi'), + visibility: $reader->nullableInt('visibility'), + wind: $hasWind + ? Wind::fromArray([ + 'speed' => $reader->nullableFloat('wind_speed'), + 'deg' => $reader->nullableInt('wind_deg'), + 'gust' => $reader->nullableFloat('wind_gust'), + ], $context) + : null, + clouds: $hasClouds + ? Clouds::fromArray([ + 'all' => $reader->nullableInt('clouds'), + ], $context) + : null, + precipitationProbability: $reader->nullableFloat('pop'), + conditions: $conditions, + // Live daily responses return scalar precipitation, while the field table + // still describes hourly objects: https://openweathermap.org/api/one-call-4 + rain: $reader->nullableFloat('rain'), + snow: $reader->nullableFloat('snow'), + alertIds: $alertIds, + units: UnitsResolver::fromContext($context), + ); + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function sunriseAt(): ?\DateTimeImmutable + { + return $this->sunriseAt; + } + + public function sunsetAt(): ?\DateTimeImmutable + { + return $this->sunsetAt; + } + + public function moonriseAt(): ?\DateTimeImmutable + { + return $this->moonriseAt; + } + + public function moonsetAt(): ?\DateTimeImmutable + { + return $this->moonsetAt; + } + + public function moonPhase(): ?float + { + return $this->moonPhase; + } + + public function temperature(): ?Temperature + { + return $this->temperature; + } + + public function feelsLikeTemperature(): ?FeelsLikeTemperature + { + return $this->feelsLikeTemperature; + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function pressureUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function pressureWithUnit(): ?string + { + return MeasurementFormatter::format($this->pressure, $this->pressureUnit()); + } + + public function humidity(): ?int + { + return $this->humidity; + } + + public function humidityUnit(): Unit + { + return Unit::PERCENT; + } + + public function humidityWithUnit(): ?string + { + return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); + } + + public function dewPointTemperature(): ?float + { + return $this->dewPointTemperature; + } + + public function dewPointTemperatureUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dewPointTemperatureWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->dewPointTemperature, + $this->dewPointTemperatureUnit(), + ); + } + + public function ultravioletIndex(): ?float + { + return $this->ultravioletIndex; + } + + public function visibility(): ?int + { + return $this->visibility; + } + + public function visibilityUnit(): Unit + { + return Unit::METER; + } + + public function visibilityWithUnit(): ?string + { + return MeasurementFormatter::format($this->visibility, $this->visibilityUnit()); + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function clouds(): ?Clouds + { + return $this->clouds; + } + + public function precipitationProbability(): ?float + { + return $this->precipitationProbability; + } + + /** + * @return list + */ + public function conditions(): array + { + return $this->conditions; + } + + public function rain(): ?float + { + return $this->rain; + } + + public function snow(): ?float + { + return $this->snow; + } + + /** + * @return list + */ + public function alertIds(): array + { + return $this->alertIds; + } +} diff --git a/src/Entity/OneCall/OneDayTimeline/Temperature.php b/src/Entity/OneCall/OneDayTimeline/Temperature.php new file mode 100644 index 0000000..1f6ed3b --- /dev/null +++ b/src/Entity/OneCall/OneDayTimeline/Temperature.php @@ -0,0 +1,134 @@ +nullableFloat('day'), + minimum: $reader->nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + night: $reader->nullableFloat('night'), + evening: $reader->nullableFloat('eve'), + morning: $reader->nullableFloat('morn'), + units: UnitsResolver::fromContext($context), + ); + } + + public function day(): ?float + { + return $this->day; + } + + public function dayUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function dayWithUnit(): ?string + { + return $this->format($this->day); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function night(): ?float + { + return $this->night; + } + + public function nightUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function nightWithUnit(): ?string + { + return $this->format($this->night); + } + + public function evening(): ?float + { + return $this->evening; + } + + public function eveningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function eveningWithUnit(): ?string + { + return $this->format($this->evening); + } + + public function morning(): ?float + { + return $this->morning; + } + + public function morningUnit(): Unit + { + return $this->units->temperatureUnit(); + } + + public function morningWithUnit(): ?string + { + return $this->format($this->morning); + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, $this->units->temperatureUnit()); + } +} diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php b/tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php new file mode 100644 index 0000000..a46aea9 --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php @@ -0,0 +1,86 @@ + 25.53, + 'night' => 19.4, + 'eve' => 24.82, + 'morn' => 18.74, + ]); + + self::assertSame(25.53, $temperature->day()); + self::assertSame(Unit::CELSIUS, $temperature->dayUnit()); + self::assertSame('25.53 °C', $temperature->dayWithUnit()); + self::assertSame(19.4, $temperature->night()); + self::assertSame('19.4 °C', $temperature->nightWithUnit()); + self::assertSame(24.82, $temperature->evening()); + self::assertSame('24.82 °C', $temperature->eveningWithUnit()); + self::assertSame(18.74, $temperature->morning()); + self::assertSame('18.74 °C', $temperature->morningWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $temperature = FeelsLikeTemperature::fromArray(['night' => 68], $context); + + self::assertSame(Unit::FAHRENHEIT, $temperature->nightUnit()); + self::assertSame('68 °F', $temperature->nightWithUnit()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $missing = FeelsLikeTemperature::fromArray([]); + + self::assertNull($missing->day()); + self::assertNull($missing->night()); + self::assertNull($missing->evening()); + self::assertNull($missing->morning()); + + $temperature = FeelsLikeTemperature::fromArray([ + 'day' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($temperature->day()); + self::assertNull($temperature->dayWithUnit()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(string $field): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected int|float, string received.', + $field, + )); + + FeelsLikeTemperature::fromArray([$field => 'invalid']); + } + + public static function invalidFields(): iterable + { + yield 'day' => ['day']; + yield 'night' => ['night']; + yield 'evening' => ['eve']; + yield 'morning' => ['morn']; + } +} diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php new file mode 100644 index 0000000..6599b37 --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php @@ -0,0 +1,238 @@ +dateTime()?->getTimestamp()); + self::assertSame('UTC', $period->dateTime()?->getTimezone()->getName()); + self::assertSame(1785649113, $period->sunriseAt()?->getTimestamp()); + self::assertSame(1785700020, $period->sunsetAt()?->getTimestamp()); + self::assertSame(1785706800, $period->moonriseAt()?->getTimestamp()); + self::assertSame(1785662640, $period->moonsetAt()?->getTimestamp()); + self::assertSame(0.62, $period->moonPhase()); + + self::assertSame(25.53, $period->temperature()?->day()); + self::assertSame(18.73, $period->temperature()?->minimum()); + self::assertSame(26.99, $period->temperature()?->maximum()); + self::assertSame(19.4, $period->temperature()?->night()); + self::assertSame(24.82, $period->temperature()?->evening()); + self::assertSame(18.74, $period->temperature()?->morning()); + self::assertSame(25.53, $period->feelsLikeTemperature()?->day()); + self::assertSame(19.4, $period->feelsLikeTemperature()?->night()); + + self::assertSame(1015.44, $period->pressure()); + self::assertSame(Unit::HECTOPASCAL, $period->pressureUnit()); + self::assertSame('1015.44 hPa', $period->pressureWithUnit()); + self::assertSame(48, $period->humidity()); + self::assertNull($period->dewPointTemperature()); + self::assertSame(0.0, $period->ultravioletIndex()); + self::assertNull($period->visibility()); + self::assertSame(6.17, $period->wind()?->speed()); + self::assertSame(307, $period->wind()?->direction()); + self::assertNull($period->wind()?->gust()); + self::assertSame(41, $period->clouds()?->coverage()); + self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame('Clouds', $period->conditions()[0]->group()); + self::assertNull($period->rain()); + self::assertNull($period->snow()); + self::assertSame([], $period->alertIds()); + } + + public function testHydratesCapturedScalarRainAndSnow(): void + { + $lightRain = self::fromFixture('one-call/one-day/success.json', 1); + $rain = self::fromFixture('one-call/one-day/rain.json'); + $snow = self::fromFixture('one-call/one-day/snow.json'); + + self::assertSame(0.09, $lightRain->rain()); + self::assertSame('Rain', $rain->conditions()[0]->group()); + self::assertSame(17.92, $rain->rain()); + self::assertNull($rain->snow()); + self::assertSame('Snow', $snow->conditions()[0]->group()); + self::assertSame(60.98, $snow->snow()); + self::assertNull($snow->rain()); + } + + public function testHydratesHistoricalAndForecastPeriodsWithoutClassification(): void + { + $historical = self::fromFixture('one-call/one-day/history.json'); + $forecast = self::fromFixture('one-call/one-day/history.json', 2); + + self::assertSame(1785456000, $historical->dateTime()?->getTimestamp()); + self::assertNull($historical->precipitationProbability()); + self::assertSame(1785628800, $forecast->dateTime()?->getTimestamp()); + self::assertSame(0.0, $forecast->precipitationProbability()); + } + + public function testHydratesDocumentedFieldsAbsentFromCapturedPeriods(): void + { + $period = Period::fromArray([ + 'dew_point' => 16.5, + 'visibility' => 10000, + 'wind_gust' => 8.2, + 'alerts' => ['alert-id'], + ]); + + self::assertSame(16.5, $period->dewPointTemperature()); + self::assertSame('16.5 °C', $period->dewPointTemperatureWithUnit()); + self::assertSame(10000, $period->visibility()); + self::assertSame('10000 m', $period->visibilityWithUnit()); + self::assertSame(8.2, $period->wind()?->gust()); + self::assertSame(['alert-id'], $period->alertIds()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $period = Period::fromArray([ + 'temp' => ['day' => 72.5], + 'feels_like' => ['day' => 71], + 'dew_point' => 60, + 'wind_speed' => 10, + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $period->temperature()?->dayUnit()); + self::assertSame('72.5 °F', $period->temperature()?->dayWithUnit()); + self::assertSame('71 °F', $period->feelsLikeTemperature()?->dayWithUnit()); + self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); + self::assertSame('10 mph', $period->wind()?->speedWithUnit()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Period::fromArray([]); + + self::assertNull($missing->dateTime()); + self::assertNull($missing->temperature()); + self::assertNull($missing->feelsLikeTemperature()); + self::assertNull($missing->wind()); + self::assertNull($missing->clouds()); + self::assertSame([], $missing->conditions()); + self::assertNull($missing->rain()); + self::assertNull($missing->snow()); + self::assertSame([], $missing->alertIds()); + + $period = Period::fromArray([ + 'dt' => null, + 'temp' => ['day' => null, 'unknown' => true], + 'feels_like' => null, + 'weather' => [['icon' => null, 'unknown' => true]], + 'clouds' => null, + 'wind_speed' => null, + 'rain' => null, + 'snow' => null, + 'alerts' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($period->dateTime()); + self::assertNull($period->temperature()?->day()); + self::assertNull($period->feelsLikeTemperature()); + self::assertCount(1, $period->conditions()); + self::assertNull($period->conditions()[0]->icon()); + self::assertNull($period->clouds()?->coverage()); + self::assertNull($period->wind()?->speed()); + self::assertNull($period->rain()); + self::assertSame([], $period->alertIds()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Period::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'date time' => [ + ['dt' => '1785628800'], + '"dt" expected int, string received.', + ]; + yield 'sunrise' => [ + ['sunrise' => '1785649113'], + '"sunrise" expected int, string received.', + ]; + yield 'moon phase' => [ + ['moon_phase' => '0.62'], + '"moon_phase" expected int|float, string received.', + ]; + yield 'temperature object' => [ + ['temp' => 25.53], + '"temp" expected array, float received.', + ]; + yield 'temperature field' => [ + ['temp' => ['day' => '25.53']], + '"day" expected int|float, string received.', + ]; + yield 'feels-like object' => [ + ['feels_like' => 25.53], + '"feels_like" expected array, float received.', + ]; + yield 'pressure' => [ + ['pressure' => '1015.44'], + '"pressure" expected int|float, string received.', + ]; + yield 'humidity' => [ + ['humidity' => 48.5], + '"humidity" expected int, float received.', + ]; + yield 'visibility' => [ + ['visibility' => 10000.5], + '"visibility" expected int, float received.', + ]; + yield 'wind speed' => [ + ['wind_speed' => '6.17'], + '"wind_speed" expected int|float, string received.', + ]; + yield 'cloud coverage' => [ + ['clouds' => 41.5], + '"clouds" expected int, float received.', + ]; + yield 'conditions' => [ + ['weather' => 'Clouds'], + '"weather" expected array, string received.', + ]; + yield 'condition member' => [ + ['weather' => ['Clouds']], + '"weather.0" expected array, string received.', + ]; + yield 'scalar rain' => [ + ['rain' => ['1h' => 1.5]], + '"rain" expected int|float, array received.', + ]; + yield 'alert ID member' => [ + ['alerts' => [123]], + '"alerts.0" expected string, int received.', + ]; + } + + private static function fromFixture(string $path, int $index = 0): Period + { + $response = Fixture::json($path); + + return Period::fromArray($response['data'][$index]); + } +} diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/TemperatureTest.php b/tests/Unit/Entity/OneCall/OneDayTimeline/TemperatureTest.php new file mode 100644 index 0000000..2fbfa78 --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneDayTimeline/TemperatureTest.php @@ -0,0 +1,96 @@ + 25.53, + 'min' => 18.73, + 'max' => 26.99, + 'night' => 19.4, + 'eve' => 24.82, + 'morn' => 18.74, + ]); + + self::assertSame(25.53, $temperature->day()); + self::assertSame(Unit::CELSIUS, $temperature->dayUnit()); + self::assertSame('25.53 °C', $temperature->dayWithUnit()); + self::assertSame(18.73, $temperature->minimum()); + self::assertSame('18.73 °C', $temperature->minimumWithUnit()); + self::assertSame(26.99, $temperature->maximum()); + self::assertSame('26.99 °C', $temperature->maximumWithUnit()); + self::assertSame(19.4, $temperature->night()); + self::assertSame('19.4 °C', $temperature->nightWithUnit()); + self::assertSame(24.82, $temperature->evening()); + self::assertSame('24.82 °C', $temperature->eveningWithUnit()); + self::assertSame(18.74, $temperature->morning()); + self::assertSame('18.74 °C', $temperature->morningWithUnit()); + } + + public function testRetainsUnitsFromHydrationContext(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + + $temperature = Temperature::fromArray(['day' => 72.5], $context); + + self::assertSame(Unit::FAHRENHEIT, $temperature->dayUnit()); + self::assertSame('72.5 °F', $temperature->dayWithUnit()); + } + + public function testToleratesMissingNullAndUnknownFields(): void + { + $missing = Temperature::fromArray([]); + + self::assertNull($missing->day()); + self::assertNull($missing->minimum()); + self::assertNull($missing->maximum()); + self::assertNull($missing->night()); + self::assertNull($missing->evening()); + self::assertNull($missing->morning()); + + $temperature = Temperature::fromArray([ + 'day' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($temperature->day()); + self::assertNull($temperature->dayWithUnit()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(string $field): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected int|float, string received.', + $field, + )); + + Temperature::fromArray([$field => 'invalid']); + } + + public static function invalidFields(): iterable + { + yield 'day' => ['day']; + yield 'minimum' => ['min']; + yield 'maximum' => ['max']; + yield 'night' => ['night']; + yield 'evening' => ['eve']; + yield 'morning' => ['morn']; + } +} From 39ba37d928d8c23b09e33464e088cf53d8e2e847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 17:10:58 +0100 Subject: [PATCH 051/113] feat(one-call): add one-day timeline entity --- src/Entity/OneCall/FifteenMinuteTimeline.php | 76 ++--------- src/Entity/OneCall/OneDayTimeline.php | 60 ++++++++ src/Entity/OneCall/OneHourTimeline.php | 76 ++--------- src/Entity/OneCall/Timeline/TimelinePage.php | 120 ++++++++++++++++ .../Entity/OneCall/OneDayTimelineTest.php | 128 ++++++++++++++++++ 5 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 src/Entity/OneCall/OneDayTimeline.php create mode 100644 src/Entity/OneCall/Timeline/TimelinePage.php create mode 100644 tests/Unit/Entity/OneCall/OneDayTimelineTest.php diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php index 8b389b1..71df221 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -6,84 +6,38 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline\Period; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Hydration\OneCallPaginationUrlNormalizer; -use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; final class FifteenMinuteTimeline implements EntityInterface { private const ENDPOINT_PATH = '/data/4.0/onecall/timeline/15min'; /** - * @param list $periods + * @param TimelinePage $page */ private function __construct( - private readonly ?Coordinates $coordinates, - private readonly ?Timezone $timezone, - private readonly array $periods, - private readonly ?string $previousPageUrl, - private readonly ?string $nextPageUrl, + private readonly TimelinePage $page, ) {} public static function fromArray(array $data, ?Context $context = null): static { - $reader = PayloadReader::from($data, self::class); - $periods = []; - - foreach ($reader->nullableArray('data') ?? [] as $index => $period) { - if (!is_array($period)) { - throw HydrationException::invalidType( - self::class, - sprintf('data.%s', $index), - 'array', - $period, - ); - } - - $periods[] = Period::fromArray($period, $context); - } - - $hasCoordinates = array_key_exists('lat', $data) - || array_key_exists('lon', $data); - $hasTimezone = array_key_exists('timezone', $data) - || array_key_exists('timezone_offset', $data); - $previousPageUrl = $reader->nullableString('prev'); - $nextPageUrl = $reader->nullableString('next'); - - $previousPageUrl = $previousPageUrl === null - ? null - : OneCallPaginationUrlNormalizer::normalize( - $previousPageUrl, - self::class, - 'prev', - self::ENDPOINT_PATH, - ); - $nextPageUrl = $nextPageUrl === null - ? null - : OneCallPaginationUrlNormalizer::normalize( - $nextPageUrl, - self::class, - 'next', - self::ENDPOINT_PATH, - ); - - return new self( - coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, - timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, - periods: $periods, - previousPageUrl: $previousPageUrl, - nextPageUrl: $nextPageUrl, - ); + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + endpointPath: self::ENDPOINT_PATH, + periodClass: Period::class, + context: $context, + )); } public function coordinates(): ?Coordinates { - return $this->coordinates; + return $this->page->coordinates(); } public function timezone(): ?Timezone { - return $this->timezone; + return $this->page->timezone(); } /** @@ -91,16 +45,16 @@ public function timezone(): ?Timezone */ public function periods(): array { - return $this->periods; + return $this->page->periods(); } public function previousPageUrl(): ?string { - return $this->previousPageUrl; + return $this->page->previousPageUrl(); } public function nextPageUrl(): ?string { - return $this->nextPageUrl; + return $this->page->nextPageUrl(); } } diff --git a/src/Entity/OneCall/OneDayTimeline.php b/src/Entity/OneCall/OneDayTimeline.php new file mode 100644 index 0000000..5830ecb --- /dev/null +++ b/src/Entity/OneCall/OneDayTimeline.php @@ -0,0 +1,60 @@ + $page + */ + private function __construct( + private readonly TimelinePage $page, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + endpointPath: self::ENDPOINT_PATH, + periodClass: Period::class, + context: $context, + )); + } + + public function coordinates(): ?Coordinates + { + return $this->page->coordinates(); + } + + public function timezone(): ?Timezone + { + return $this->page->timezone(); + } + + /** + * @return list + */ + public function periods(): array + { + return $this->page->periods(); + } + + public function previousPageUrl(): ?string + { + return $this->page->previousPageUrl(); + } + + public function nextPageUrl(): ?string + { + return $this->page->nextPageUrl(); + } +} diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php index 6b7a2fa..25744fa 100644 --- a/src/Entity/OneCall/OneHourTimeline.php +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -6,84 +6,38 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Hydration\OneCallPaginationUrlNormalizer; -use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; final class OneHourTimeline implements EntityInterface { private const ENDPOINT_PATH = '/data/4.0/onecall/timeline/1h'; /** - * @param list $periods + * @param TimelinePage $page */ private function __construct( - private readonly ?Coordinates $coordinates, - private readonly ?Timezone $timezone, - private readonly array $periods, - private readonly ?string $previousPageUrl, - private readonly ?string $nextPageUrl, + private readonly TimelinePage $page, ) {} public static function fromArray(array $data, ?Context $context = null): static { - $reader = PayloadReader::from($data, self::class); - $periods = []; - - foreach ($reader->nullableArray('data') ?? [] as $index => $period) { - if (!is_array($period)) { - throw HydrationException::invalidType( - self::class, - sprintf('data.%s', $index), - 'array', - $period, - ); - } - - $periods[] = Period::fromArray($period, $context); - } - - $hasCoordinates = array_key_exists('lat', $data) - || array_key_exists('lon', $data); - $hasTimezone = array_key_exists('timezone', $data) - || array_key_exists('timezone_offset', $data); - $previousPageUrl = $reader->nullableString('prev'); - $nextPageUrl = $reader->nullableString('next'); - - $previousPageUrl = $previousPageUrl === null - ? null - : OneCallPaginationUrlNormalizer::normalize( - $previousPageUrl, - self::class, - 'prev', - self::ENDPOINT_PATH, - ); - $nextPageUrl = $nextPageUrl === null - ? null - : OneCallPaginationUrlNormalizer::normalize( - $nextPageUrl, - self::class, - 'next', - self::ENDPOINT_PATH, - ); - - return new self( - coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, - timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, - periods: $periods, - previousPageUrl: $previousPageUrl, - nextPageUrl: $nextPageUrl, - ); + return new self(TimelinePage::fromArray( + data: $data, + entity: self::class, + endpointPath: self::ENDPOINT_PATH, + periodClass: Period::class, + context: $context, + )); } public function coordinates(): ?Coordinates { - return $this->coordinates; + return $this->page->coordinates(); } public function timezone(): ?Timezone { - return $this->timezone; + return $this->page->timezone(); } /** @@ -91,16 +45,16 @@ public function timezone(): ?Timezone */ public function periods(): array { - return $this->periods; + return $this->page->periods(); } public function previousPageUrl(): ?string { - return $this->previousPageUrl; + return $this->page->previousPageUrl(); } public function nextPageUrl(): ?string { - return $this->nextPageUrl; + return $this->page->nextPageUrl(); } } diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php new file mode 100644 index 0000000..2b031ad --- /dev/null +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -0,0 +1,120 @@ + $periods + */ + private function __construct( + private readonly ?Coordinates $coordinates, + private readonly ?Timezone $timezone, + private readonly array $periods, + private readonly ?string $previousPageUrl, + private readonly ?string $nextPageUrl, + ) {} + + /** + * @template T of EntityInterface + * + * @param class-string $entity + * @param class-string $periodClass + * + * @return self + */ + public static function fromArray( + array $data, + string $entity, + string $endpointPath, + string $periodClass, + ?Context $context = null, + ): self { + $reader = PayloadReader::from($data, $entity); + $periods = []; + + foreach ($reader->nullableArray('data') ?? [] as $index => $period) { + if (!is_array($period)) { + throw HydrationException::invalidType( + $entity, + sprintf('data.%s', $index), + 'array', + $period, + ); + } + + $periods[] = $periodClass::fromArray($period, $context); + } + + $hasCoordinates = array_key_exists('lat', $data) + || array_key_exists('lon', $data); + $hasTimezone = array_key_exists('timezone', $data) + || array_key_exists('timezone_offset', $data); + $previousPageUrl = $reader->nullableString('prev'); + $nextPageUrl = $reader->nullableString('next'); + + $previousPageUrl = $previousPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $previousPageUrl, + $entity, + 'prev', + $endpointPath, + ); + $nextPageUrl = $nextPageUrl === null + ? null + : OneCallPaginationUrlNormalizer::normalize( + $nextPageUrl, + $entity, + 'next', + $endpointPath, + ); + + return new self( + coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, + timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, + periods: $periods, + previousPageUrl: $previousPageUrl, + nextPageUrl: $nextPageUrl, + ); + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function timezone(): ?Timezone + { + return $this->timezone; + } + + /** + * @return list + */ + public function periods(): array + { + return $this->periods; + } + + public function previousPageUrl(): ?string + { + return $this->previousPageUrl; + } + + public function nextPageUrl(): ?string + { + return $this->nextPageUrl; + } +} diff --git a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php new file mode 100644 index 0000000..96e7b01 --- /dev/null +++ b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php @@ -0,0 +1,128 @@ +coordinates()?->latitude()); + self::assertSame(-9.1393, $timeline->coordinates()?->longitude()); + self::assertSame('Europe/Lisbon', $timeline->timezone()?->identifier()); + self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); + self::assertCount(10, $timeline->periods()); + self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame(1786406400, $timeline->periods()[9]->dateTime()?->getTimestamp()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1784764800&units=metric&lang=en', + $timeline->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1786492800&units=metric&lang=en', + $timeline->nextPageUrl(), + ); + } + + public function testHydratesCapturedMixedHistoricalAndForecastTimeline(): void + { + $timeline = OneDayTimeline::fromArray( + Fixture::json('one-call/one-day/history.json'), + ); + + self::assertCount(10, $timeline->periods()); + self::assertSame(1785456000, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertNull($timeline->periods()[0]->precipitationProbability()); + self::assertSame(1785628800, $timeline->periods()[2]->dateTime()?->getTimestamp()); + self::assertSame(0.0, $timeline->periods()[2]->precipitationProbability()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = OneDayTimeline::fromArray([]); + + self::assertNull($missing->coordinates()); + self::assertNull($missing->timezone()); + self::assertSame([], $missing->periods()); + self::assertNull($missing->previousPageUrl()); + self::assertNull($missing->nextPageUrl()); + + $timeline = OneDayTimeline::fromArray([ + 'lat' => null, + 'timezone_offset' => null, + 'data' => [ + [], + ['dt' => null, 'unknown' => new \stdClass()], + ], + 'prev' => null, + 'next' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($timeline->coordinates()?->latitude()); + self::assertNull($timeline->coordinates()?->longitude()); + self::assertNull($timeline->timezone()?->identifier()); + self::assertNull($timeline->timezone()?->offsetSeconds()); + self::assertCount(2, $timeline->periods()); + self::assertNull($timeline->periods()[0]->dateTime()); + self::assertNull($timeline->periods()[1]->temperature()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + OneDayTimeline::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'latitude' => [ + ['lat' => '38.7'], + '"lat" expected int|float, string received.', + ]; + yield 'timezone' => [ + ['timezone' => 1], + '"timezone" expected string, int received.', + ]; + yield 'periods' => [ + ['data' => 'invalid'], + '"data" expected array, string received.', + ]; + yield 'period member' => [ + ['data' => ['invalid']], + '"data.0" expected array, string received.', + ]; + yield 'period field' => [ + ['data' => [['temp' => 'invalid']]], + '"temp" expected array, string received.', + ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'unexpected page host' => [ + ['next' => 'https://example.com/page?appid=secret'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + yield 'unexpected endpoint path' => [ + ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h'], + '"next" expected safe One Call pagination URL, "[redacted]" received.', + ]; + } +} From 03dfc383e6a8f4ed04ca9b6556236ee48ae8a528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 17:25:46 +0100 Subject: [PATCH 052/113] feat(one-call): add detailed alert entity --- src/Entity/OneCall/Alert.php | 138 ++++++++++++ .../OneCall/Alert/LocalizedDescription.php | 35 +++ src/Entity/OneCall/Current.php | 17 +- src/Entity/OneCall/MinuteTimeline/Period.php | 18 +- src/Entity/OneCall/OneDayTimeline/Period.php | 17 +- src/Entity/OneCall/Timeline/WeatherPeriod.php | 17 +- src/Hydration/PayloadReader.php | 29 +++ .../Alert/LocalizedDescriptionTest.php | 60 ++++++ tests/Unit/Entity/OneCall/AlertTest.php | 199 ++++++++++++++++++ tests/Unit/Hydration/PayloadReaderTest.php | 6 + 10 files changed, 471 insertions(+), 65 deletions(-) create mode 100644 src/Entity/OneCall/Alert.php create mode 100644 src/Entity/OneCall/Alert/LocalizedDescription.php create mode 100644 tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php create mode 100644 tests/Unit/Entity/OneCall/AlertTest.php diff --git a/src/Entity/OneCall/Alert.php b/src/Entity/OneCall/Alert.php new file mode 100644 index 0000000..839b77d --- /dev/null +++ b/src/Entity/OneCall/Alert.php @@ -0,0 +1,138 @@ + $descriptions + * @param list $tags + */ + private function __construct( + private readonly ?string $id, + private readonly ?string $senderName, + private readonly ?string $event, + private readonly ?\DateTimeImmutable $startsAt, + private readonly ?\DateTimeImmutable $endsAt, + private readonly array $descriptions, + private readonly array $tags, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + $reader = PayloadReader::from($data, self::class); + $descriptions = self::hydrateDescriptions($data, $context); + + return new self( + id: $reader->nullableString('id'), + senderName: $reader->nullableString('sender_name'), + event: $reader->nullableString('event'), + startsAt: $reader->nullableTimestamp('start'), + endsAt: $reader->nullableTimestamp('end'), + descriptions: $descriptions, + tags: $reader->nullableStringList('tags') ?? [], + ); + } + + public function id(): ?string + { + return $this->id; + } + + public function senderName(): ?string + { + return $this->senderName; + } + + public function event(): ?string + { + return $this->event; + } + + public function startsAt(): ?\DateTimeImmutable + { + return $this->startsAt; + } + + public function endsAt(): ?\DateTimeImmutable + { + return $this->endsAt; + } + + /** + * @return list + */ + public function descriptions(): array + { + return $this->descriptions; + } + + public function description(string $languageCode): ?string + { + foreach ($this->descriptions as $description) { + if ($description->languageCode() === $languageCode) { + return $description->text(); + } + } + + return null; + } + + /** + * @return list + */ + public function tags(): array + { + return $this->tags; + } + + /** + * @return list + */ + private static function hydrateDescriptions(array $data, ?Context $context): array + { + if (!array_key_exists('description', $data) || $data['description'] === null) { + return []; + } + + // The official contract documents one string, while live responses return + // localized object arrays: https://openweathermap.org/api/one-call-4 + if (is_string($data['description'])) { + return [LocalizedDescription::fromArray([ + 'description' => $data['description'], + ], $context)]; + } + + if (!is_array($data['description'])) { + throw HydrationException::invalidType( + self::class, + 'description', + 'string|array', + $data['description'], + ); + } + + $descriptions = []; + + foreach ($data['description'] as $index => $description) { + if (!is_array($description)) { + throw HydrationException::invalidType( + self::class, + sprintf('description.%s', $index), + 'array', + $description, + ); + } + + $descriptions[] = LocalizedDescription::fromArray($description, $context); + } + + return $descriptions; + } +} diff --git a/src/Entity/OneCall/Alert/LocalizedDescription.php b/src/Entity/OneCall/Alert/LocalizedDescription.php new file mode 100644 index 0000000..6d7672f --- /dev/null +++ b/src/Entity/OneCall/Alert/LocalizedDescription.php @@ -0,0 +1,35 @@ +nullableString('language'), + text: $reader->nullableString('description'), + ); + } + + public function languageCode(): ?string + { + return $this->languageCode; + } + + public function text(): ?string + { + return $this->text; + } +} diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php index b39efd2..4864ae9 100644 --- a/src/Entity/OneCall/Current.php +++ b/src/Entity/OneCall/Current.php @@ -65,21 +65,6 @@ public static function fromArray(array $data, ?Context $context = null): static $conditions[] = Condition::fromArray($condition, $context); } - $alertIds = []; - - foreach ($reader->nullableArray('data.0.alerts') ?? [] as $index => $alertId) { - if (!is_string($alertId)) { - throw HydrationException::invalidType( - self::class, - sprintf('data.0.alerts.%s', $index), - 'string', - $alertId, - ); - } - - $alertIds[] = $alertId; - } - $rain = $reader->nullableArray('data.0.rain'); $snow = $reader->nullableArray('data.0.snow'); $hasCoordinates = array_key_exists('lat', $data) @@ -119,7 +104,7 @@ public static function fromArray(array $data, ?Context $context = null): static conditions: $conditions, rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), - alertIds: $alertIds, + alertIds: $reader->nullableStringList('data.0.alerts') ?? [], units: UnitsResolver::fromContext($context), ); } diff --git a/src/Entity/OneCall/MinuteTimeline/Period.php b/src/Entity/OneCall/MinuteTimeline/Period.php index d06be77..d9acb3d 100644 --- a/src/Entity/OneCall/MinuteTimeline/Period.php +++ b/src/Entity/OneCall/MinuteTimeline/Period.php @@ -5,7 +5,6 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Formatting\MeasurementFormatter; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; @@ -23,25 +22,10 @@ private function __construct( public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $alertIds = []; - - foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { - if (!is_string($alertId)) { - throw HydrationException::invalidType( - self::class, - sprintf('alerts.%s', $index), - 'string', - $alertId, - ); - } - - $alertIds[] = $alertId; - } - return new self( dateTime: $reader->nullableTimestamp('dt'), precipitation: $reader->nullableFloat('precipitation'), - alertIds: $alertIds, + alertIds: $reader->nullableStringList('alerts') ?? [], ); } diff --git a/src/Entity/OneCall/OneDayTimeline/Period.php b/src/Entity/OneCall/OneDayTimeline/Period.php index bf5a86b..b15b553 100644 --- a/src/Entity/OneCall/OneDayTimeline/Period.php +++ b/src/Entity/OneCall/OneDayTimeline/Period.php @@ -62,21 +62,6 @@ public static function fromArray(array $data, ?Context $context = null): static $conditions[] = Condition::fromArray($condition, $context); } - $alertIds = []; - - foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { - if (!is_string($alertId)) { - throw HydrationException::invalidType( - self::class, - sprintf('alerts.%s', $index), - 'string', - $alertId, - ); - } - - $alertIds[] = $alertId; - } - $temperature = $reader->nullableArray('temp'); $feelsLikeTemperature = $reader->nullableArray('feels_like'); $hasWind = array_key_exists('wind_speed', $data) @@ -120,7 +105,7 @@ public static function fromArray(array $data, ?Context $context = null): static // still describes hourly objects: https://openweathermap.org/api/one-call-4 rain: $reader->nullableFloat('rain'), snow: $reader->nullableFloat('snow'), - alertIds: $alertIds, + alertIds: $reader->nullableStringList('alerts') ?? [], units: UnitsResolver::fromContext($context), ); } diff --git a/src/Entity/OneCall/Timeline/WeatherPeriod.php b/src/Entity/OneCall/Timeline/WeatherPeriod.php index 50bbb51..78766ec 100644 --- a/src/Entity/OneCall/Timeline/WeatherPeriod.php +++ b/src/Entity/OneCall/Timeline/WeatherPeriod.php @@ -61,21 +61,6 @@ public static function fromArray(array $data, ?Context $context = null): static $conditions[] = Condition::fromArray($condition, $context); } - $alertIds = []; - - foreach ($reader->nullableArray('alerts') ?? [] as $index => $alertId) { - if (!is_string($alertId)) { - throw HydrationException::invalidType( - static::class, - sprintf('alerts.%s', $index), - 'string', - $alertId, - ); - } - - $alertIds[] = $alertId; - } - $rain = $reader->nullableArray('rain'); $snow = $reader->nullableArray('snow'); $hasWind = array_key_exists('wind_speed', $data) @@ -108,7 +93,7 @@ public static function fromArray(array $data, ?Context $context = null): static conditions: $conditions, rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), - alertIds: $alertIds, + alertIds: $reader->nullableStringList('alerts') ?? [], units: UnitsResolver::fromContext($context), ); } diff --git a/src/Hydration/PayloadReader.php b/src/Hydration/PayloadReader.php index 4b7b34b..fa61ef3 100644 --- a/src/Hydration/PayloadReader.php +++ b/src/Hydration/PayloadReader.php @@ -69,6 +69,35 @@ public function nullableArray(string $path): ?array ); } + /** + * @return list|null + */ + public function nullableStringList(string $path): ?array + { + $values = $this->nullableArray($path); + + if ($values === null) { + return null; + } + + $strings = []; + + foreach ($values as $index => $value) { + if (!is_string($value)) { + throw HydrationException::invalidType( + $this->entity, + sprintf('%s.%s', $path, $index), + 'string', + $value + ); + } + + $strings[] = $value; + } + + return $strings; + } + public function nullableTimestamp(string $path): ?\DateTimeImmutable { $timestamp = $this->nullableInt($path); diff --git a/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php b/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php new file mode 100644 index 0000000..a6e93bf --- /dev/null +++ b/tests/Unit/Entity/OneCall/Alert/LocalizedDescriptionTest.php @@ -0,0 +1,60 @@ + 'es-CL', + 'description' => 'Precipitaciones moderadas', + ]); + + self::assertSame('es-CL', $description->languageCode()); + self::assertSame('Precipitaciones moderadas', $description->text()); + } + + public function testToleratesMissingNullUnknownAndOpenLanguageValues(): void + { + $missing = LocalizedDescription::fromArray([]); + + self::assertNull($missing->languageCode()); + self::assertNull($missing->text()); + + $description = LocalizedDescription::fromArray([ + 'language' => 'x-agency-local', + 'description' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertSame('x-agency-local', $description->languageCode()); + self::assertNull($description->text()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + LocalizedDescription::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'language' => [ + ['language' => 1], + '"language" expected string, int received.', + ]; + yield 'text' => [ + ['description' => []], + '"description" expected string, array received.', + ]; + } +} diff --git a/tests/Unit/Entity/OneCall/AlertTest.php b/tests/Unit/Entity/OneCall/AlertTest.php new file mode 100644 index 0000000..6e30937 --- /dev/null +++ b/tests/Unit/Entity/OneCall/AlertTest.php @@ -0,0 +1,199 @@ +id(), + ); + self::assertSame('Dirección Meteorológica de Chile', $alert->senderName()); + self::assertSame('', $alert->event()); + self::assertSame(1785578400, $alert->startsAt()?->getTimestamp()); + self::assertSame('UTC', $alert->startsAt()?->getTimezone()->getName()); + self::assertSame(1785708000, $alert->endsAt()?->getTimestamp()); + self::assertCount(1, $alert->descriptions()); + self::assertContainsOnlyInstancesOf( + LocalizedDescription::class, + $alert->descriptions(), + ); + self::assertSame('es-CL', $alert->descriptions()[0]->languageCode()); + self::assertSame( + 'Precipitaciones Normales a Moderadas en zonas de las regiones de ' + .'La Araucanía, Los Ríos y Los Lagos', + $alert->descriptions()[0]->text(), + ); + self::assertSame( + $alert->descriptions()[0]->text(), + $alert->description('es-CL'), + ); + self::assertNull($alert->description('en-US')); + self::assertSame(['Rain'], $alert->tags()); + } + + #[DataProvider('capturedAlerts')] + public function testHydratesOtherCapturedLocalizedAlerts( + string $fixture, + string $senderName, + string $language, + string $tag, + ): void { + $alert = Alert::fromArray(Fixture::json($fixture)); + + self::assertSame($senderName, $alert->senderName()); + self::assertSame('', $alert->event()); + self::assertSame($language, $alert->descriptions()[0]->languageCode()); + self::assertNotSame('', $alert->descriptions()[0]->text()); + self::assertSame([$tag], $alert->tags()); + } + + public function testReturnsFirstDescriptionMatchingLanguage(): void + { + $alert = Alert::fromArray([ + 'description' => [ + ['language' => 'en-US', 'description' => 'First description'], + ['language' => 'pt-PT', 'description' => 'Descrição'], + ['language' => 'en-US', 'description' => 'Second description'], + ], + ]); + + self::assertSame('First description', $alert->description('en-US')); + self::assertSame('Descrição', $alert->description('pt-PT')); + self::assertNull($alert->description('es-ES')); + } + + public static function capturedAlerts(): iterable + { + yield 'Houston air quality' => [ + 'one-call/alert/houston-air-quality.json', + 'NWS Houston/Galveston TX', + 'en-US', + 'Air quality', + ]; + yield 'Phoenix extreme heat' => [ + 'one-call/alert/phoenix-extreme-heat.json', + 'NWS Phoenix AZ', + 'en-US', + 'Extreme high temperature', + ]; + yield 'Tokyo thunderstorm' => [ + 'one-call/alert/tokyo-thunderstorm.json', + 'JMA', + 'ja-JP', + 'Thunderstorm', + ]; + } + + public function testNormalizesDocumentedStringDescription(): void + { + $alert = Alert::fromArray([ + 'description' => 'Documented alert description', + ]); + + self::assertCount(1, $alert->descriptions()); + self::assertNull($alert->descriptions()[0]->languageCode()); + self::assertSame('Documented alert description', $alert->descriptions()[0]->text()); + self::assertNull($alert->description('en-US')); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Alert::fromArray([]); + + self::assertNull($missing->id()); + self::assertNull($missing->senderName()); + self::assertNull($missing->event()); + self::assertNull($missing->startsAt()); + self::assertNull($missing->endsAt()); + self::assertSame([], $missing->descriptions()); + self::assertSame([], $missing->tags()); + + $alert = Alert::fromArray([ + 'id' => null, + 'sender_name' => null, + 'event' => '', + 'start' => null, + 'description' => [ + ['language' => null, 'description' => null, 'unknown' => true], + ], + 'tags' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($alert->id()); + self::assertSame('', $alert->event()); + self::assertCount(1, $alert->descriptions()); + self::assertNull($alert->descriptions()[0]->languageCode()); + self::assertNull($alert->descriptions()[0]->text()); + self::assertSame([], $alert->tags()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Alert::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'ID' => [ + ['id' => 1], + '"id" expected string, int received.', + ]; + yield 'sender name' => [ + ['sender_name' => []], + '"sender_name" expected string, array received.', + ]; + yield 'event' => [ + ['event' => 1], + '"event" expected string, int received.', + ]; + yield 'start' => [ + ['start' => '1785578400'], + '"start" expected int, string received.', + ]; + yield 'end' => [ + ['end' => 1785708000.5], + '"end" expected int, float received.', + ]; + yield 'description shape' => [ + ['description' => 1], + '"description" expected string|array, int received.', + ]; + yield 'description member' => [ + ['description' => ['invalid']], + '"description.0" expected array, string received.', + ]; + yield 'description language' => [ + ['description' => [['language' => 1]]], + '"language" expected string, int received.', + ]; + yield 'description text' => [ + ['description' => [['description' => []]]], + '"description" expected string, array received.', + ]; + yield 'tags' => [ + ['tags' => 'Rain'], + '"tags" expected array, string received.', + ]; + yield 'tag member' => [ + ['tags' => [1]], + '"tags.0" expected string, int received.', + ]; + } +} diff --git a/tests/Unit/Hydration/PayloadReaderTest.php b/tests/Unit/Hydration/PayloadReaderTest.php index 0f0eb53..074f5f4 100644 --- a/tests/Unit/Hydration/PayloadReaderTest.php +++ b/tests/Unit/Hydration/PayloadReaderTest.php @@ -18,6 +18,7 @@ public function testItReadsSupportedNullableValues(): void 'cloudiness' => 12.5, 'daylight' => true, 'rain' => ['1h' => 0.4], + 'alerts' => ['alert-1', 'alert-2'], ], 'Weather'); self::assertSame('Lisbon', $reader->nullableString('name')); @@ -26,6 +27,10 @@ public function testItReadsSupportedNullableValues(): void self::assertSame(12.5, $reader->nullableFloat('cloudiness')); self::assertTrue($reader->nullableBool('daylight')); self::assertSame(['1h' => 0.4], $reader->nullableArray('rain')); + self::assertSame( + ['alert-1', 'alert-2'], + $reader->nullableStringList('alerts') + ); } public function testMissingAndNullValuesAreTolerated(): void @@ -34,6 +39,7 @@ public function testMissingAndNullValuesAreTolerated(): void self::assertNull($reader->nullableString('missing')); self::assertNull($reader->nullableString('name')); + self::assertNull($reader->nullableStringList('alerts')); self::assertNull($reader->nullableTimestamp('observed_at')); } From 5772340b39d0feba6d2ac6d1b87e9517e4ac5003 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 17:34:37 +0100 Subject: [PATCH 053/113] feat(one-call): expose current weather resource --- README.md | 1 + docs/one-call.md | 67 ++++++++++++++++++++++ src/OpenWeatherMap.php | 6 ++ src/Resource/OneCall.php | 36 ++++++++++++ tests/Unit/Resource/OneCallTest.php | 89 +++++++++++++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 docs/one-call.md create mode 100644 src/Resource/OneCall.php create mode 100644 tests/Unit/Resource/OneCallTest.php diff --git a/README.md b/README.md index 6a7c195..307de8a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ use yet. ## Documentation +- [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) - [Geocoding](docs/geocoding.md) diff --git a/docs/one-call.md b/docs/one-call.md new file mode 100644 index 0000000..42400fa --- /dev/null +++ b/docs/one-call.md @@ -0,0 +1,67 @@ +# One Call 4.0 + +One Call 4.0 requires a separate OpenWeather subscription and includes free +daily API calls. Consult OpenWeather's official documentation for the current +allowance, pricing, usage limits, and account configuration before using these +endpoints in production. + +## Current + +See OpenWeather's +[official One Call API 4.0 documentation](https://openweathermap.org/api/one-call-4#current) +for the upstream endpoint contract and current subscription terms. + +Use `current()` with a latitude and longitude. Both coordinates are validated +before the request is sent. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Every response property may be absent or explicitly `null`. Coordinates and +timezone metadata describe the requested location, while `dateTime()` and the +astronomical timestamps remain UTC values. + +```php +echo $current->coordinates()?->latitude(); +echo $current->coordinates()?->longitude(); +echo $current->timezone()?->identifier(); +echo $current->dateTime()?->format(DATE_ATOM); +echo $current->temperature(); + +foreach ($current->conditions() as $condition) { + echo $condition->description(); +} +``` + +Units and language can be overridden for one immutable resource chain: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; + +$current = $api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE) + ->current(38.7223, -9.1393); +``` + +Raw measurement getters return nullable values. Companion methods expose the +effective unit and a locale-independent formatted value. With the default +metric configuration, for example: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; + +$current->temperature(); // 24.34 +$current->temperatureUnit(); // Unit::CELSIUS +$current->temperatureWithUnit(); // '24.34 °C' +``` diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 6f31c19..738add4 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -14,6 +14,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\Resource\AirPollution; use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; +use ProgrammatorDev\OpenWeatherMap\Resource\OneCall; use ProgrammatorDev\OpenWeatherMap\Resource\Weather; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -60,6 +61,11 @@ public function geocoding(): Geocoding return $this->resource(Geocoding::class); } + public function oneCall(): OneCall + { + return $this->resource(OneCall::class); + } + public function weather(): Weather { return $this->resource(Weather::class); diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php new file mode 100644 index 0000000..a978de3 --- /dev/null +++ b/src/Resource/OneCall.php @@ -0,0 +1,36 @@ +endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/current') + ->entity(Current::class); + + return $current; + } +} diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php new file mode 100644 index 0000000..889aaf7 --- /dev/null +++ b/tests/Unit/Resource/OneCallTest.php @@ -0,0 +1,89 @@ +respondWithFixture('one-call/current/success.json'); + + $current = $this->api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Current::class, $current); + self::assertSame(24.34, $current->temperature()); + self::assertSame(Unit::CELSIUS, $current->temperatureUnit()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/current', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testFluentOverridesAreRequestLocal(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + $this->respondWithFixture('one-call/current/success.json'); + + $oneCall = $this->api->oneCall(); + $overridden = $oneCall + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt'); + + $imperial = $overridden->current(38.7223, -9.1393); + $imperialRequest = $this->client->getLastRequest(); + $metric = $oneCall->current(38.7223, -9.1393); + $metricRequest = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $imperial->temperatureUnit()); + self::assertSame('72.5 °F', $imperial->temperatureWithUnit()); + self::assertSame(Unit::CELSIUS, $metric->temperatureUnit()); + self::assertSame('imperial', $this->query($imperialRequest)['units']); + self::assertSame('pt', $this->query($imperialRequest)['lang']); + self::assertSame('metric', $this->query($metricRequest)['units']); + self::assertSame('en', $this->query($metricRequest)['lang']); + } + + #[DataProvider('invalidCoordinates')] + public function testRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->current($latitude, $longitude); + } + + public static function invalidCoordinates(): iterable + { + yield 'invalid latitude' => [ + 90.0001, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 0, + 180.0001, + 'Longitude must be a finite number between -180 and 180.', + ]; + } +} From 8af3325058a96d6be9206be475669ee97b1575a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 17:40:51 +0100 Subject: [PATCH 054/113] feat(one-call): expose minute timeline --- docs/one-call.md | 38 +++++++++++++++++++ src/Resource/OneCall.php | 22 +++++++++++ tests/Unit/Resource/OneCallTest.php | 58 +++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/docs/one-call.md b/docs/one-call.md index 42400fa..4f10dda 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -65,3 +65,41 @@ $current->temperature(); // 24.34 $current->temperatureUnit(); // Unit::CELSIUS $current->temperatureWithUnit(); // '24.34 °C' ``` + +## Minute Timeline + +See OpenWeather's +[official One Call 4.0 minute forecast documentation](https://openweathermap.org/api/one-call-4#min) +for the upstream endpoint contract. + +Use `minuteTimeline()` with a latitude and longitude to retrieve up to 60 +one-minute forecast periods. + +```php +$timeline = $api->oneCall()->minuteTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The response exposes location metadata and a typed collection of periods. Each +period provides its UTC date and time, precipitation, and any referenced alert +IDs. + +```php +echo $timeline->coordinates()?->latitude(); +echo $timeline->timezone()?->identifier(); + +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->precipitation(); + echo $period->precipitationWithUnit(); + + foreach ($period->alertIds() as $alertId) { + echo $alertId; + } +} +``` + +One-minute precipitation is always expressed in millimetres per hour, so its +unit is unaffected by the configured weather unit system. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index a978de3..6377e8e 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -33,4 +34,25 @@ public function current(float $latitude, float $longitude): Current return $current; } + + public function minuteTimeline(float $latitude, float $longitude): MinuteTimeline + { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/one-call-4#min + /** @var MinuteTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1min') + ->entity(MinuteTimeline::class); + + return $timeline; + } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 889aaf7..92a7545 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -5,6 +5,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -61,6 +62,51 @@ public function testFluentOverridesAreRequestLocal(): void self::assertSame('en', $this->query($metricRequest)['lang']); } + public function testGetsMinuteTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-minute/success.json'); + + $timeline = $this->api->oneCall()->minuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(MinuteTimeline::class, $timeline); + self::assertCount(60, $timeline->periods()); + self::assertSame(1785669780, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1min', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testMinuteTimelineAcceptsFluentConfiguration(): void + { + $this->respondWithFixture('one-call/one-minute/precipitation-alerts.json'); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->minuteTimeline(-38.4, -71.58); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::MILLIMETERS_PER_HOUR, $timeline->periods()[0]->precipitationUnit()); + self::assertSame([ + 'lat' => '-38.4', + 'lon' => '-71.58', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + #[DataProvider('invalidCoordinates')] public function testRejectsInvalidCoordinates( float $latitude, @@ -73,6 +119,18 @@ public function testRejectsInvalidCoordinates( $this->api->oneCall()->current($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testMinuteTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->minuteTimeline($latitude, $longitude); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From b7d30e374b15daea094e67054e34a018f9ed5150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:16:08 +0100 Subject: [PATCH 055/113] feat(one-call): expose fifteen-minute timeline --- docs/one-call.md | 36 +++++++++++++++++ src/Resource/OneCall.php | 24 +++++++++++ tests/Unit/Resource/OneCallTest.php | 63 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/docs/one-call.md b/docs/one-call.md index 4f10dda..8340262 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -103,3 +103,39 @@ foreach ($timeline->periods() as $period) { One-minute precipitation is always expressed in millimetres per hour, so its unit is unaffected by the configured weather unit system. + +## Fifteen-minute Timeline + +See OpenWeather's +[official One Call 4.0 15-minute forecast documentation](https://openweathermap.org/api/one-call-4#15min) +for the upstream endpoint contract. + +Use `fifteenMinuteTimeline()` with a latitude and longitude to retrieve the +initial page of 15-minute forecast periods. + +```php +$timeline = $api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +The response exposes location metadata, up to 50 typed periods, and passive +pagination URLs when OpenWeather provides them. Pagination URLs are normalized +to HTTPS and stripped of the API key before they are exposed. + +```php +echo $timeline->coordinates()?->latitude(); +echo $timeline->timezone()?->identifier(); +echo $timeline->previousPageUrl(); +echo $timeline->nextPageUrl(); + +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); +} +``` + +The pagination URL getters return metadata only and do not make another API +request. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index 6377e8e..ee5b25d 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; @@ -55,4 +56,27 @@ public function minuteTimeline(float $latitude, float $longitude): MinuteTimelin return $timeline; } + + public function fifteenMinuteTimeline( + float $latitude, + float $longitude, + ): FifteenMinuteTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/one-call-4#15min + /** @var FifteenMinuteTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/15min') + ->entity(FifteenMinuteTimeline::class); + + return $timeline; + } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 92a7545..4bfc5c5 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -5,6 +5,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; @@ -107,6 +108,56 @@ public function testMinuteTimelineAcceptsFluentConfiguration(): void ], $this->query($request)); } + public function testGetsFifteenMinuteTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/fifteen-minute/success.json'); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $timeline); + self::assertCount(50, $timeline->periods()); + self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertNull($timeline->previousPageUrl()); + self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->fifteenMinuteTimeline(38.7223, -9.1393); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperatureWithUnit()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'imperial', + 'lang' => 'pt', + 'appid' => 'api-key', + ], $this->query($request)); + } + #[DataProvider('invalidCoordinates')] public function testRejectsInvalidCoordinates( float $latitude, @@ -131,6 +182,18 @@ public function testMinuteTimelineRejectsInvalidCoordinates( $this->api->oneCall()->minuteTimeline($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testFifteenMinuteTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->fifteenMinuteTimeline($latitude, $longitude); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From ead55e08b4cba0a1a3e885b22ac1da45143028bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:23:16 +0100 Subject: [PATCH 056/113] feat(one-call): expose one-hour timeline --- docs/one-call.md | 44 ++++++++++++++++ src/Resource/OneCall.php | 26 ++++++++++ tests/Unit/Resource/OneCallTest.php | 80 +++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/docs/one-call.md b/docs/one-call.md index 8340262..d619821 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -139,3 +139,47 @@ foreach ($timeline->periods() as $period) { The pagination URL getters return metadata only and do not make another API request. + +## One-hour Timeline + +See OpenWeather's +[official One Call 4.0 hourly forecast documentation](https://openweathermap.org/api/one-call-4#hourly) +for the upstream endpoint contract. + +Use `oneHourTimeline()` with a latitude and longitude to retrieve the default +hourly timeline. + +```php +$timeline = $api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Pass an optional `DateTimeInterface` value to select a historical or future +starting point. It is sent to OpenWeather as a Unix timestamp. Actual data +availability is determined by OpenWeather. + +```php +$timeline = $api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, + start: new DateTimeImmutable('2 days ago'), +); +``` + +The response contains up to 20 typed periods. Historical and forecast periods +share the same entity and expose their UTC timestamp through `dateTime()`. + +```php +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->temperature(); + echo $period->precipitationProbability(); + echo $period->rain()?->lastHour(); + echo $period->snow()?->lastHour(); +} +``` + +As with the 15-minute timeline, normalized `previousPageUrl()` and +`nextPageUrl()` values are passive metadata and do not make another request. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index ee5b25d..d124f74 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -6,6 +6,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -79,4 +80,29 @@ public function fifteenMinuteTimeline( return $timeline; } + + public function oneHourTimeline( + float $latitude, + float $longitude, + ?\DateTimeInterface $start = null, + ): OneHourTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + // https://openweathermap.org/api/one-call-4#hourly + /** @var OneHourTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $start?->getTimestamp(), + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1h') + ->entity(OneHourTimeline::class); + + return $timeline; + } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 4bfc5c5..9abe2c3 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -7,6 +7,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -158,6 +159,73 @@ public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void ], $this->query($request)); } + public function testGetsOneHourTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + + $timeline = $this->api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneHourTimeline::class, $timeline); + self::assertCount(20, $timeline->periods()); + self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertStringNotContainsString('appid', $timeline->previousPageUrl() ?? ''); + self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsOneHourTimelineFromStart(): void + { + $this->respondWithFixture('one-call/one-hour/history.json'); + + $timeline = $this->api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, + start: new \DateTimeImmutable('@1785495600'), + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785495600, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785495600', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testOneHourTimelineAcceptsFluentConfiguration(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":72.5}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->oneHourTimeline(38.7223, -9.1393); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperatureWithUnit()); + self::assertSame('imperial', $this->query($request)['units']); + self::assertSame('pt', $this->query($request)['lang']); + } + #[DataProvider('invalidCoordinates')] public function testRejectsInvalidCoordinates( float $latitude, @@ -194,6 +262,18 @@ public function testFifteenMinuteTimelineRejectsInvalidCoordinates( $this->api->oneCall()->fifteenMinuteTimeline($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testOneHourTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->oneHourTimeline($latitude, $longitude); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From 374e0e95c15612e760c607fedc3703c107f2675d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:27:40 +0100 Subject: [PATCH 057/113] feat(one-call): allow timeline count selection --- docs/one-call.md | 9 +++++++++ src/Resource/OneCall.php | 12 ++++++++++++ tests/Unit/Resource/OneCallTest.php | 22 ++++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/one-call.md b/docs/one-call.md index d619821..06fdc20 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -117,9 +117,14 @@ initial page of 15-minute forecast periods. $timeline = $api->oneCall()->fifteenMinuteTimeline( latitude: 38.7223, longitude: -9.1393, + count: 10, ); ``` +The optional positive `count` limits the requested page size. When it is +omitted, OpenWeather chooses the page size. OpenWeather also determines the +supported maximum. + The response exposes location metadata, up to 50 typed periods, and passive pagination URLs when OpenWeather provides them. Pagination URLs are normalized to HTTPS and stripped of the API key before they are exposed. @@ -165,9 +170,13 @@ $timeline = $api->oneCall()->oneHourTimeline( latitude: 38.7223, longitude: -9.1393, start: new DateTimeImmutable('2 days ago'), + count: 10, ); ``` +The optional positive `count` limits the requested page size. OpenWeather +determines the supported maximum. + The response contains up to 20 typed periods. Historical and forecast periods share the same entity and expose their UTC timestamp through `dateTime()`. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index d124f74..ca23fae 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -61,10 +61,15 @@ public function minuteTimeline(float $latitude, float $longitude): MinuteTimelin public function fifteenMinuteTimeline( float $latitude, float $longitude, + ?int $count = null, ): FifteenMinuteTimeline { $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + // https://openweathermap.org/api/one-call-4#15min /** @var FifteenMinuteTimeline $timeline */ $timeline = $this @@ -72,6 +77,7 @@ public function fifteenMinuteTimeline( ->queries([ 'lat' => $latitude, 'lon' => $longitude, + 'cnt' => $count, 'units' => $this->resolvedUnits(), 'lang' => $this->resolvedLanguage(), ]) @@ -85,10 +91,15 @@ public function oneHourTimeline( float $latitude, float $longitude, ?\DateTimeInterface $start = null, + ?int $count = null, ): OneHourTimeline { $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + // https://openweathermap.org/api/one-call-4#hourly /** @var OneHourTimeline $timeline */ $timeline = $this @@ -97,6 +108,7 @@ public function oneHourTimeline( 'lat' => $latitude, 'lon' => $longitude, 'start' => $start?->getTimestamp(), + 'cnt' => $count, 'units' => $this->resolvedUnits(), 'lang' => $this->resolvedLanguage(), ]) diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 9abe2c3..4f5d2f7 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -145,7 +145,7 @@ public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void ->oneCall() ->withUnits(Units::IMPERIAL) ->withLanguage('pt') - ->fifteenMinuteTimeline(38.7223, -9.1393); + ->fifteenMinuteTimeline(38.7223, -9.1393, count: 3); $request = $this->client->getLastRequest(); self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); @@ -153,6 +153,7 @@ public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void self::assertSame([ 'lat' => '38.7223', 'lon' => '-9.1393', + 'cnt' => '3', 'units' => 'imperial', 'lang' => 'pt', 'appid' => 'api-key', @@ -217,11 +218,12 @@ public function testOneHourTimelineAcceptsFluentConfiguration(): void ->oneCall() ->withUnits(Units::IMPERIAL) ->withLanguage('pt') - ->oneHourTimeline(38.7223, -9.1393); + ->oneHourTimeline(38.7223, -9.1393, count: 3); $request = $this->client->getLastRequest(); self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); self::assertSame('72.5 °F', $timeline->periods()[0]->temperatureWithUnit()); + self::assertSame('3', $this->query($request)['cnt']); self::assertSame('imperial', $this->query($request)['units']); self::assertSame('pt', $this->query($request)['lang']); } @@ -274,6 +276,22 @@ public function testOneHourTimelineRejectsInvalidCoordinates( $this->api->oneCall()->oneHourTimeline($latitude, $longitude); } + public function testFifteenMinuteTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->fifteenMinuteTimeline(38.7223, -9.1393, count: 0); + } + + public function testOneHourTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->oneHourTimeline(38.7223, -9.1393, count: 0); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From 5727754b2e476a03185506386ff76f031bfea8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:32:20 +0100 Subject: [PATCH 058/113] docs: simplify API usage guides --- docs/air-pollution.md | 40 ++++++++++++++++------------------- docs/geocoding.md | 2 +- docs/one-call.md | 49 ++++++++++++++++++------------------------- docs/weather.md | 34 +++++++++++++----------------- 4 files changed, 54 insertions(+), 71 deletions(-) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index c8dcab0..c3634b8 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -7,10 +7,9 @@ standard free and paid subscriptions. See OpenWeather's [official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) -for the upstream endpoint contract. +for API details. -Use `current()` with a latitude and longitude. Both coordinates are validated -before the request is sent. +Use `current()` with a latitude and longitude. ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; @@ -23,8 +22,8 @@ $current = $api->airPollution()->current( ); ``` -The returned `Current` entity exposes the single observation directly. Every -response property may be absent or explicitly `null`. +The returned `Current` entity exposes the observation directly. Every property +may be absent or explicitly `null`. ```php echo $current->coordinates()?->latitude(); @@ -55,8 +54,8 @@ echo $components?->coarseParticulateMatter(); echo $components?->ammonia(); ``` -Raw concentration getters return nullable floats. Companion methods expose the -unit and a locale-independent formatted value: +Concentration getters return nullable floats. Companion methods provide the +unit and a formatted value: ```php use ProgrammatorDev\OpenWeatherMap\Enum\Unit; @@ -70,10 +69,9 @@ $components?->fineParticulateMatterWithUnit(); // '5.89 µg/m³' The Air Pollution Forecast API provides hourly periods for four days. See the [official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) -for the upstream endpoint contract. +for API details. -Use `forecast()` with a latitude and longitude. Both coordinates are validated -before the request is sent. +Use `forecast()` with a latitude and longitude. ```php $forecast = $api->airPollution()->forecast( @@ -82,9 +80,9 @@ $forecast = $api->airPollution()->forecast( ); ``` -The returned `Forecast` entity exposes the response coordinates and a typed -collection of hourly periods. Missing or `null` period lists become empty -arrays, and every period property may be absent or explicitly `null`. +The returned `Forecast` entity exposes the response coordinates and hourly +periods. Missing or `null` period lists become empty arrays, and every period +property may be absent or explicitly `null`. ```php echo $forecast->coordinates()?->latitude(); @@ -103,15 +101,13 @@ Forecast periods use the same OpenWeather Air Quality Index and fixed ## History The Historical Air Pollution API returns hourly observations for a coordinate -and date range. OpenWeather documents historical availability from November 27, -2020, although actual availability may vary. See the +and date range. See the [official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) -for the upstream endpoint contract. +for availability and API details. Use `history()` with a latitude, longitude, start date, and end date. The date -arguments accept any `DateTimeInterface` implementation and are sent as Unix -timestamps. The end must be after or equal to the start and cannot be in the -future. +arguments accept any `DateTimeInterface` implementation. The end must be after +or equal to the start and cannot be in the future. ```php $history = $api->airPollution()->history( @@ -122,9 +118,9 @@ $history = $api->airPollution()->history( ); ``` -The returned `History` entity exposes the response coordinates and a typed -collection of hourly periods. A valid range for which OpenWeather has no data -returns an empty collection. +The returned `History` entity exposes the response coordinates and hourly +periods. A valid range for which OpenWeather has no data returns an empty +collection. ```php echo $history->coordinates()?->latitude(); diff --git a/docs/geocoding.md b/docs/geocoding.md index 3c7a1ab..70ecb4c 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -3,7 +3,7 @@ The Geocoding API is available on OpenWeather's standard free and paid subscriptions. See the [official Geocoding API documentation](https://openweathermap.org/api/geocoding-api) -for the upstream endpoint contract. +for API details. ## Lookup By Name diff --git a/docs/one-call.md b/docs/one-call.md index 06fdc20..c4b4a6b 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -9,10 +9,9 @@ endpoints in production. See OpenWeather's [official One Call API 4.0 documentation](https://openweathermap.org/api/one-call-4#current) -for the upstream endpoint contract and current subscription terms. +for API details and current subscription terms. -Use `current()` with a latitude and longitude. Both coordinates are validated -before the request is sent. +Use `current()` with a latitude and longitude. ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; @@ -41,7 +40,7 @@ foreach ($current->conditions() as $condition) { } ``` -Units and language can be overridden for one immutable resource chain: +Configure units and language for a request: ```php use ProgrammatorDev\OpenWeatherMap\Enum\Language; @@ -54,9 +53,8 @@ $current = $api ->current(38.7223, -9.1393); ``` -Raw measurement getters return nullable values. Companion methods expose the -effective unit and a locale-independent formatted value. With the default -metric configuration, for example: +Measurement getters return nullable values. Companion methods provide the unit +and a formatted value. With the default metric configuration, for example: ```php use ProgrammatorDev\OpenWeatherMap\Enum\Unit; @@ -70,7 +68,7 @@ $current->temperatureWithUnit(); // '24.34 °C' See OpenWeather's [official One Call 4.0 minute forecast documentation](https://openweathermap.org/api/one-call-4#min) -for the upstream endpoint contract. +for API details. Use `minuteTimeline()` with a latitude and longitude to retrieve up to 60 one-minute forecast periods. @@ -82,9 +80,8 @@ $timeline = $api->oneCall()->minuteTimeline( ); ``` -The response exposes location metadata and a typed collection of periods. Each -period provides its UTC date and time, precipitation, and any referenced alert -IDs. +The response exposes location metadata and forecast periods. Each period +provides its UTC date and time, precipitation, and any referenced alert IDs. ```php echo $timeline->coordinates()?->latitude(); @@ -108,7 +105,7 @@ unit is unaffected by the configured weather unit system. See OpenWeather's [official One Call 4.0 15-minute forecast documentation](https://openweathermap.org/api/one-call-4#15min) -for the upstream endpoint contract. +for API details. Use `fifteenMinuteTimeline()` with a latitude and longitude to retrieve the initial page of 15-minute forecast periods. @@ -121,13 +118,10 @@ $timeline = $api->oneCall()->fifteenMinuteTimeline( ); ``` -The optional positive `count` limits the requested page size. When it is -omitted, OpenWeather chooses the page size. OpenWeather also determines the -supported maximum. +The optional positive `count` limits the requested page size. -The response exposes location metadata, up to 50 typed periods, and passive -pagination URLs when OpenWeather provides them. Pagination URLs are normalized -to HTTPS and stripped of the API key before they are exposed. +The response exposes location metadata, up to 50 periods, and pagination URLs +when OpenWeather provides them. ```php echo $timeline->coordinates()?->latitude(); @@ -142,14 +136,13 @@ foreach ($timeline->periods() as $period) { } ``` -The pagination URL getters return metadata only and do not make another API -request. +The pagination URL getters do not make another API request. ## One-hour Timeline See OpenWeather's [official One Call 4.0 hourly forecast documentation](https://openweathermap.org/api/one-call-4#hourly) -for the upstream endpoint contract. +for API details. Use `oneHourTimeline()` with a latitude and longitude to retrieve the default hourly timeline. @@ -162,8 +155,7 @@ $timeline = $api->oneCall()->oneHourTimeline( ``` Pass an optional `DateTimeInterface` value to select a historical or future -starting point. It is sent to OpenWeather as a Unix timestamp. Actual data -availability is determined by OpenWeather. +starting point. Availability depends on OpenWeather. ```php $timeline = $api->oneCall()->oneHourTimeline( @@ -174,11 +166,10 @@ $timeline = $api->oneCall()->oneHourTimeline( ); ``` -The optional positive `count` limits the requested page size. OpenWeather -determines the supported maximum. +The optional positive `count` limits the requested page size. -The response contains up to 20 typed periods. Historical and forecast periods -share the same entity and expose their UTC timestamp through `dateTime()`. +The response contains up to 20 periods. Historical and forecast periods expose +their UTC date and time through `dateTime()`. ```php foreach ($timeline->periods() as $period) { @@ -190,5 +181,5 @@ foreach ($timeline->periods() as $period) { } ``` -As with the 15-minute timeline, normalized `previousPageUrl()` and -`nextPageUrl()` values are passive metadata and do not make another request. +As with the 15-minute timeline, `previousPageUrl()` and `nextPageUrl()` do not +make another request. diff --git a/docs/weather.md b/docs/weather.md index 75185f1..689ab3f 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -5,10 +5,9 @@ The Current Weather API is available on OpenWeather's standard free and paid subscriptions. See the [official Current Weather API documentation](https://openweathermap.org/api/current) -for the upstream endpoint contract. +for API details. -Use `current()` with a latitude and longitude. Both coordinates are validated -before the request is sent. +Use `current()` with a latitude and longitude. ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; @@ -21,9 +20,9 @@ $current = $api->weather()->current( ); ``` -The method returns a `Current` entity. Every response property may be -absent or explicitly `null`; missing or `null` condition lists become empty -arrays. Contextual response coordinates are grouped under `coordinates()`. +`current()` returns a `Current` entity. Every property may be absent or +explicitly `null`; missing or `null` condition lists become empty arrays. +Coordinates are available through `coordinates()`. ```php echo $current->name(); @@ -65,8 +64,8 @@ echo $current->rain()?->lastHour(); echo $current->snow()?->lastHour(); ``` -Observation, sunrise, and sunset timestamps are nullable `DateTimeImmutable` -values normalized to UTC. `timezoneOffset()` retains the location's offset +Observation, sunrise, and sunset timestamps are nullable UTC +`DateTimeImmutable` values. `timezoneOffset()` provides the location's offset from UTC in seconds. ## Forecast @@ -74,12 +73,10 @@ from UTC in seconds. The 5 Day / 3 Hour Forecast API is available on OpenWeather's standard free and paid subscriptions. See the [official forecast documentation](https://openweathermap.org/api/forecast5) -for the upstream endpoint contract. +for API details. Use `forecast()` with a latitude and longitude. The optional `count` limits the -number of three-hour periods returned. It must be a positive integer; no -maximum is imposed by this library because the official documentation does not -define one. +number of three-hour periods returned and must be positive. ```php $forecast = $api->weather()->forecast( @@ -89,7 +86,7 @@ $forecast = $api->weather()->forecast( ); ``` -The method returns a `Forecast` entity containing its periods and city +`forecast()` returns a `Forecast` entity containing its periods and city metadata. Missing or `null` period lists become empty arrays. ```php @@ -119,9 +116,8 @@ Forecast, sunrise, and sunset timestamps are nullable UTC ## Units And Language -Weather requests use the API configuration by default. Request-local fluent -overrides are immutable and do not affect later calls through the original -resource. +Weather requests use the API configuration by default. Configure units and +language for a request with `withUnits()` and `withLanguage()`. ```php use ProgrammatorDev\OpenWeatherMap\Enum\Language; @@ -137,9 +133,9 @@ $current = $api ); ``` -Raw measurement getters remain numeric. Companion `Unit` and `WithUnit` -methods expose the effective request unit and a locale-independent formatted -value. For example, when a metric response contains a temperature of `22.55`: +Measurement getters remain numeric. Companion `Unit` and `WithUnit` methods +provide the unit and a formatted value. For example, when a metric response +contains a temperature of `22.55`: ```php $current->temperature(); // 22.55 From c5e29e65be7f7f63e294b1a5f02560863f2e7fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:35:46 +0100 Subject: [PATCH 059/113] feat(one-call): expose one-day timeline --- docs/one-call.md | 49 ++++++++++++++++ src/Resource/OneCall.php | 32 +++++++++++ tests/Unit/Resource/OneCallTest.php | 89 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/docs/one-call.md b/docs/one-call.md index c4b4a6b..c74f1b0 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -183,3 +183,52 @@ foreach ($timeline->periods() as $period) { As with the 15-minute timeline, `previousPageUrl()` and `nextPageUrl()` do not make another request. + +## One-day Timeline + +See OpenWeather's +[official One Call 4.0 daily forecast documentation](https://openweathermap.org/api/one-call-4#daily) +for API details. + +Use `oneDayTimeline()` with a latitude and longitude to retrieve the default +daily timeline. + +```php +$timeline = $api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, +); +``` + +Use `start` to select a historical or future starting point and `count` to +limit the requested page size. + +```php +$timeline = $api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, + start: new DateTimeImmutable('2 days ago'), + count: 5, +); +``` + +Daily periods provide UTC dates, astronomy, daily temperatures, weather +measurements, conditions, precipitation probability, rain, snow, and alert +references. + +```php +foreach ($timeline->periods() as $period) { + echo $period->dateTime()?->format(DATE_ATOM); + echo $period->sunriseAt()?->format(DATE_ATOM); + echo $period->moonPhase(); + echo $period->temperature()?->day(); + echo $period->temperature()?->minimum(); + echo $period->temperature()?->maximum(); + echo $period->rain(); + echo $period->snow(); +} +``` + +OpenWeather does not currently define units for the daily scalar rain and snow +values, so these getters return raw nullable floats. `previousPageUrl()` and +`nextPageUrl()` do not make another request. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index ca23fae..026e90a 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -6,6 +6,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithLanguage; use ProgrammatorDev\OpenWeatherMap\Resource\Concern\WithUnits; @@ -117,4 +118,35 @@ public function oneHourTimeline( return $timeline; } + + public function oneDayTimeline( + float $latitude, + float $longitude, + ?\DateTimeInterface $start = null, + ?int $count = null, + ): OneDayTimeline { + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + + if ($count !== null) { + $count = Assert::positiveInteger($count, 'timeline count'); + } + + // https://openweathermap.org/api/one-call-4#daily + /** @var OneDayTimeline $timeline */ + $timeline = $this + ->endpoint() + ->queries([ + 'lat' => $latitude, + 'lon' => $longitude, + 'start' => $start?->getTimestamp(), + 'cnt' => $count, + 'units' => $this->resolvedUnits(), + 'lang' => $this->resolvedLanguage(), + ]) + ->get('/data/4.0/onecall/timeline/1day') + ->entity(OneDayTimeline::class); + + return $timeline; + } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 4f5d2f7..22d10a6 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -7,6 +7,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; @@ -228,6 +229,74 @@ public function testOneHourTimelineAcceptsFluentConfiguration(): void self::assertSame('pt', $this->query($request)['lang']); } + public function testGetsOneDayTimelineByCoordinates(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + + $timeline = $this->api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneDayTimeline::class, $timeline); + self::assertCount(10, $timeline->periods()); + self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertStringNotContainsString('appid', $timeline->previousPageUrl() ?? ''); + self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsOneDayTimelineFromStart(): void + { + $this->respondWithFixture('one-call/one-day/history.json'); + + $timeline = $this->api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, + start: new \DateTimeImmutable('@1785456000'), + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785456000, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785456000', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testOneDayTimelineAcceptsFluentConfigurationAndCount(): void + { + $this->client->addResponse(new Response( + body: '{"data":[{"temp":{"day":72.5}}]}', + )); + + $timeline = $this->api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage('pt') + ->oneDayTimeline(38.7223, -9.1393, count: 3); + $request = $this->client->getLastRequest(); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperature()?->dayUnit()); + self::assertSame('72.5 °F', $timeline->periods()[0]->temperature()?->dayWithUnit()); + self::assertSame('3', $this->query($request)['cnt']); + self::assertSame('imperial', $this->query($request)['units']); + self::assertSame('pt', $this->query($request)['lang']); + } + #[DataProvider('invalidCoordinates')] public function testRejectsInvalidCoordinates( float $latitude, @@ -276,6 +345,18 @@ public function testOneHourTimelineRejectsInvalidCoordinates( $this->api->oneCall()->oneHourTimeline($latitude, $longitude); } + #[DataProvider('invalidCoordinates')] + public function testOneDayTimelineRejectsInvalidCoordinates( + float $latitude, + float $longitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->oneCall()->oneDayTimeline($latitude, $longitude); + } + public function testFifteenMinuteTimelineRejectsInvalidCount(): void { $this->expectException(\InvalidArgumentException::class); @@ -292,6 +373,14 @@ public function testOneHourTimelineRejectsInvalidCount(): void $this->api->oneCall()->oneHourTimeline(38.7223, -9.1393, count: 0); } + public function testOneDayTimelineRejectsInvalidCount(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The timeline count must be at least 1.'); + + $this->api->oneCall()->oneDayTimeline(38.7223, -9.1393, count: 0); + } + public static function invalidCoordinates(): iterable { yield 'invalid latitude' => [ From f0b080f9bb3aa5ffdb64e0b7c0a27eb7100b772d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 19:47:26 +0100 Subject: [PATCH 060/113] feat(one-call): expose alert lookup --- docs/one-call.md | 36 +++++++++++++++++++++++++ src/Resource/OneCall.php | 17 ++++++++++++ tests/Unit/Resource/OneCallTest.php | 42 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/docs/one-call.md b/docs/one-call.md index c74f1b0..b1264f4 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -232,3 +232,39 @@ foreach ($timeline->periods() as $period) { OpenWeather does not currently define units for the daily scalar rain and snow values, so these getters return raw nullable floats. `previousPageUrl()` and `nextPageUrl()` do not make another request. + +## Alert + +See OpenWeather's +[official One Call 4.0 weather alert documentation](https://openweathermap.org/api/one-call-4#alerts) +for API details. + +Current weather and timeline periods may provide alert IDs. Use `alert()` to +retrieve the corresponding alert. + +```php +$alert = $api->oneCall()->alert($id); +``` + +Alerts provide sender and event information, validity dates, localized +descriptions, and tags. + +```php +echo $alert->id(); +echo $alert->senderName(); +echo $alert->event(); +echo $alert->startsAt()?->format(DATE_ATOM); +echo $alert->endsAt()?->format(DATE_ATOM); +echo $alert->description('en-US'); + +foreach ($alert->descriptions() as $description) { + echo $description->languageCode(); + echo $description->text(); +} + +foreach ($alert->tags() as $tag) { + echo $tag; +} +``` + +`description()` returns the first exact language-code match or `null`. diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index 026e90a..0a49e5c 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource; use ProgrammatorDev\Api\Resource; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Alert; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; @@ -149,4 +150,20 @@ public function oneDayTimeline( return $timeline; } + + public function alert(string $id): Alert + { + $id = Assert::notBlank($id, 'alert ID'); + + // https://openweathermap.org/api/one-call-4#alerts + /** @var Alert $alert */ + $alert = $this + ->endpoint() + ->get('/data/4.0/onecall/alert/{id}', [ + 'id' => $id, + ]) + ->entity(Alert::class); + + return $alert; + } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 22d10a6..5b99f60 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -4,6 +4,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Alert; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; @@ -15,6 +16,47 @@ final class OneCallTest extends ApiTestCase { + public function testGetsAlertById(): void + { + $this->respondWithFixture('one-call/alert/chile-rain.json'); + $id = 'urn:oid:2.49.0.0.152.0.2026.7.31.14.20.43:' + .'f1076d7511a15522d5a6e41917020bc0'; + + $alert = $this->api->oneCall()->alert($id); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Alert::class, $alert); + self::assertSame($id, $alert->id()); + self::assertSame('Dirección Meteorológica de Chile', $alert->senderName()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/4.0/onecall/alert/'.rawurlencode($id), + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testEncodesOpaqueAlertIdAsOnePathSegment(): void + { + $this->client->addResponse(new Response(body: '{}')); + + $this->api->oneCall()->alert('agency:alert/segment'); + $request = $this->client->getLastRequest(); + + self::assertSame( + '/data/4.0/onecall/alert/agency%3Aalert%2Fsegment', + $request->getUri()->getPath(), + ); + } + + public function testRejectsBlankAlertId(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The alert ID must be a non-empty string.'); + + $this->api->oneCall()->alert(' '); + } + public function testGetsCurrentWeatherByCoordinates(): void { $this->respondWithFixture('one-call/current/success.json'); From 282974eca5471ed0eccebaa082e0c529b74daf83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 20:52:14 +0100 Subject: [PATCH 061/113] refactor(one-call): simplify pagination URL handling --- src/Entity/OneCall/FifteenMinuteTimeline.php | 3 --- src/Entity/OneCall/OneDayTimeline.php | 3 --- src/Entity/OneCall/OneHourTimeline.php | 3 --- src/Entity/OneCall/Timeline/TimelinePage.php | 3 --- src/Hydration/OneCallPaginationUrlNormalizer.php | 16 +++++----------- src/OpenWeatherMap.php | 3 ++- .../Entity/OneCall/FifteenMinuteTimelineTest.php | 4 ---- tests/Unit/Entity/OneCall/OneDayTimelineTest.php | 4 ---- .../Unit/Entity/OneCall/OneHourTimelineTest.php | 4 ---- 9 files changed, 7 insertions(+), 36 deletions(-) diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php index 71df221..e014388 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -10,8 +10,6 @@ final class FifteenMinuteTimeline implements EntityInterface { - private const ENDPOINT_PATH = '/data/4.0/onecall/timeline/15min'; - /** * @param TimelinePage $page */ @@ -24,7 +22,6 @@ public static function fromArray(array $data, ?Context $context = null): static return new self(TimelinePage::fromArray( data: $data, entity: self::class, - endpointPath: self::ENDPOINT_PATH, periodClass: Period::class, context: $context, )); diff --git a/src/Entity/OneCall/OneDayTimeline.php b/src/Entity/OneCall/OneDayTimeline.php index 5830ecb..3604894 100644 --- a/src/Entity/OneCall/OneDayTimeline.php +++ b/src/Entity/OneCall/OneDayTimeline.php @@ -10,8 +10,6 @@ final class OneDayTimeline implements EntityInterface { - private const ENDPOINT_PATH = '/data/4.0/onecall/timeline/1day'; - /** * @param TimelinePage $page */ @@ -24,7 +22,6 @@ public static function fromArray(array $data, ?Context $context = null): static return new self(TimelinePage::fromArray( data: $data, entity: self::class, - endpointPath: self::ENDPOINT_PATH, periodClass: Period::class, context: $context, )); diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php index 25744fa..a7c9413 100644 --- a/src/Entity/OneCall/OneHourTimeline.php +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -10,8 +10,6 @@ final class OneHourTimeline implements EntityInterface { - private const ENDPOINT_PATH = '/data/4.0/onecall/timeline/1h'; - /** * @param TimelinePage $page */ @@ -24,7 +22,6 @@ public static function fromArray(array $data, ?Context $context = null): static return new self(TimelinePage::fromArray( data: $data, entity: self::class, - endpointPath: self::ENDPOINT_PATH, periodClass: Period::class, context: $context, )); diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php index 2b031ad..fb02bc1 100644 --- a/src/Entity/OneCall/Timeline/TimelinePage.php +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -37,7 +37,6 @@ private function __construct( public static function fromArray( array $data, string $entity, - string $endpointPath, string $periodClass, ?Context $context = null, ): self { @@ -70,7 +69,6 @@ public static function fromArray( $previousPageUrl, $entity, 'prev', - $endpointPath, ); $nextPageUrl = $nextPageUrl === null ? null @@ -78,7 +76,6 @@ public static function fromArray( $nextPageUrl, $entity, 'next', - $endpointPath, ); return new self( diff --git a/src/Hydration/OneCallPaginationUrlNormalizer.php b/src/Hydration/OneCallPaginationUrlNormalizer.php index 187cd80..41497d7 100644 --- a/src/Hydration/OneCallPaginationUrlNormalizer.php +++ b/src/Hydration/OneCallPaginationUrlNormalizer.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Hydration; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; final class OneCallPaginationUrlNormalizer { @@ -14,20 +15,13 @@ public static function normalize( string $url, string $entity, string $path, - string $expectedPath, ): string { $parts = parse_url($url); if ( !is_array($parts) - || !isset($parts['scheme'], $parts['host'], $parts['path']) - || !in_array(strtolower($parts['scheme']), ['http', 'https'], true) + || !isset($parts['host'], $parts['path']) || strtolower($parts['host']) !== self::HOST - || $parts['path'] !== $expectedPath - || isset($parts['user']) - || isset($parts['pass']) - || isset($parts['port']) - || isset($parts['fragment']) ) { throw self::invalidUrl($entity, $path); } @@ -53,7 +47,7 @@ private static function withoutApiKey(string $query): string $name = rawurldecode(explode('=', $parameter, 2)[0]); - if (strtolower($name) !== 'appid') { + if (strtolower($name) !== OpenWeatherMap::AUTHENTICATION_KEY) { $parameters[] = $parameter; } } @@ -63,8 +57,8 @@ private static function withoutApiKey(string $query): string private static function invalidUrl(string $entity, string $path): HydrationException { - // Pagination URLs may contain credentials, so invalid values are never - // copied into exception messages. + // Pagination URLs may contain credentials, + // so invalid values are never copied into exception messages. return HydrationException::invalidValue( $entity, $path, diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 738add4..81964ec 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -20,6 +20,7 @@ class OpenWeatherMap extends Api { + public const AUTHENTICATION_KEY = 'appid'; public const OPTION_LANGUAGE = 'language'; public const OPTION_UNITS = 'units'; @@ -38,7 +39,7 @@ public function __construct(string $apiKey, array $options = []) ]); $this->baseUrl(self::BASE_URL); - $this->auth()->query('appid', $apiKey); + $this->auth()->query(self::AUTHENTICATION_KEY, $apiKey); $this->responses()->json(); $this->errors()->when(static fn (ErrorContext $context): ?ApiException => match (true) { diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php index b8b1f05..cb2bf80 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -137,9 +137,5 @@ public static function invalidFields(): iterable ['next' => 'https://example.com/page?appid=secret'], '"next" expected safe One Call pagination URL, "[redacted]" received.', ]; - yield 'unexpected endpoint path' => [ - ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php index 96e7b01..1fa2ee7 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php @@ -120,9 +120,5 @@ public static function invalidFields(): iterable ['next' => 'https://example.com/page?appid=secret'], '"next" expected safe One Call pagination URL, "[redacted]" received.', ]; - yield 'unexpected endpoint path' => [ - ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index 05ce937..e77a243 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -111,9 +111,5 @@ public static function invalidFields(): iterable ['next' => 'https://example.com/page?appid=secret'], '"next" expected safe One Call pagination URL, "[redacted]" received.', ]; - yield 'unexpected endpoint path' => [ - ['next' => 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } From 38ca8417c3c9a8fcfc00e68129e6f37735a25032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 2 Aug 2026 21:13:16 +0100 Subject: [PATCH 062/113] refactor(one-call): normalize pagination URLs with PSR-7 --- composer.json | 1 + src/Entity/OneCall/Timeline/TimelinePage.php | 12 +--- .../OneCallPaginationUrlNormalizer.php | 69 +++++-------------- .../OneCall/FifteenMinuteTimelineTest.php | 20 +++--- .../Entity/OneCall/OneDayTimelineTest.php | 4 -- .../Entity/OneCall/OneHourTimelineTest.php | 4 -- 6 files changed, 34 insertions(+), 76 deletions(-) diff --git a/composer.json b/composer.json index 866ae04..e88cd3c 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "php": ">=8.1", + "php-http/discovery": "^1.20", "programmatordev/php-api-sdk": "^3.1" }, "require-dev": { diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php index fb02bc1..0dfbf95 100644 --- a/src/Entity/OneCall/Timeline/TimelinePage.php +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -65,18 +65,10 @@ public static function fromArray( $previousPageUrl = $previousPageUrl === null ? null - : OneCallPaginationUrlNormalizer::normalize( - $previousPageUrl, - $entity, - 'prev', - ); + : OneCallPaginationUrlNormalizer::normalize($previousPageUrl); $nextPageUrl = $nextPageUrl === null ? null - : OneCallPaginationUrlNormalizer::normalize( - $nextPageUrl, - $entity, - 'next', - ); + : OneCallPaginationUrlNormalizer::normalize($nextPageUrl); return new self( coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, diff --git a/src/Hydration/OneCallPaginationUrlNormalizer.php b/src/Hydration/OneCallPaginationUrlNormalizer.php index 41497d7..0df151b 100644 --- a/src/Hydration/OneCallPaginationUrlNormalizer.php +++ b/src/Hydration/OneCallPaginationUrlNormalizer.php @@ -2,68 +2,37 @@ namespace ProgrammatorDev\OpenWeatherMap\Hydration; -use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use Http\Discovery\Psr17FactoryDiscovery; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; final class OneCallPaginationUrlNormalizer { - private const HOST = 'api.openweathermap.org'; - private function __construct() {} - public static function normalize( - string $url, - string $entity, - string $path, - ): string { - $parts = parse_url($url); - - if ( - !is_array($parts) - || !isset($parts['host'], $parts['path']) - || strtolower($parts['host']) !== self::HOST - ) { - throw self::invalidUrl($entity, $path); - } - - $query = self::withoutApiKey($parts['query'] ?? ''); - - return sprintf( - 'https://%s%s%s', - self::HOST, - $parts['path'], - $query === '' ? '' : sprintf('?%s', $query), - ); - } - - private static function withoutApiKey(string $query): string + public static function normalize(string $url): string { - $parameters = []; + $uri = Psr17FactoryDiscovery::findUriFactory()->createUri($url); + $queryParameters = []; - foreach (explode('&', $query) as $parameter) { - if ($parameter === '') { - continue; - } + parse_str($uri->getQuery(), $queryParameters); - $name = rawurldecode(explode('=', $parameter, 2)[0]); + // Authentication is reapplied by the SDK when the pagination link is followed, + // so the response must not retain its embedded API key. + unset($queryParameters[OpenWeatherMap::AUTHENTICATION_KEY]); - if (strtolower($name) !== OpenWeatherMap::AUTHENTICATION_KEY) { - $parameters[] = $parameter; - } + // Keep relative references relative so the resolver can apply the configured base URL; + // absolute references are upgraded to HTTPS. + if ($uri->getHost() !== '') { + $uri = $uri->withScheme('https'); } - return implode('&', $parameters); - } - - private static function invalidUrl(string $entity, string $path): HydrationException - { - // Pagination URLs may contain credentials, - // so invalid values are never copied into exception messages. - return HydrationException::invalidValue( - $entity, - $path, - 'safe One Call pagination URL', - '[redacted]', + return (string) $uri->withQuery( + http_build_query( + $queryParameters, + '', + '&', + PHP_QUERY_RFC3986, + ), ); } } diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php index cb2bf80..45402bd 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -59,6 +59,18 @@ public function testNormalizesCapturedBidirectionalPagination(): void ); } + public function testNormalizesPaginationUrl(): void + { + $timeline = FifteenMinuteTimeline::fromArray([ + 'next' => 'http://example.com/page?cursor=next&appid=secret', + ]); + + self::assertSame( + 'https://example.com/page?cursor=next', + $timeline->nextPageUrl(), + ); + } + public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = FifteenMinuteTimeline::fromArray([]); @@ -129,13 +141,5 @@ public static function invalidFields(): iterable ['next' => []], '"next" expected string, array received.', ]; - yield 'malformed page URL' => [ - ['next' => 'not a URL'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; - yield 'unexpected page host' => [ - ['next' => 'https://example.com/page?appid=secret'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php index 1fa2ee7..486da15 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php @@ -116,9 +116,5 @@ public static function invalidFields(): iterable ['prev' => 1], '"prev" expected string, int received.', ]; - yield 'unexpected page host' => [ - ['next' => 'https://example.com/page?appid=secret'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index e77a243..f355dab 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -107,9 +107,5 @@ public static function invalidFields(): iterable ['prev' => 1], '"prev" expected string, int received.', ]; - yield 'unexpected page host' => [ - ['next' => 'https://example.com/page?appid=secret'], - '"next" expected safe One Call pagination URL, "[redacted]" received.', - ]; } } From e49cdc4657a565d5889612159045704138ad389c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 7 Aug 2026 18:36:54 +0100 Subject: [PATCH 063/113] refactor(one-call): prepare resolver-based pagination --- composer.json | 2 +- src/Entity/OneCall/Timeline/TimelinePage.php | 6 +++--- .../PaginationUrlNormalizer.php} | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/Hydration/{OneCallPaginationUrlNormalizer.php => OneCall/PaginationUrlNormalizer.php} (91%) diff --git a/composer.json b/composer.json index e88cd3c..0f9282b 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "require": { "php": ">=8.1", "php-http/discovery": "^1.20", - "programmatordev/php-api-sdk": "^3.1" + "programmatordev/php-api-sdk": "^3.2" }, "require-dev": { "monolog/monolog": "^3.10", diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php index 0dfbf95..355abb5 100644 --- a/src/Entity/OneCall/Timeline/TimelinePage.php +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -7,7 +7,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timezone; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Hydration\OneCallPaginationUrlNormalizer; +use ProgrammatorDev\OpenWeatherMap\Hydration\OneCall\PaginationUrlNormalizer; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; /** @@ -65,10 +65,10 @@ public static function fromArray( $previousPageUrl = $previousPageUrl === null ? null - : OneCallPaginationUrlNormalizer::normalize($previousPageUrl); + : PaginationUrlNormalizer::normalize($previousPageUrl); $nextPageUrl = $nextPageUrl === null ? null - : OneCallPaginationUrlNormalizer::normalize($nextPageUrl); + : PaginationUrlNormalizer::normalize($nextPageUrl); return new self( coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, diff --git a/src/Hydration/OneCallPaginationUrlNormalizer.php b/src/Hydration/OneCall/PaginationUrlNormalizer.php similarity index 91% rename from src/Hydration/OneCallPaginationUrlNormalizer.php rename to src/Hydration/OneCall/PaginationUrlNormalizer.php index 0df151b..fd2bfd5 100644 --- a/src/Hydration/OneCallPaginationUrlNormalizer.php +++ b/src/Hydration/OneCall/PaginationUrlNormalizer.php @@ -1,11 +1,11 @@ Date: Sat, 8 Aug 2026 09:31:49 +0100 Subject: [PATCH 064/113] feat(one-call): add one-hour timeline pagination --- src/Entity/OneCall/FifteenMinuteTimeline.php | 10 --- src/Entity/OneCall/OneDayTimeline.php | 10 --- src/Entity/OneCall/OneHourTimeline.php | 20 +++-- .../OneCall/OneHourTimeline/Pagination.php | 77 +++++++++++++++++ src/Entity/OneCall/Timeline/TimelinePage.php | 24 ------ .../OneCall/PaginationUrlNormalizer.php | 23 +----- .../OneCall/FifteenMinuteTimelineTest.php | 54 ------------ .../Entity/OneCall/OneDayTimelineTest.php | 16 ---- .../Entity/OneCall/OneHourTimelineTest.php | 18 ++-- tests/Unit/Resource/OneCallTest.php | 82 +++++++++++++++++-- 10 files changed, 178 insertions(+), 156 deletions(-) create mode 100644 src/Entity/OneCall/OneHourTimeline/Pagination.php diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php index e014388..f88bdf9 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -44,14 +44,4 @@ public function periods(): array { return $this->page->periods(); } - - public function previousPageUrl(): ?string - { - return $this->page->previousPageUrl(); - } - - public function nextPageUrl(): ?string - { - return $this->page->nextPageUrl(); - } } diff --git a/src/Entity/OneCall/OneDayTimeline.php b/src/Entity/OneCall/OneDayTimeline.php index 3604894..307f062 100644 --- a/src/Entity/OneCall/OneDayTimeline.php +++ b/src/Entity/OneCall/OneDayTimeline.php @@ -44,14 +44,4 @@ public function periods(): array { return $this->page->periods(); } - - public function previousPageUrl(): ?string - { - return $this->page->previousPageUrl(); - } - - public function nextPageUrl(): ?string - { - return $this->page->nextPageUrl(); - } } diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php index a7c9413..e995bd3 100644 --- a/src/Entity/OneCall/OneHourTimeline.php +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; @@ -15,16 +16,22 @@ final class OneHourTimeline implements EntityInterface */ private function __construct( private readonly TimelinePage $page, + private readonly Pagination $pagination, ) {} public static function fromArray(array $data, ?Context $context = null): static { - return new self(TimelinePage::fromArray( + $page = TimelinePage::fromArray( data: $data, entity: self::class, periodClass: Period::class, context: $context, - )); + ); + + return new self( + page: $page, + pagination: Pagination::fromArray($data, $context), + ); } public function coordinates(): ?Coordinates @@ -45,13 +52,8 @@ public function periods(): array return $this->page->periods(); } - public function previousPageUrl(): ?string - { - return $this->page->previousPageUrl(); - } - - public function nextPageUrl(): ?string + public function pagination(): Pagination { - return $this->page->nextPageUrl(); + return $this->pagination; } } diff --git a/src/Entity/OneCall/OneHourTimeline/Pagination.php b/src/Entity/OneCall/OneHourTimeline/Pagination.php new file mode 100644 index 0000000..4122563 --- /dev/null +++ b/src/Entity/OneCall/OneHourTimeline/Pagination.php @@ -0,0 +1,77 @@ +nullableString('prev'); + $nextPageUrl = $reader->nullableString('next'); + + return new self( + previousPageUrl: $previousPageUrl === null + ? null + : PaginationUrlNormalizer::normalize($previousPageUrl), + nextPageUrl: $nextPageUrl === null + ? null + : PaginationUrlNormalizer::normalize($nextPageUrl), + resolver: $context?->resolver(), + ); + } + + public function previousPageUrl(): ?string + { + return $this->previousPageUrl; + } + + public function nextPageUrl(): ?string + { + return $this->nextPageUrl; + } + + public function nextPage(): ?OneHourTimeline + { + return $this->resolve($this->nextPageUrl); + } + + public function previousPage(): ?OneHourTimeline + { + return $this->resolve($this->previousPageUrl); + } + + private function resolve(?string $pageUrl): ?OneHourTimeline + { + if ($pageUrl === null) { + return null; + } + + if ($this->resolver === null) { + throw new \LogicException( + 'Pagination navigation requires a timeline returned by the API.', + ); + } + + /** @var OneHourTimeline $timeline */ + $timeline = $this->resolver->entity( + $pageUrl, + OneHourTimeline::class, + ); + + return $timeline; + } +} diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php index 355abb5..7ef3c08 100644 --- a/src/Entity/OneCall/Timeline/TimelinePage.php +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -7,7 +7,6 @@ use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timezone; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; -use ProgrammatorDev\OpenWeatherMap\Hydration\OneCall\PaginationUrlNormalizer; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; /** @@ -22,8 +21,6 @@ private function __construct( private readonly ?Coordinates $coordinates, private readonly ?Timezone $timezone, private readonly array $periods, - private readonly ?string $previousPageUrl, - private readonly ?string $nextPageUrl, ) {} /** @@ -60,22 +57,11 @@ public static function fromArray( || array_key_exists('lon', $data); $hasTimezone = array_key_exists('timezone', $data) || array_key_exists('timezone_offset', $data); - $previousPageUrl = $reader->nullableString('prev'); - $nextPageUrl = $reader->nullableString('next'); - - $previousPageUrl = $previousPageUrl === null - ? null - : PaginationUrlNormalizer::normalize($previousPageUrl); - $nextPageUrl = $nextPageUrl === null - ? null - : PaginationUrlNormalizer::normalize($nextPageUrl); return new self( coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, periods: $periods, - previousPageUrl: $previousPageUrl, - nextPageUrl: $nextPageUrl, ); } @@ -96,14 +82,4 @@ public function periods(): array { return $this->periods; } - - public function previousPageUrl(): ?string - { - return $this->previousPageUrl; - } - - public function nextPageUrl(): ?string - { - return $this->nextPageUrl; - } } diff --git a/src/Hydration/OneCall/PaginationUrlNormalizer.php b/src/Hydration/OneCall/PaginationUrlNormalizer.php index fd2bfd5..4d9bc61 100644 --- a/src/Hydration/OneCall/PaginationUrlNormalizer.php +++ b/src/Hydration/OneCall/PaginationUrlNormalizer.php @@ -3,7 +3,6 @@ namespace ProgrammatorDev\OpenWeatherMap\Hydration\OneCall; use Http\Discovery\Psr17FactoryDiscovery; -use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; final class PaginationUrlNormalizer { @@ -12,27 +11,9 @@ private function __construct() {} public static function normalize(string $url): string { $uri = Psr17FactoryDiscovery::findUriFactory()->createUri($url); - $queryParameters = []; - - parse_str($uri->getQuery(), $queryParameters); - - // Authentication is reapplied by the SDK when the pagination link is followed, - // so the response must not retain its embedded API key. - unset($queryParameters[OpenWeatherMap::AUTHENTICATION_KEY]); // Keep relative references relative so the resolver can apply the configured base URL; - // absolute references are upgraded to HTTPS. - if ($uri->getHost() !== '') { - $uri = $uri->withScheme('https'); - } - - return (string) $uri->withQuery( - http_build_query( - $queryParameters, - '', - '&', - PHP_QUERY_RFC3986, - ), - ); + // absolute references are upgraded to HTTPS without changing their query. + return (string) ($uri->getHost() === '' ? $uri : $uri->withScheme('https')); } } diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php index 45402bd..39eb92d 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -25,50 +25,6 @@ public function testHydratesCapturedTimeline(): void self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1785714300, $timeline->periods()[49]->dateTime()?->getTimestamp()); - self::assertNull($timeline->previousPageUrl()); - self::assertSame( - 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' - .'cnt=50&lat=38.7223&lon=-9.1393&start=1785715200&units=metric&lang=en', - $timeline->nextPageUrl(), - ); - } - - public function testNormalizesCapturedBidirectionalPagination(): void - { - $timeline = FifteenMinuteTimeline::fromArray( - Fixture::json('one-call/fifteen-minute/pagination.json'), - ); - - self::assertSame( - 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' - .'cnt=50&lat=38.7223&lon=-9.1393&start=1785670200&units=metric&lang=en', - $timeline->previousPageUrl(), - ); - self::assertSame( - 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' - .'cnt=50&lat=38.7223&lon=-9.1393&start=1785760200&units=metric&lang=en', - $timeline->nextPageUrl(), - ); - self::assertStringNotContainsString( - 'appid', - $timeline->previousPageUrl() ?? '', - ); - self::assertStringNotContainsString( - 'appid', - $timeline->nextPageUrl() ?? '', - ); - } - - public function testNormalizesPaginationUrl(): void - { - $timeline = FifteenMinuteTimeline::fromArray([ - 'next' => 'http://example.com/page?cursor=next&appid=secret', - ]); - - self::assertSame( - 'https://example.com/page?cursor=next', - $timeline->nextPageUrl(), - ); } public function testToleratesMissingNullUnknownAndPartialFields(): void @@ -78,8 +34,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); self::assertSame([], $missing->periods()); - self::assertNull($missing->previousPageUrl()); - self::assertNull($missing->nextPageUrl()); $timeline = FifteenMinuteTimeline::fromArray([ 'lat' => null, @@ -133,13 +87,5 @@ public static function invalidFields(): iterable ['data' => [['pressure' => '1015.75']]], '"pressure" expected int|float, string received.', ]; - yield 'previous page URL type' => [ - ['prev' => 1], - '"prev" expected string, int received.', - ]; - yield 'next page URL type' => [ - ['next' => []], - '"next" expected string, array received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php index 486da15..aff1e0d 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php @@ -25,16 +25,6 @@ public function testHydratesCapturedTimeline(): void self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1786406400, $timeline->periods()[9]->dateTime()?->getTimestamp()); - self::assertSame( - 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' - .'cnt=10&lat=38.7223&lon=-9.1393&start=1784764800&units=metric&lang=en', - $timeline->previousPageUrl(), - ); - self::assertSame( - 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' - .'cnt=10&lat=38.7223&lon=-9.1393&start=1786492800&units=metric&lang=en', - $timeline->nextPageUrl(), - ); } public function testHydratesCapturedMixedHistoricalAndForecastTimeline(): void @@ -57,8 +47,6 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); self::assertSame([], $missing->periods()); - self::assertNull($missing->previousPageUrl()); - self::assertNull($missing->nextPageUrl()); $timeline = OneDayTimeline::fromArray([ 'lat' => null, @@ -112,9 +100,5 @@ public static function invalidFields(): iterable ['data' => [['temp' => 'invalid']]], '"temp" expected array, string received.', ]; - yield 'previous page URL type' => [ - ['prev' => 1], - '"prev" expected string, int received.', - ]; } } diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index f355dab..5c31f5a 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; @@ -23,17 +24,20 @@ public function testHydratesCapturedTimeline(): void self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); self::assertCount(20, $timeline->periods()); self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1785736800, $timeline->periods()[19]->dateTime()?->getTimestamp()); self::assertSame( 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' - .'cnt=20&lat=38.7223&lon=-9.1393&start=1785596400&units=metric&lang=en', - $timeline->previousPageUrl(), + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785596400' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), ); self::assertSame( 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' - .'cnt=20&lat=38.7223&lon=-9.1393&start=1785740400&units=metric&lang=en', - $timeline->nextPageUrl(), + .'cnt=20&lat=38.7223&lon=-9.1393&start=1785740400' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), ); } @@ -56,8 +60,10 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); self::assertSame([], $missing->periods()); - self::assertNull($missing->previousPageUrl()); - self::assertNull($missing->nextPageUrl()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); $timeline = OneHourTimeline::fromArray([ 'lat' => null, diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 5b99f60..5b9f1d6 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -165,8 +165,6 @@ public function testGetsFifteenMinuteTimelineByCoordinates(): void self::assertInstanceOf(FifteenMinuteTimeline::class, $timeline); self::assertCount(50, $timeline->periods()); self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); - self::assertNull($timeline->previousPageUrl()); - self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); self::assertSame('GET', $request->getMethod()); self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); self::assertSame([ @@ -216,8 +214,14 @@ public function testGetsOneHourTimelineByCoordinates(): void self::assertInstanceOf(OneHourTimeline::class, $timeline); self::assertCount(20, $timeline->periods()); self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); - self::assertStringNotContainsString('appid', $timeline->previousPageUrl() ?? ''); - self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->previousPageUrl() ?? '', + ); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); self::assertSame('GET', $request->getMethod()); self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); self::assertSame([ @@ -251,6 +255,74 @@ public function testGetsOneHourTimelineFromStart(): void ], $this->query($request)); } + public function testGetsNextOneHourTimelinePage(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785740400}]}', + )); + + $timeline = $this->api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneHourTimeline::class, $nextPage); + self::assertSame(1785740400, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '20', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785740400', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousOneHourTimelinePage(): void + { + $this->respondWithFixture('one-call/one-hour/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785596400}]}', + )); + + $timeline = $this->api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneHourTimeline::class, $previousPage); + self::assertSame(1785596400, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1h', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '20', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785596400', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + public function testOneHourTimelineAcceptsFluentConfiguration(): void { $this->client->addResponse(new Response( @@ -284,8 +356,6 @@ public function testGetsOneDayTimelineByCoordinates(): void self::assertInstanceOf(OneDayTimeline::class, $timeline); self::assertCount(10, $timeline->periods()); self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); - self::assertStringNotContainsString('appid', $timeline->previousPageUrl() ?? ''); - self::assertStringNotContainsString('appid', $timeline->nextPageUrl() ?? ''); self::assertSame('GET', $request->getMethod()); self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); self::assertSame([ From 0ead8aef2a803c665d99f445fb812528d0081ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 09:36:57 +0100 Subject: [PATCH 065/113] refactor(one-call): share timeline pagination --- src/Entity/OneCall/OneHourTimeline.php | 12 ++++- .../Pagination.php | 46 +++++++++++++------ .../Entity/OneCall/OneHourTimelineTest.php | 2 +- 3 files changed, 44 insertions(+), 16 deletions(-) rename src/Entity/OneCall/{OneHourTimeline => Timeline}/Pagination.php (63%) diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php index e995bd3..21ea7f6 100644 --- a/src/Entity/OneCall/OneHourTimeline.php +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -5,14 +5,15 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; -use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; final class OneHourTimeline implements EntityInterface { /** * @param TimelinePage $page + * @param Pagination $pagination */ private function __construct( private readonly TimelinePage $page, @@ -30,7 +31,11 @@ public static function fromArray(array $data, ?Context $context = null): static return new self( page: $page, - pagination: Pagination::fromArray($data, $context), + pagination: Pagination::fromArray( + data: $data, + timelineClass: self::class, + context: $context, + ), ); } @@ -52,6 +57,9 @@ public function periods(): array return $this->page->periods(); } + /** + * @return Pagination + */ public function pagination(): Pagination { return $this->pagination; diff --git a/src/Entity/OneCall/OneHourTimeline/Pagination.php b/src/Entity/OneCall/Timeline/Pagination.php similarity index 63% rename from src/Entity/OneCall/OneHourTimeline/Pagination.php rename to src/Entity/OneCall/Timeline/Pagination.php index 4122563..4d9e3a9 100644 --- a/src/Entity/OneCall/OneHourTimeline/Pagination.php +++ b/src/Entity/OneCall/Timeline/Pagination.php @@ -1,29 +1,43 @@ $timelineClass + */ private function __construct( + private readonly string $timelineClass, private readonly ?string $previousPageUrl, private readonly ?string $nextPageUrl, private readonly ?ResolverInterface $resolver, ) {} - public static function fromArray(array $data, ?Context $context = null): static - { + /** + * @param class-string $timelineClass + * @return self + */ + public static function fromArray( + array $data, + string $timelineClass, + ?Context $context = null, + ): self { $reader = PayloadReader::from($data, self::class); $previousPageUrl = $reader->nullableString('prev'); $nextPageUrl = $reader->nullableString('next'); return new self( + timelineClass: $timelineClass, previousPageUrl: $previousPageUrl === null ? null : PaginationUrlNormalizer::normalize($previousPageUrl), @@ -44,17 +58,26 @@ public function nextPageUrl(): ?string return $this->nextPageUrl; } - public function nextPage(): ?OneHourTimeline + /** + * @return TTimeline|null + */ + public function nextPage(): ?EntityInterface { return $this->resolve($this->nextPageUrl); } - public function previousPage(): ?OneHourTimeline + /** + * @return TTimeline|null + */ + public function previousPage(): ?EntityInterface { return $this->resolve($this->previousPageUrl); } - private function resolve(?string $pageUrl): ?OneHourTimeline + /** + * @return TTimeline|null + */ + private function resolve(?string $pageUrl): ?EntityInterface { if ($pageUrl === null) { return null; @@ -66,11 +89,8 @@ private function resolve(?string $pageUrl): ?OneHourTimeline ); } - /** @var OneHourTimeline $timeline */ - $timeline = $this->resolver->entity( - $pageUrl, - OneHourTimeline::class, - ); + /** @var TTimeline $timeline */ + $timeline = $this->resolver->entity($pageUrl, $this->timelineClass); return $timeline; } diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index 5c31f5a..ad58864 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -5,8 +5,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; -use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; From f89688a36910fc7ad7f47c70a01e7a5f2671b739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 09:53:59 +0100 Subject: [PATCH 066/113] feat(one-call): paginate remaining timelines --- src/Entity/OneCall/FifteenMinuteTimeline.php | 11 +- src/Entity/OneCall/OneDayTimeline.php | 11 +- src/Entity/OneCall/OneHourTimeline.php | 19 +-- src/Entity/OneCall/Timeline/Pagination.php | 26 +-- src/Entity/OneCall/Timeline/TimelinePage.php | 25 ++- .../OneCall/FifteenMinuteTimelineTest.php | 41 +++++ .../Entity/OneCall/OneDayTimelineTest.php | 26 +++ tests/Unit/Resource/OneCallTest.php | 149 ++++++++++++++++++ 8 files changed, 274 insertions(+), 34 deletions(-) diff --git a/src/Entity/OneCall/FifteenMinuteTimeline.php b/src/Entity/OneCall/FifteenMinuteTimeline.php index f88bdf9..8f5d6aa 100644 --- a/src/Entity/OneCall/FifteenMinuteTimeline.php +++ b/src/Entity/OneCall/FifteenMinuteTimeline.php @@ -6,12 +6,13 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; final class FifteenMinuteTimeline implements EntityInterface { /** - * @param TimelinePage $page + * @param TimelinePage $page */ private function __construct( private readonly TimelinePage $page, @@ -44,4 +45,12 @@ public function periods(): array { return $this->page->periods(); } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->page->pagination(); + } } diff --git a/src/Entity/OneCall/OneDayTimeline.php b/src/Entity/OneCall/OneDayTimeline.php index 307f062..5e68377 100644 --- a/src/Entity/OneCall/OneDayTimeline.php +++ b/src/Entity/OneCall/OneDayTimeline.php @@ -6,12 +6,13 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; final class OneDayTimeline implements EntityInterface { /** - * @param TimelinePage $page + * @param TimelinePage $page */ private function __construct( private readonly TimelinePage $page, @@ -44,4 +45,12 @@ public function periods(): array { return $this->page->periods(); } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->page->pagination(); + } } diff --git a/src/Entity/OneCall/OneHourTimeline.php b/src/Entity/OneCall/OneHourTimeline.php index 21ea7f6..33b2462 100644 --- a/src/Entity/OneCall/OneHourTimeline.php +++ b/src/Entity/OneCall/OneHourTimeline.php @@ -12,31 +12,20 @@ final class OneHourTimeline implements EntityInterface { /** - * @param TimelinePage $page - * @param Pagination $pagination + * @param TimelinePage $page */ private function __construct( private readonly TimelinePage $page, - private readonly Pagination $pagination, ) {} public static function fromArray(array $data, ?Context $context = null): static { - $page = TimelinePage::fromArray( + return new self(TimelinePage::fromArray( data: $data, entity: self::class, periodClass: Period::class, context: $context, - ); - - return new self( - page: $page, - pagination: Pagination::fromArray( - data: $data, - timelineClass: self::class, - context: $context, - ), - ); + )); } public function coordinates(): ?Coordinates @@ -62,6 +51,6 @@ public function periods(): array */ public function pagination(): Pagination { - return $this->pagination; + return $this->page->pagination(); } } diff --git a/src/Entity/OneCall/Timeline/Pagination.php b/src/Entity/OneCall/Timeline/Pagination.php index 4d9e3a9..651b27f 100644 --- a/src/Entity/OneCall/Timeline/Pagination.php +++ b/src/Entity/OneCall/Timeline/Pagination.php @@ -9,27 +9,27 @@ use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; /** - * @template TTimeline of EntityInterface + * @template TPage of EntityInterface */ final class Pagination { /** - * @param class-string $timelineClass + * @param class-string $pageClass */ private function __construct( - private readonly string $timelineClass, + private readonly string $pageClass, private readonly ?string $previousPageUrl, private readonly ?string $nextPageUrl, private readonly ?ResolverInterface $resolver, ) {} /** - * @param class-string $timelineClass - * @return self + * @param class-string $pageClass + * @return self */ public static function fromArray( array $data, - string $timelineClass, + string $pageClass, ?Context $context = null, ): self { $reader = PayloadReader::from($data, self::class); @@ -37,7 +37,7 @@ public static function fromArray( $nextPageUrl = $reader->nullableString('next'); return new self( - timelineClass: $timelineClass, + pageClass: $pageClass, previousPageUrl: $previousPageUrl === null ? null : PaginationUrlNormalizer::normalize($previousPageUrl), @@ -59,7 +59,7 @@ public function nextPageUrl(): ?string } /** - * @return TTimeline|null + * @return TPage|null */ public function nextPage(): ?EntityInterface { @@ -67,7 +67,7 @@ public function nextPage(): ?EntityInterface } /** - * @return TTimeline|null + * @return TPage|null */ public function previousPage(): ?EntityInterface { @@ -75,7 +75,7 @@ public function previousPage(): ?EntityInterface } /** - * @return TTimeline|null + * @return TPage|null */ private function resolve(?string $pageUrl): ?EntityInterface { @@ -89,9 +89,9 @@ private function resolve(?string $pageUrl): ?EntityInterface ); } - /** @var TTimeline $timeline */ - $timeline = $this->resolver->entity($pageUrl, $this->timelineClass); + /** @var TPage $page */ + $page = $this->resolver->entity($pageUrl, $this->pageClass); - return $timeline; + return $page; } } diff --git a/src/Entity/OneCall/Timeline/TimelinePage.php b/src/Entity/OneCall/Timeline/TimelinePage.php index 7ef3c08..e937725 100644 --- a/src/Entity/OneCall/Timeline/TimelinePage.php +++ b/src/Entity/OneCall/Timeline/TimelinePage.php @@ -11,25 +11,29 @@ /** * @template TPeriod of EntityInterface + * @template TPage of EntityInterface */ final class TimelinePage { /** * @param list $periods + * @param Pagination $pagination */ private function __construct( private readonly ?Coordinates $coordinates, private readonly ?Timezone $timezone, private readonly array $periods, + private readonly Pagination $pagination, ) {} /** - * @template T of EntityInterface + * @template TPeriodClass of EntityInterface + * @template TPageClass of EntityInterface * - * @param class-string $entity - * @param class-string $periodClass + * @param class-string $entity + * @param class-string $periodClass * - * @return self + * @return self */ public static function fromArray( array $data, @@ -62,6 +66,11 @@ public static function fromArray( coordinates: $hasCoordinates ? Coordinates::fromArray($data, $context) : null, timezone: $hasTimezone ? Timezone::fromArray($data, $context) : null, periods: $periods, + pagination: Pagination::fromArray( + data: $data, + pageClass: $entity, + context: $context, + ), ); } @@ -82,4 +91,12 @@ public function periods(): array { return $this->periods; } + + /** + * @return Pagination + */ + public function pagination(): Pagination + { + return $this->pagination; + } } diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php index 39eb92d..b43416f 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimelineTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; @@ -23,8 +24,36 @@ public function testHydratesCapturedTimeline(): void self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); self::assertCount(50, $timeline->periods()); self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1785714300, $timeline->periods()[49]->dateTime()?->getTimestamp()); + self::assertNull($timeline->pagination()->previousPageUrl()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785715200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); + } + + public function testHydratesCapturedBidirectionalPagination(): void + { + $timeline = FifteenMinuteTimeline::fromArray( + Fixture::json('one-call/fifteen-minute/pagination.json'), + ); + + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785670200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/15min?' + .'cnt=50&lat=38.7223&lon=-9.1393&start=1785760200' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); } public function testToleratesMissingNullUnknownAndPartialFields(): void @@ -34,6 +63,10 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); self::assertSame([], $missing->periods()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); $timeline = FifteenMinuteTimeline::fromArray([ 'lat' => null, @@ -87,5 +120,13 @@ public static function invalidFields(): iterable ['data' => [['pressure' => '1015.75']]], '"pressure" expected int|float, string received.', ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'next page URL type' => [ + ['next' => []], + '"next" expected string, array received.', + ]; } } diff --git a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php index aff1e0d..68e68f0 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimelineTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; @@ -23,8 +24,21 @@ public function testHydratesCapturedTimeline(): void self::assertSame(3600, $timeline->timezone()?->offsetSeconds()); self::assertCount(10, $timeline->periods()); self::assertContainsOnlyInstancesOf(Period::class, $timeline->periods()); + self::assertInstanceOf(Pagination::class, $timeline->pagination()); self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1786406400, $timeline->periods()[9]->dateTime()?->getTimestamp()); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1784764800' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->previousPageUrl(), + ); + self::assertSame( + 'https://api.openweathermap.org/data/4.0/onecall/timeline/1day?' + .'cnt=10&lat=38.7223&lon=-9.1393&start=1786492800' + .'&appid=%7BAPI%20key%7D&units=metric&lang=en', + $timeline->pagination()->nextPageUrl(), + ); } public function testHydratesCapturedMixedHistoricalAndForecastTimeline(): void @@ -47,6 +61,10 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); self::assertSame([], $missing->periods()); + self::assertNull($missing->pagination()->previousPageUrl()); + self::assertNull($missing->pagination()->nextPageUrl()); + self::assertNull($missing->pagination()->previousPage()); + self::assertNull($missing->pagination()->nextPage()); $timeline = OneDayTimeline::fromArray([ 'lat' => null, @@ -100,5 +118,13 @@ public static function invalidFields(): iterable ['data' => [['temp' => 'invalid']]], '"temp" expected array, string received.', ]; + yield 'previous page URL type' => [ + ['prev' => 1], + '"prev" expected string, int received.', + ]; + yield 'next page URL type' => [ + ['next' => []], + '"next" expected string, array received.', + ]; } } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 5b9f1d6..0b33612 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -165,14 +165,87 @@ public function testGetsFifteenMinuteTimelineByCoordinates(): void self::assertInstanceOf(FifteenMinuteTimeline::class, $timeline); self::assertCount(50, $timeline->periods()); self::assertSame(1785670200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertNull($timeline->pagination()->previousPageUrl()); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testGetsNextFifteenMinuteTimelinePage(): void + { + $this->respondWithFixture('one-call/fifteen-minute/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785715200}]}', + )); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $nextPage); + self::assertSame(1785715200, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); self::assertSame([ + 'cnt' => '50', 'lat' => '38.7223', 'lon' => '-9.1393', + 'start' => '1785715200', + 'appid' => 'api-key', 'units' => 'metric', 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousFifteenMinuteTimelinePage(): void + { + $this->respondWithFixture('one-call/fifteen-minute/pagination.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1785670200}]}', + )); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(FifteenMinuteTimeline::class, $previousPage); + self::assertSame(1785670200, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/15min', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '50', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785670200', 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', ], $this->query($request)); } @@ -356,6 +429,14 @@ public function testGetsOneDayTimelineByCoordinates(): void self::assertInstanceOf(OneDayTimeline::class, $timeline); self::assertCount(10, $timeline->periods()); self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->previousPageUrl() ?? '', + ); + self::assertStringContainsString( + 'appid=', + $timeline->pagination()->nextPageUrl() ?? '', + ); self::assertSame('GET', $request->getMethod()); self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); self::assertSame([ @@ -389,6 +470,74 @@ public function testGetsOneDayTimelineFromStart(): void ], $this->query($request)); } + public function testGetsNextOneDayTimelinePage(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1786492800}]}', + )); + + $timeline = $this->api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $nextPage = $timeline->pagination()->nextPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneDayTimeline::class, $nextPage); + self::assertSame(1786492800, $nextPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '10', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1786492800', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + + public function testGetsPreviousOneDayTimelinePage(): void + { + $this->respondWithFixture('one-call/one-day/success.json'); + $this->client->addResponse(new Response( + body: '{"data":[{"dt":1784764800}]}', + )); + + $timeline = $this->api->oneCall()->oneDayTimeline( + latitude: 38.7223, + longitude: -9.1393, + ); + + self::assertCount(1, $this->client->getRequests()); + + $previousPage = $timeline->pagination()->previousPage(); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(OneDayTimeline::class, $previousPage); + self::assertSame(1784764800, $previousPage->periods()[0]->dateTime()?->getTimestamp()); + self::assertCount(2, $this->client->getRequests()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('/data/4.0/onecall/timeline/1day', $request->getUri()->getPath()); + self::assertSame([ + 'cnt' => '10', + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1784764800', + 'appid' => 'api-key', + 'units' => 'metric', + 'lang' => 'en', + ], $this->query($request)); + } + public function testOneDayTimelineAcceptsFluentConfigurationAndCount(): void { $this->client->addResponse(new Response( From 3f0855295774eff3ed6b26b2f6f7626ce4687f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 10:01:44 +0100 Subject: [PATCH 067/113] feat(one-call): expose pagination availability --- docs/one-call.md | 38 +++++++++++++------ src/Entity/OneCall/Timeline/Pagination.php | 10 +++++ .../Entity/OneCall/OneHourTimelineTest.php | 4 ++ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/one-call.md b/docs/one-call.md index b1264f4..8515e45 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -120,14 +120,12 @@ $timeline = $api->oneCall()->fifteenMinuteTimeline( The optional positive `count` limits the requested page size. -The response exposes location metadata, up to 50 periods, and pagination URLs -when OpenWeather provides them. +The response exposes location metadata, up to 50 periods, and pagination when +OpenWeather provides it. ```php echo $timeline->coordinates()?->latitude(); echo $timeline->timezone()?->identifier(); -echo $timeline->previousPageUrl(); -echo $timeline->nextPageUrl(); foreach ($timeline->periods() as $period) { echo $period->dateTime()?->format(DATE_ATOM); @@ -136,8 +134,6 @@ foreach ($timeline->periods() as $period) { } ``` -The pagination URL getters do not make another API request. - ## One-hour Timeline See OpenWeather's @@ -181,9 +177,6 @@ foreach ($timeline->periods() as $period) { } ``` -As with the 15-minute timeline, `previousPageUrl()` and `nextPageUrl()` do not -make another request. - ## One-day Timeline See OpenWeather's @@ -230,8 +223,31 @@ foreach ($timeline->periods() as $period) { ``` OpenWeather does not currently define units for the daily scalar rain and snow -values, so these getters return raw nullable floats. `previousPageUrl()` and -`nextPageUrl()` do not make another request. +values, so these getters return raw nullable floats. + +## Timeline Pagination + +The 15-minute, one-hour, and one-day timelines provide explicit pagination. + +```php +$pagination = $timeline->pagination(); + +if ($pagination->hasPreviousPage()) { + $previousTimeline = $pagination->previousPage(); +} + +if ($pagination->hasNextPage()) { + $nextTimeline = $pagination->nextPage(); +} + +echo $pagination->previousPageUrl(); +echo $pagination->nextPageUrl(); +``` + +The availability checks and URL getters do not make another API request. +`previousPage()` and `nextPage()` request the corresponding page when its URL +is available and otherwise return `null`. Pagination does not iterate +automatically. ## Alert diff --git a/src/Entity/OneCall/Timeline/Pagination.php b/src/Entity/OneCall/Timeline/Pagination.php index 651b27f..d091f91 100644 --- a/src/Entity/OneCall/Timeline/Pagination.php +++ b/src/Entity/OneCall/Timeline/Pagination.php @@ -53,11 +53,21 @@ public function previousPageUrl(): ?string return $this->previousPageUrl; } + public function hasPreviousPage(): bool + { + return $this->previousPageUrl !== null; + } + public function nextPageUrl(): ?string { return $this->nextPageUrl; } + public function hasNextPage(): bool + { + return $this->nextPageUrl !== null; + } + /** * @return TPage|null */ diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index ad58864..bb914b6 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -27,6 +27,8 @@ public function testHydratesCapturedTimeline(): void self::assertInstanceOf(Pagination::class, $timeline->pagination()); self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertSame(1785736800, $timeline->periods()[19]->dateTime()?->getTimestamp()); + self::assertTrue($timeline->pagination()->hasPreviousPage()); + self::assertTrue($timeline->pagination()->hasNextPage()); self::assertSame( 'https://api.openweathermap.org/data/4.0/onecall/timeline/1h?' .'cnt=20&lat=38.7223&lon=-9.1393&start=1785596400' @@ -62,6 +64,8 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertSame([], $missing->periods()); self::assertNull($missing->pagination()->previousPageUrl()); self::assertNull($missing->pagination()->nextPageUrl()); + self::assertFalse($missing->pagination()->hasPreviousPage()); + self::assertFalse($missing->pagination()->hasNextPage()); self::assertNull($missing->pagination()->previousPage()); self::assertNull($missing->pagination()->nextPage()); From c9b46f3fc0f5460a5f75908875d7926f0ac4c159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 10:08:49 +0100 Subject: [PATCH 068/113] fix(one-call): defer pagination resolver access --- src/Entity/OneCall/Timeline/Pagination.php | 11 ++++----- .../Entity/OneCall/OneHourTimelineTest.php | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Entity/OneCall/Timeline/Pagination.php b/src/Entity/OneCall/Timeline/Pagination.php index d091f91..4f34355 100644 --- a/src/Entity/OneCall/Timeline/Pagination.php +++ b/src/Entity/OneCall/Timeline/Pagination.php @@ -4,7 +4,6 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\Api\Contract\ResolverInterface; use ProgrammatorDev\OpenWeatherMap\Hydration\OneCall\PaginationUrlNormalizer; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; @@ -20,7 +19,7 @@ private function __construct( private readonly string $pageClass, private readonly ?string $previousPageUrl, private readonly ?string $nextPageUrl, - private readonly ?ResolverInterface $resolver, + private readonly ?Context $context, ) {} /** @@ -44,7 +43,7 @@ public static function fromArray( nextPageUrl: $nextPageUrl === null ? null : PaginationUrlNormalizer::normalize($nextPageUrl), - resolver: $context?->resolver(), + context: $context, ); } @@ -93,14 +92,14 @@ private function resolve(?string $pageUrl): ?EntityInterface return null; } - if ($this->resolver === null) { + if ($this->context === null) { throw new \LogicException( - 'Pagination navigation requires a timeline returned by the API.', + 'Pagination navigation requires a page returned by the API.', ); } /** @var TPage $page */ - $page = $this->resolver->entity($pageUrl, $this->pageClass); + $page = $this->context->resolver()->entity($pageUrl, $this->pageClass); return $page; } diff --git a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php index bb914b6..0566dd1 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimelineTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimelineTest.php @@ -4,10 +4,15 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ProgrammatorDev\Api\Config\Config; +use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline\Period; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; final class OneHourTimelineTest extends TestCase @@ -55,6 +60,24 @@ public function testHydratesCapturedHistoricalTimeline(): void self::assertNull($timeline->periods()[0]->precipitationProbability()); } + public function testHydratesWithConfigurationContextWithoutResolver(): void + { + $context = new Context(new Config([ + OpenWeatherMap::OPTION_UNITS => Units::IMPERIAL, + ])); + $timeline = OneHourTimeline::fromArray([ + 'data' => [['temp' => 72.5]], + 'next' => '/data/4.0/onecall/timeline/1h?start=1785740400', + ], $context); + + self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); + self::assertTrue($timeline->pagination()->hasNextPage()); + self::assertSame( + '/data/4.0/onecall/timeline/1h?start=1785740400', + $timeline->pagination()->nextPageUrl(), + ); + } + public function testToleratesMissingNullUnknownAndPartialFields(): void { $missing = OneHourTimeline::fromArray([]); From 30112b8cc1b96adcd0628fce9461dccf992fd6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 10:15:09 +0100 Subject: [PATCH 069/113] refactor(api): clarify temporal arguments and pagination calls --- docs/air-pollution.md | 4 ++-- docs/one-call.md | 11 +++++++---- src/Resource/AirPollution.php | 12 ++++++------ src/Resource/OneCall.php | 8 ++++---- tests/Unit/Resource/AirPollutionTest.php | 16 ++++++++-------- tests/Unit/Resource/OneCallTest.php | 4 ++-- 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/docs/air-pollution.md b/docs/air-pollution.md index c3634b8..bf96a5e 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -113,8 +113,8 @@ or equal to the start and cannot be in the future. $history = $api->airPollution()->history( latitude: 38.7223, longitude: -9.1393, - start: new DateTimeImmutable('2 days ago'), - end: new DateTimeImmutable('1 day ago'), + startAt: new DateTimeImmutable('2 days ago'), + endAt: new DateTimeImmutable('1 day ago'), ); ``` diff --git a/docs/one-call.md b/docs/one-call.md index 8515e45..40eb532 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -157,7 +157,7 @@ starting point. Availability depends on OpenWeather. $timeline = $api->oneCall()->oneHourTimeline( latitude: 38.7223, longitude: -9.1393, - start: new DateTimeImmutable('2 days ago'), + startAt: new DateTimeImmutable('2 days ago'), count: 10, ); ``` @@ -193,14 +193,14 @@ $timeline = $api->oneCall()->oneDayTimeline( ); ``` -Use `start` to select a historical or future starting point and `count` to +Use `startAt` to select a historical or future starting point and `count` to limit the requested page size. ```php $timeline = $api->oneCall()->oneDayTimeline( latitude: 38.7223, longitude: -9.1393, - start: new DateTimeImmutable('2 days ago'), + startAt: new DateTimeImmutable('2 days ago'), count: 5, ); ``` @@ -247,7 +247,10 @@ echo $pagination->nextPageUrl(); The availability checks and URL getters do not make another API request. `previousPage()` and `nextPage()` request the corresponding page when its URL is available and otherwise return `null`. Pagination does not iterate -automatically. +automatically. Every pagination request counts as a separate One Call API call +under your OpenWeather subscription; consult the +[official documentation](https://openweathermap.org/api/one-call-4#pagination) +for current usage and billing terms. ## Alert diff --git a/src/Resource/AirPollution.php b/src/Resource/AirPollution.php index b519b7d..70fdc62 100644 --- a/src/Resource/AirPollution.php +++ b/src/Resource/AirPollution.php @@ -51,16 +51,16 @@ public function forecast(float $latitude, float $longitude): Forecast public function history( float $latitude, float $longitude, - \DateTimeInterface $start, - \DateTimeInterface $end, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, ): History { $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); - Assert::chronologicalRange($start, $end); + Assert::chronologicalRange($startAt, $endAt); // A non-future end also constrains the ordered start. // The documented minimum is left to OpenWeather because live availability differs. - $end = Assert::notFuture($end, 'end date'); + $endAt = Assert::notFuture($endAt, 'end date'); // https://openweathermap.org/api/air-pollution /** @var History $history */ @@ -69,8 +69,8 @@ public function history( ->queries([ 'lat' => $latitude, 'lon' => $longitude, - 'start' => $start->getTimestamp(), - 'end' => $end->getTimestamp(), + 'start' => $startAt->getTimestamp(), + 'end' => $endAt->getTimestamp(), ]) ->get('/data/2.5/air_pollution/history') ->entity(History::class); diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index 0a49e5c..1906223 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -92,7 +92,7 @@ public function fifteenMinuteTimeline( public function oneHourTimeline( float $latitude, float $longitude, - ?\DateTimeInterface $start = null, + ?\DateTimeInterface $startAt = null, ?int $count = null, ): OneHourTimeline { $latitude = Assert::latitude($latitude); @@ -109,7 +109,7 @@ public function oneHourTimeline( ->queries([ 'lat' => $latitude, 'lon' => $longitude, - 'start' => $start?->getTimestamp(), + 'start' => $startAt?->getTimestamp(), 'cnt' => $count, 'units' => $this->resolvedUnits(), 'lang' => $this->resolvedLanguage(), @@ -123,7 +123,7 @@ public function oneHourTimeline( public function oneDayTimeline( float $latitude, float $longitude, - ?\DateTimeInterface $start = null, + ?\DateTimeInterface $startAt = null, ?int $count = null, ): OneDayTimeline { $latitude = Assert::latitude($latitude); @@ -140,7 +140,7 @@ public function oneDayTimeline( ->queries([ 'lat' => $latitude, 'lon' => $longitude, - 'start' => $start?->getTimestamp(), + 'start' => $startAt?->getTimestamp(), 'cnt' => $count, 'units' => $this->resolvedUnits(), 'lang' => $this->resolvedLanguage(), diff --git a/tests/Unit/Resource/AirPollutionTest.php b/tests/Unit/Resource/AirPollutionTest.php index 0d6def2..2a653a0 100644 --- a/tests/Unit/Resource/AirPollutionTest.php +++ b/tests/Unit/Resource/AirPollutionTest.php @@ -65,8 +65,8 @@ public function testGetsHistoricalAirPollutionByCoordinatesAndDateRange(): void $history = $this->api->airPollution()->history( latitude: 38.7223, longitude: -9.1393, - start: new \DateTimeImmutable('@1782864000'), - end: new \DateTimeImmutable('@1782950400'), + startAt: new \DateTimeImmutable('@1782864000'), + endAt: new \DateTimeImmutable('@1782950400'), ); $request = $this->client->getLastRequest(); @@ -96,8 +96,8 @@ public function testHistoryAllowsEqualRangeBoundaries(): void $this->api->airPollution()->history( latitude: 38.7223, longitude: -9.1393, - start: $boundary, - end: $boundary, + startAt: $boundary, + endAt: $boundary, ); self::assertSame([ @@ -119,8 +119,8 @@ public function testHistoryRejectsReversedRange(): void $this->api->airPollution()->history( latitude: 38.7223, longitude: -9.1393, - start: new \DateTimeImmutable('@1782950400'), - end: new \DateTimeImmutable('@1782864000'), + startAt: new \DateTimeImmutable('@1782950400'), + endAt: new \DateTimeImmutable('@1782864000'), ); } @@ -132,8 +132,8 @@ public function testHistoryRejectsFutureEnd(): void $this->api->airPollution()->history( latitude: 38.7223, longitude: -9.1393, - start: new \DateTimeImmutable('@0'), - end: new \DateTimeImmutable(sprintf('@%d', time() + 60)), + startAt: new \DateTimeImmutable('@0'), + endAt: new \DateTimeImmutable(sprintf('@%d', time() + 60)), ); } diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 0b33612..6ba6534 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -313,7 +313,7 @@ public function testGetsOneHourTimelineFromStart(): void $timeline = $this->api->oneCall()->oneHourTimeline( latitude: 38.7223, longitude: -9.1393, - start: new \DateTimeImmutable('@1785495600'), + startAt: new \DateTimeImmutable('@1785495600'), ); $request = $this->client->getLastRequest(); @@ -455,7 +455,7 @@ public function testGetsOneDayTimelineFromStart(): void $timeline = $this->api->oneCall()->oneDayTimeline( latitude: 38.7223, longitude: -9.1393, - start: new \DateTimeImmutable('@1785456000'), + startAt: new \DateTimeImmutable('@1785456000'), ); $request = $this->client->getLastRequest(); From 86f7fde20de6e2ff477943debb4ac080f0ab6207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 10:19:37 +0100 Subject: [PATCH 070/113] feat(one-call): allow starting 15-minute timelines --- docs/one-call.md | 6 ++++-- src/Resource/OneCall.php | 2 ++ tests/Unit/Resource/OneCallTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/one-call.md b/docs/one-call.md index 40eb532..92d4536 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -114,11 +114,13 @@ initial page of 15-minute forecast periods. $timeline = $api->oneCall()->fifteenMinuteTimeline( latitude: 38.7223, longitude: -9.1393, + startAt: new DateTimeImmutable('1 day from now'), count: 10, ); ``` -The optional positive `count` limits the requested page size. +Use `startAt` to select a future starting point and `count` to limit the +requested page size. The response exposes location metadata, up to 50 periods, and pagination when OpenWeather provides it. @@ -200,7 +202,7 @@ limit the requested page size. $timeline = $api->oneCall()->oneDayTimeline( latitude: 38.7223, longitude: -9.1393, - startAt: new DateTimeImmutable('2 days ago'), + startAt: new DateTimeImmutable('2 days from now'), count: 5, ); ``` diff --git a/src/Resource/OneCall.php b/src/Resource/OneCall.php index 1906223..a85074f 100644 --- a/src/Resource/OneCall.php +++ b/src/Resource/OneCall.php @@ -63,6 +63,7 @@ public function minuteTimeline(float $latitude, float $longitude): MinuteTimelin public function fifteenMinuteTimeline( float $latitude, float $longitude, + ?\DateTimeInterface $startAt = null, ?int $count = null, ): FifteenMinuteTimeline { $latitude = Assert::latitude($latitude); @@ -79,6 +80,7 @@ public function fifteenMinuteTimeline( ->queries([ 'lat' => $latitude, 'lon' => $longitude, + 'start' => $startAt?->getTimestamp(), 'cnt' => $count, 'units' => $this->resolvedUnits(), 'lang' => $this->resolvedLanguage(), diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 6ba6534..1b738c9 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -181,6 +181,30 @@ public function testGetsFifteenMinuteTimelineByCoordinates(): void ], $this->query($request)); } + public function testGetsFifteenMinuteTimelineFromStart(): void + { + $this->respondWithFixture('one-call/fifteen-minute/pagination.json'); + + $timeline = $this->api->oneCall()->fifteenMinuteTimeline( + latitude: 38.7223, + longitude: -9.1393, + startAt: new \DateTimeImmutable('@1785715200'), + count: 50, + ); + $request = $this->client->getLastRequest(); + + self::assertSame(1785715200, $timeline->periods()[0]->dateTime()?->getTimestamp()); + self::assertSame([ + 'lat' => '38.7223', + 'lon' => '-9.1393', + 'start' => '1785715200', + 'cnt' => '50', + 'units' => 'metric', + 'lang' => 'en', + 'appid' => 'api-key', + ], $this->query($request)); + } + public function testGetsNextFifteenMinuteTimelinePage(): void { $this->respondWithFixture('one-call/fifteen-minute/success.json'); From b7981a508704db1c2087090af8a520f75658b764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 11:56:04 +0100 Subject: [PATCH 071/113] test(maps): add weather tile response fixtures --- tests/Fixtures/README.md | 12 ++++++++-- .../weather-maps/tile/clouds-new.meta.json | 22 ++++++++++++++++++ .../Fixtures/weather-maps/tile/clouds-new.png | Bin 0 -> 80024 bytes .../weather-maps/tile/missing-key.json | 1 + .../weather-maps/tile/missing-key.meta.json | 19 +++++++++++++++ .../tile/precipitation-new.meta.json | 22 ++++++++++++++++++ .../weather-maps/tile/precipitation-new.png | Bin 0 -> 81096 bytes .../weather-maps/tile/pressure-new.meta.json | 22 ++++++++++++++++++ .../weather-maps/tile/pressure-new.png | Bin 0 -> 66276 bytes .../weather-maps/tile/temp-new.meta.json | 22 ++++++++++++++++++ tests/Fixtures/weather-maps/tile/temp-new.png | Bin 0 -> 57338 bytes .../weather-maps/tile/unknown-layer.json | 1 + .../weather-maps/tile/unknown-layer.meta.json | 18 ++++++++++++++ .../weather-maps/tile/wind-new.meta.json | 22 ++++++++++++++++++ tests/Fixtures/weather-maps/tile/wind-new.png | Bin 0 -> 113454 bytes 15 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/weather-maps/tile/clouds-new.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/clouds-new.png create mode 100644 tests/Fixtures/weather-maps/tile/missing-key.json create mode 100644 tests/Fixtures/weather-maps/tile/missing-key.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/precipitation-new.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/precipitation-new.png create mode 100644 tests/Fixtures/weather-maps/tile/pressure-new.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/pressure-new.png create mode 100644 tests/Fixtures/weather-maps/tile/temp-new.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/temp-new.png create mode 100644 tests/Fixtures/weather-maps/tile/unknown-layer.json create mode 100644 tests/Fixtures/weather-maps/tile/unknown-layer.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/wind-new.meta.json create mode 100644 tests/Fixtures/weather-maps/tile/wind-new.png diff --git a/tests/Fixtures/README.md b/tests/Fixtures/README.md index d07393b..1dab1b9 100644 --- a/tests/Fixtures/README.md +++ b/tests/Fixtures/README.md @@ -1,7 +1,9 @@ # Response Fixtures -Automated tests use committed JSON fixtures derived from representative real -OpenWeather responses. Tests must never make live OpenWeather requests. +Automated tests use committed response fixtures derived from representative +real OpenWeather responses. These are usually JSON, but binary APIs retain +their original response format. Tests must never make live OpenWeather +requests. ## Naming @@ -22,6 +24,8 @@ tests/Fixtures/geocoding/direct/success.meta.json Use stable endpoint and scenario names such as `success`, `empty`, `missing-optional-fields`, or `invalid-request`. Do not include a captured location name in a filename because the returned name may change or be absent. +Use the actual body format as the fixture extension, such as `.png` for a map +tile, even when the response advertises an incorrect content type. ## Metadata @@ -52,6 +56,10 @@ Record non-secret request parameters, including coordinates when applicable. Never include an API key. Synthetic fixtures use `"provenance": "synthetic"` and describe why they were created in a `notes` field. +For binary fixtures, also record the response content type, body format, byte +length, and SHA-256 hash. Record stable format metadata such as image dimensions +when it is useful for validation. + ## Sanitization Keep captured payloads as close to the real response as possible. Record every diff --git a/tests/Fixtures/weather-maps/tile/clouds-new.meta.json b/tests/Fixtures/weather-maps/tile/clouds-new.meta.json new file mode 100644 index 0000000..158dfc9 --- /dev/null +++ b/tests/Fixtures/weather-maps/tile/clouds-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 80024, + "sha256": "b3f24ece986ec16dd20e5d11c7ab7f4fbe63f8e301f29f33d29e70e010406eef", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/clouds_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather-maps/tile/clouds-new.png b/tests/Fixtures/weather-maps/tile/clouds-new.png new file mode 100644 index 0000000000000000000000000000000000000000..06d65260a8e66f940980fb3b591cc3dbfab7336a GIT binary patch literal 80024 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

R=JVz|6m{`4J@$L@t^&$ahVr5xN3miA8}g|+nW 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

w;2To8Q2us^-(H6~1UMWLUf?{6R7chQi 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�bq2Z5`|RCkL09Eduo&o4OoiL87jOR znO}&`!`EEfkqZ1wFbukerq911nQ*RzkMsEK@9+d+f35>_;Fg?9&Or3A-@D$MoIHP` zeeAT(g8Ooewu~V+(DKQ?3oGTE?jwJX3yc3l%XWS9o_6ywdGvL0OCk|^(I`W*kZUt$ z9==H&4ASvVOB>?Ln`im_?sWd-@B%N*vne192G!*DeTeP#z!GK-jA2AlWzdpkW*5^8B!P#kvAVI+L|tRfYiQ0v};rAo={rXmAhvM-RCn zw7_t{OFaSWe!Cu)^9{O$6Hv8-PF_S%2Gi)Z_1%S-AN*HB6$B7MRuK@Lr?g1V0Xcw> z8Fsi;`LatAQV+t8eG2{o;*0V6msUe%yOpHz?_E-~6_OD$U#~o)>NiwBKd}v)Z8V#D zNm(aqcB`q&EGfMFf@LJ(p-ik?+ma-ODv=8_3rC@1BDgDS<*m6rIH}Vw|$Ixv!A)Mj)>Ozz(iKGKle% ztF(0_0jGWZ&_}dMJa6M?g*=W76o}aADVl~2JvbL*s2D$?rRA+cs32n>1X#c7 z-jWu@`~gX5EXreGFoKs-?RuhVtfXqZdYtW**bHF-|52By8&;M5z$>yaksF?{%Wyxi zbOu^eNm)`!;`szd>yTrCKIA|YUYJ3G->gF9K0I0r56R!$fA;wJT+7d$Kte)->n1bA z`+N?`JJst$DcX0E@=Zhp!zSg%n8ZLlF6ey)Ey+MOvQaW{dakC)I?9i{Y8f&iZFrJV zx9f^#+syNo=~3DzoAY+*)ePU2D(LaU_HMfI=zSrRtSZqSTHhJ7AOr*nah8az_QJut zus8`TgF;W99s&y;xCaqJFb$!RK?^J(3_JQDNb>r@8ctbKCM7Foam>)_fe02unZYpY z#_v4)JzFjBiTio9m&IC(5C7NS{?XfCR&2o>O)b7-uyXR6z3-%xWF`^)mL37YCKR5e z3+FKwHhd&hAk^VWLlA?F95%oG_GfqqgO!z;QZ$&*m`m-n-~CPp)r5DR%3 z&lwzKHDQhV=+i0lPu6`%_c!V;^o}h3rtQc!(Xc&icD?s?HzYKr^c^w2!h_hyz`6#( zJQhq5>O@Ux3ZuOiB=8|*QL?QMh#^zC%_R_pg!YWd+oPuBVLbKyYi<)7NJ!eng7ZQ| zjw-Z0{B)cvJIW}O@Q{oYTt`sE8%H82prMqs-j6wPe#6Ce@F@O4LqLZR3s=QR^!^Z3 z=%5~rNLyToW+oT#nvF9o3}CPO_9%oMc-FK33IKt;VIr+FswtDko0WFfUwvcRzfA^Eu|GIkeAb1RwJF>nY_avIbRqq#He){|+%X+u3KYU&uEpNm;uR^fHtLnJM zh;82YJTZp$;oa)1FaCzdnO7DkN^!PqG44sX;Kh{9ho}ytz1k?j)dizyf7NQ|W7;eQ z(K@^V58UJdj}FD<+3dQlS@Y`V?vy!sdB&SK&W1y#p6A^8XY8c^8WQqr`tTgfU z?Wy4KETxIXCW8WZGI)$vn?FRzmER}wVF#rfl?a?@`tZGk!Cfr#l~+m0S-8gYH}fWR zVU7-P?v?8#XYcJ*Cf^ry_i?TaHl5~+iX za$?wEw$7TRAqA>OoS&PAr;JQu_;0@ZX7OzOqFX*XtR8(G-`>5cExfDOTmy6qjR_mt z8UgZt0COBN29tq6S@)OQqd*nCP(%&^o8&)j+2^%2MTG>TANC6{ez$W^k3ip+rR#!>S}XbEc1Fa%e;O0^2aRG z#PXs{)PZHmnW9GW$J z7PuIFGxtsoqQm@n63*E5Y?~ZYF&Wx{#hBp`H#G9r5o-a$8U>8KE4v(mu20aycX$^T zK1b>|aQZL@yAs)Z!YDi~=tA*(M1OwVBR3L6@<(=_hM;S6c_GjdgEH-6i$_Osl^`6c zXt;5Fc-e(nZFgs(ArQFft7u-#1&70y+4=o@ZI+>qNHmjRmuCSOR;I@T~*IjrHXyy6ON%GrDh=0 z4QR%`yk=Up52qd14SUaLfiAT*J;q#o^{|3mh8iW@1z^5?ExrB+Sj3xn##cfamU<~f z&;;$q%_dSuox%cLizk*zq9A z;lpPJeYDv~o@JMl0noBeBRC;LyRVM-*B}9hQHqa=*O78pc7;Cjju;|fz576?llS#Y zfhN)lj+uZ9!Ukc$3xv`Si%!^}VoDYj7(WnEeRlQ<9zQ=ix@4O&z8A9MJm1ocm6N;R zVI*L|5Kzk&R8>yI+o+Xgj;N}Xz9c%pQgDspR>9lUuc2HBlumv1BTx6841>hQ(UA8H zMBbSu){NfShk~P6{W-*Q`N;bn^YU`TewNYa4r(U>NZBpfOVx@saSu!iBmtM=*Pu^eCvI zgoFL9N^0ryacZGc(zW|u`{=u)VQad`U~^fMk0Ng*#U!MKW;}evy*JABV4j-W+f$*k zz;7V;aC7shqvAyv>J4u9$~Dk6XvaDIh^P%8WfqILaZqwq{VJ{JO`yyAX|}VFv$%da zti2p)QLz%p9=a(pC>}V!gp%QzKL;^wZG4%YWHb6=3c*`$IL`{ z#_)T*T;4Jsof(pc>d_~supJ9x#EfmZdRm5!E_g#bIOAa&5EKJx1kV&-f$HS(A+|QK zRI+VWRA>fELQz!WFS;95=7ibi5!%g`MJ_@_Y*?ubG^HgPp-2+(o5zBzGP@Q0xAWD5 z4x%x`#XMqfJC#S0!UHGaA4<-=DLZ+i6eCq^i!g`>-k?(G844IXO1C@TAy;7@hKxiL z#FA`a)VM11^-yK>=he&GE1n6v)?PPIBs{{&j<$;-q`EFl%jjnF8OUszyHE_ZRQ>zwCawv z0(k4Ir)&z-SADL^Nv{OBjLqWd^19jFonk;p3Laikf>k;ha;~t5>m(g z`bD-yUu2-!Rai!H9CtVVlx7m7%Hs&V(#PUCB*uZG(PM+<;Ysw#<10Js@GnvVRPrPS zr|eVaB^ZZ+jt<;5V7Md@BahBfY-m$zc5PO!X#Kr^lKkrYOOiD-We|G!rW&+`yW6g#di|o zYE}9}M5!30vgmBUaCP-Wp$J~%y>G{jHC2MN!+UvhMPATm8mC}!FWW%`ynDa%ZNpUo znd_-Fgd`(YcQQH>0`nMngzEkK&+vA@WyXfL=X+Nwp%a#q-r;Tf?RGgK2b7c$rsFy! zT0KAD2!gp`2t6@{nUYad;wcy%i~!0$Aq8j==%(i+DEU+rNk;5T7XG942Jj+yZt@5c zO3^@r7CM?XAJxVcZ7WDB6oFBg!X&QuFn4>g2f+TODo=k@+|JgCdFr&j{B3jitG{}6 z{O0y$W`ZytFCGn&4ZHUJhmOad61qjMepPxT( zx6OS1LivGlxVt-{OsdcUW6K&9ceD@GeaGiho-gU}hJxyNd$I>mVypMW^Ey;-KF4XlbYl9%9ylsUXS2Gs8!93WD_K@3swh!c@1 zWnnBhj&=2!K_|1MQeqVU>f7PpbM;<5mStIf-|^?#Fc}eP%2#)>s+(Oi9JM$-kOVyt z5~wH9hyn$@2oRu;psz=O5cEO;BsD$5X*=E3Rh2%&Uw1s$@4xnrEGe?0BI4e2&)H^~ z|FZi?cD37$et+(nxV?=4P^)A=Q%w@tmL8(g=Q*2RkgRl%H{WF0rFg@!@j?Mb-nKg! zZT9%__ZL4Kv$ucy=5u+>$xyU~JluBkqPgMVh>_j}(#W&w8Q2bim8}+FSJ*E55fVLwNXX|#vb)d`(_wCK&YYZ{2KV`l5CXjq6C}3R zic4!x(*{BdAt(tgQI5ZN7Gkk#__-UWd$Sk7_Rm^ULXhZ@0 z=hPfx)tC|maqEb_U>;>T21eH!u>Rqv63Ni;VQril7`vI#n^A{)is8kCPQ+??HpvfS zb}JotlaxfA=l}6=;gOFdeR=t^+8d-iUp^k{Vm3WKe%zmYT;4<62og6lXke03Rx7Gq zKbjrnNHvDrT@5qBrqBrDt5~S@xN-vQnYkf`o;5XL-L~7d#OzSm+0YLY4Tu*ek%O>4 zuD8$$>xP35p|12`_Bj%?eI4FiKhYdbm?N}dJQ9Ie6q#)#r`aAFQaGlKnA+*D*C0Eh z``UL3-h#kaRVLl3S!>t0tZ$~U9@k1i;lD`Se0ut``U)Pp`ldUIC{==NP!+At&0tK0 z;C{G3(SQImqRq+;09UI7|_t@j3N-imU1ph>Y;>aPGuYG-cp-`0^NaKQ;$s><|3Slv~^%Ery`NG=hOZh33 zrb@PWBx~zSpX&5wHV%eW z#To+UhuQ;8L-C3W9zTo}trG-E{E}G^yv;Gx#QH?x=|UhdxH~)Fr{g*$NI^u#cU##_ zNOvH+!r)qHNqeqvB@C}}RW)i6lH`3-x?oUXs*UWh?aN)eDTvjfNlG)o?Rz?%KN^Ai zIJqErDI%WP`JS?a#Pg4y6+1HxB)o6{l4>r2ZV;d1850&tK}Qf!*}40ExAOc01M+uc zwR?SY;kgG+*F1-L_1RQZYq!-|UquDFuEhMxNdg+>Ax)L;ZgjYMI=>B`{HZ=af5Dz2 zSV?Wy8e$3rGc4?xrvtYw$c&SVW=1<|c#HX0)NSS`>LJ8MugUykb)A<76*Adv)KCRJ zptaoxfBE#chx@mckgWCu)b8Wqe%;N@dH#RW4dSoH?n&EihYT zOs*3{aSwNlNr((Id`{@NwyAnXq_+zm52Q4Ygr@fbg>HSD6 zA|Ii-XY?r`Ol8m8cvFahaYJa8A%NMMW*WZcFah~05rxtqlYN+*-5!#MB#dTjj;Hg6 zs~n}p^_(Ff(^GW<-dvV>k;tVi(*eZ?*47MXVjW`mphp;& zBBJhgb2>Xax}u&%_YO%tLTq6lc%3~A32PJ?VS4#k*bBQ4rYr(LNMPi&EYF;T4r$Fl zd;I7XN8>@Ken*6Zlz2AvhXv6N{9~-MOoK&zfYy72)Ll;$fGMJAwD=h@FKB6^$6r7J zfkE9tB{tKBf<+|7rM2~%vaCcP2V$il;~I=6^6YWD;bn1Sm8D z4xE*k(KL4GFspXbjGcv+l*ieFSC%slgL#nL+mKvKd%AY;K1aJ9*ccIk>^Z+-7E)8m z9IGbPKZrNX4%_p-77YzQkct|detd@CWpc59!PfktMi4{c+hNEq&ydK-35kcZvWbiQgp&Uk>x=f7w zGA`M>lM8D0gzUdK7v8565T*cW=Fk$WC4mJBNqo%I3}_9EHaN3l{UqCA2I3dMhW5QT zC1te5x?ws71_xD<8|7e7|dR%W3vP+F-R*&=D>8Ir#K9O)zv=r?AYN#oYI(l;cz0T0Ud1+=y zdwq8XQidOut*6Ek5V8_8h|ogD#Iez=nVbi}E*{Kcvgs_`;w(SJg_F0w9|aZpYLkLaaWCYP8Cw*+d;-XRqm@2|}G=gCE;h;nwHd5w6aNJEa zO>yU);=UwGq2+}l@H6iB@9|zoFTtJB3|qyNT%2_8O1~{i8!z`68HVDMCVX&2qam4ab%n(`#Ij zaaF^B!;~BW>fG!DfK&pDXstS(O`W~LL-8t7EQR))AE!-hc@dq&yiORAaqq2QWNKBjJQFYyJ4~7-r_P&=5aYAD=((fB*IqH-XF&Gw6l&I%JJO<(d| ztxa*65TUepw(h(8SKb3*ahf)YsMst`conN3oxXvgQ5uAOO9Vl6SS~;L?)G-Z!sFS;E}Y1a1d7 zcZQL+=yAx?qr|0xjs;A)F5gT~Xvis7g_SI6mz6(S1$G9=&Em zX}c~fjO7;9N`9u^1`8r6wwX?BqDDeAGtoqaQ|06rNElM_Il3O?&#NeV5NpJU5|U%T5!$dhG?$l8xxX4g6|=nS5B&^>2n5e&1Wj)oo~aKT zM72CqTcOgSRhr^Zp>W7Yix75*;zToXN^U1ut`l}dJPzwOiE_6|5CO8-efJy;fd^Q2 z7X9tX*A5Uf5?s5t#k7GK<8|eVDktyH-|Z9VL`VSk%RC-Rhhoz%R~Om<;5c?JVHaD_ z`Q!DKLHv)J4S9Hzcfm1cJ6vy0+q=yP^TLsI^t9N`jz~b&6qSq=n<_FU3>SJPxG=(I z9%UBQ5bC^WhkaiG<9I$^U43rE*HAX??e>^;l1Bp^7PE2P4niK46E9(nAmVy# zRZtuIWN^+~JvjtcZw6^>2;E-q;E=$e{(NYLHT?neqpfAaBw~IE*wIUR~h=p5? z@6k?h7K*_izETzK_`O({u!oFI#EalW^W~&*Cw*vtrqor+f2EX6XjKMDw})+x_dlbT zn;bo3vfmyIjpiJn+L2I5`87kMA&SgQT5UDnVvg6jzp_OK(CS3HFy(X*;*-{|==$pp zXV#ODC&$S(Z~Jod*3XHUn<9JiGnrxS?eqP~y1!57PY!cX*yVqE{w4bZ&0;#3S)jIJ z(%h^-5bS3As2!M01^b(9K#mlX--*qirjjbXk!nMjfAZFMAU_bB1VHOP3&vE`hPg4f zMmK##gZMFan9eEnyY!31tW`e)ZHFnqJ)vh4I`!knFF07xR)#!&Ul5=YgJJiGm)a*( zcgzE-r&-5Daj2+fdsN0(&M75qtbJ-C>__TJcSv#*92LSqjW;o{%xDO*JkJm0 z$&<`cuTfO3L#QsKFQ(u>f{XTd(Vxb117A&@=hJBH{PfzGbW%RWQD~IYC1=r=k$iJW^+n7-?aRqP-li=XGU124;~7MhpmH*rb?P-AMH0;0(taN~lO| zxb{Y9z?2dqQS*ndh(-eQI&%Y`{clHy4*TDK={lL>E?^P_Oz1SsSC6kmcDA%{B7vsz z=!wRkq5eSOJkp4DOS?~1l+&R*7?TY-o6JCSdyRWDjz}MNtt+aGBpGjQhp8#E0{W4U zO5sA~&vSCHn;tf=%)2`wCP`cZQ5d1fKNjXL$rzp5s%+fQx!JaRt#V^!+({GTNOs3; zj^Gc`oe-;6ZbgMZMp>}oKpPykeJW-4;< zP2=3AXoGqVP--;DrDpB%1oTr4Y2q8qxxcd;iMu21{H7!NTFQ;zNHQDT>qFEzQj!>SGeUj^uce;$9AI~rHe^AjIzn1u zp41r|PLwkZ?Pm9Y_#y2OG7}OAHapAY&w>UwGdc0+QGOO!}R(X0ZD<$TX zz>&}*IgNda0)X|T`iY+)t*Fr%6G>%6b_!a_L7N%PXtQ=O^T5WaGc0(1qL<= zb8HY^cQ4tLPoQizlTuNG?LRe#Lpm=PYD9vjXnXZ&Op0cyc4ZI@`iB%@5dik(2yQIzvi?`l3Zb6Keg*HLABi8K9X=`9YOFk6OmJ7nsH7<<99MEp?8ayhFPTwcZ*v3CZ zMC2glt_Prl?}Hob5~{LCksb)PNYDrin1q`nFs11u3IKg*X`Cq~RA4S{$Us1I2x{F6 zLi4f3uV^lKzk`4(T)D(?nLtyAW<^oZntY!5s8TQpJMAF^4KB6!E4rjLdn1w|!xVrH zfZNQ)4PVa%VBLEAV4bP5uO;-yJ~j7!Wxto03^GAdcQ`U{p67HZW%dx(Wu2if!ev4! zm{zH`X9wk-e^K9{MP-{C9nGIT`{wZG%}44Z5TZC@Y9qt47K2j$^22W?@H8xb})WHI6@jhxVR^LOeq#p$PuP3=&`7|h@37|PkM|y&05#ixe}A4k-d5?9r1OrHVn7fB&AJU zS!P4yi8S0>j3^S583LoLMOgIWW8*?7(!SD+zPUnJEQ99Enz~2Z1YSnzE%O!;dO3GO zn=_b_>t!Mc=DH7nF~{{IY-`VPNfVeC0WJgrbLs^fFVEFQ{#nEpK7t6*L$p9xnxq{G z82g~=_-BdE_>JW#8Dibl%f##V)BWP4aylYXe*WQi_p{XJ;hJ1nkj8lrt?0lSk2mvBYjN$HgfBc!8 zy499a?KY<^9l0n(dr~Szi%vp-JdXSO6M90hhIx5^YKXHB;p!0{M9|_mEQk(ejZOjb zyOsPHm@ln|s4wp>%b)(@gEvqAo3HvBrtHr9`$yhFEb&mc zmot|-gmL{O-yu!W!jhzeJ2tSXs@7X)6K0{0Ak0JmcKmI`WPRryR@U_M7sMK(jp$kh z2|uUA(ocZRvjm3nc3V?ufT6I_D5s*Fi=ErFkOgt#?QUD5?e7jZw&5h(mCQOlnw^ZN zi!;TMoeUi!OPfyaMC{4bwtK`AiOj}RLrC^y=)G>fYo_A%dB0L|MePkn1S5$kU^CiQ z>5vLEA!*HGTK9IF01*6+#DZ`Ouvi*WC;fONL{2{h1MjLe7~OnfO8XjMu;8eQrqkFa zX^WHAiPG(Cdse$Q?IvRMwZbH;muaWCJ!>Did<1U0K+@`9K42G<;wm%2J>Cc^{KjJN zmfQ_m#)b+&1P9f8f%$z_K>)Zg@Nqfqc&N&khjeqvz>XwEf^?GQb zKlZ2&j#Ac6QRg9~rfWW#AI@UJXYuk>x|SK6s0 z$%-T%lEBm@m}DR0348+G&CMB5yx;fo=0jhsN3}5WT2cX+ck{F5jn;$&G5{-Q-2ea} z07*naR4)ed`45h~l#gS|X?B!1pwVuZ7HIsBKYP6S$v^w>uepBq`03u~`&1 zrv0IetKGY-G;V~FD|;{kL{3Ykaz;W_XCJjHH_?(%Y&n4{)J)s{!>b@0G&obVd82X& z0`M04VblAApF!uEUL9;p*}6VIon9j?hu{Jt2PSdiq`9Sw12HC2hfMs=v(xyU`%19z znZeBfh9IF#UwaToFr{1sxxl3x2hUOqx6Xx&#sQ-yFqG`^Lc&id%(Px%ic~hHo`>Me z(e~&-wB7@APr+B9KYVQZUGF*9<0_bd-(hZIc${j^@LEJ&8rGP(8`F#Yx`q`hAG; zb8j1pl71wxGw&8;RH}8+elT2nt`wG|zi_g&XG>CK^a#&A(H*q9+4W=vX7z3jv?fE3 z=CnX>cZ;STOl`E|SIMFgqF#WtoRes31_nx-c`|(w7)LeDfar z>cWl}SYxzyh!}903q-$1>d9vat<{Bk0Km5%%zh4+XWdG#9|Hm(%{NvC6I@Giz&E@42 z=ylT0G!U z?~HN#qFYi?#Q>F82H zX=w)j8P?yPndqE6iZ_CdCjkMu(M-+zHpo%N{S_Zh96rN<-7Zm11L0N1zuon7@-cj8 z-#^QL5qfyAzZq-rlW6Y;*OgGJzC1@Tiu3T#i-ud}>Ar0G`QTG3iFkZx?gY4-B>Y?z=Azmv?8j7xL|>iAY`V z3WNXn2b3007N{YH;qGohu@Tb}Psk*7lgW`_O3M0P&PB>~!$BkYyxS(p&3RO42!QK% z+ZoRB|KXqg;=7;zFJJ!~GdD0DbO_1*#f#5C&xGHzgc+u@q&fMx+;9VxSjwjMM2W1Rw^SmJEFN7gRN@IK^nzodv3Hd= zY=r44z(ogvlLbg=Cz}YfBN2hB5Z;fZhVhjMvd90*6McEE_^%Zs0Oe|YzsVwtaH zpcxWKH_bh}p0B$jqql-42PRF(XTs5wB`xJdrrqiF4iVw>apqFLesA}cc>LH1Rj9~` z$%zoUMF@yFTw@TZeaIWveC_&-SaP^FGL_yBS`^L3#bSEyI|v;Fo8N!udNE*6n8!4d zh#NFEX06$gr2oawBH8Z`w}_*p?rXfODP^;%t@SiiGO;*J6D=#v1u?^ri9>exJ!A=a ztu8d-OLyGq8I#BSg8*c2J?1q8XJB9mh*#4N^G0CsmvS@4Ab8@O9c3ufk5`#DN`Xu} zw7AxoJwf0JmH9IeYFTg|2c{Hr>X@(;1ep!MbC=fW2G-FX!9*TN3c{p~f?DzNA0Rnk zJqQaLjVZm*o6sBPxbKya9tA}vxJnoaXn*^sy9;I~L7BT!B;$^*&vwm+1HA>ZjOxzQ zZnIw@^q@c1AfL~6rB?`g>$XXqg%_9)9BPAaVVuqOh6dDvtXka7`8?zMFaO2GA0K`3 z*}Idkzy5!;#`Z<%@bL2Gr~Au>T9=CPqOi1P_XjgM4{M%1J3oF$>AQ|giknP&sY71Z z@_ZkCV4B~y>i$iS& z>l}7m9E!X#duwlK2##xqgP0%u%(cyRQ_VKBvR;%SL50i~aQfsSG+@7Lc?3{}ITl9j zhzV^k(4lTsoU(17*Zv^a53RJ6j-%N{p9JEq@xVqk{~{}it~$tO9D^( zA|yD`xkz+u_iqh3@wy=boytsS0@jOrBUBjk{<@!~xA!yq`=LpV9M>je>QkPhW4(&@ z^71K~-R*CGuAGtUMCSqm^J!eu-QJ$TajwMIyP_h`RBim zbK;A9gS%h<`seR{-=0WtYO?;V{htYScjdC$$8=?RMfvt57&4kt^-n`G%IRPWNj@~~ zvLOT(9Ielh*=SC*Gz5#5A@0qFv$3*Uu}SCjt9J-2Pm6;^(q!q>N(yD~!cwYoB?dKsba4`Z;duj{5wckNML`Hbg=ASf~DN&oK za)oUX5B)x!_iBNtVu{6S=HWw_a>B_6^c*=ds;5q#3`Ll~G$F`R!U$Yn=-`)N5}~0; z1wObppY`x)La_Hi28_|yPr@wjtA5F(P$l3H8Cm6Jwxl>4o#3iacV411AkiR%5CoH~ z?)M=%xpRXkBUuYpAMXrk?1TP>PzM9dU2=*r8MACu$|%t`!K|BtoogFT^25`k4`<(A z|FPH&J2XUwc7#FPDR?TRnx}MN(k-)Wuenb2O?P`w8jAIi%{>hqrfAQg*#``amT}D% zWATEULL)%{2&xLQVp^z4C*_`MwS@C0@%sEC)iF?=vdh#M-kDlNE5QUj3;qVJ7fu@l zhN(kQqYbnfnzZQ-!s(28*X>eLWO3n>CCN<2!H_`T=oAwq9!^pT2-7~R`Y5RmH81O^ z@d)|j{s^J)S|;b5%ERGML2}@FU@}`fRdMn;(F;t)K0C2F_(sc#JAuOT0ge1E6DODt z+N+^VZ7Xzer9z{0a-rOM#4lxcp{`x#*rfHxF{p7xhUc#Fne0>e4o*=|S*EYWsA^54 zzvSVd6$vaN0*qCHmk^vxJffai++!h6vIx~DgIoq2OPZ;W1iz6z3VPn(dd~rP07qM> z=F{oI`D#ULFe>||YE(}UnI|aMBz^HYUVoU9!X4F)klMZFfSlEBYz2?!e)i02 z@e30JC*$?6zWoV*i}}MEiTNwfg_s=|4Vu3^K7I!x(Rl^`$3Okwzow*>rVR`TrjD>~ z!s85a$}fI13R_3!0XhtP1&uA-@pA;d1VLyNW`(cXheg>8Ir|dhTG~XkvE9Z z{)dTy){(Z>y>x=E#)KmVVikKiDN6_h24EeORI=buNoDxichP25$uZFCvVud1&0)iR zhkFV<=0igXsXFRto?b$X#t&K(2E-=!h(J83RauUpo1&1jKcPI5zxk;RvN@ZoCL@S( zrxg@#uTwH>+F#jsFj_x$NKQ?yHV$KMJfmyr2AmM4_4UZ2%^w_)JX8yhW_6(vh^a>r z$leVa6qKNS4$eM4ChhO*7=!_9JQ`|gL2L#!Tcg5MdEFuNP(+)Ay%D;?)L_4bE-_^s zamZvVG@g;;uR)r#b>0ET$-98ASYKnx8FPhR`HZ3-endLG2|UN6mJdkJaUthNZTUTd zx$B8(LPR9SAs9k5KnjIxp%1EQq1${`wrYj%PQ3&D0hpA`5e0(>%w{w*brAWuy*ZQ9 zk25s-zG`o7F6h{V)>oqt+PK-0pyk!YDtzhS5i=cbfBVOuaUv4q)2?Om&5h*m;GeT` zb>+#)yV=FXOYS%9_iMTqtKtn!DB{FcmFuR_SjBBg$E4#3I`WVX+yj{q-m29MzQ4Bqdp- zdNx`BqSY7o%DuV_f&*PYK;CvRBSObE)6@Yis0NNE3>;yg0cZpg9B$I=5@`N+u$5h- z@F;J^0(?gc`WhLN7-LWMtQsmCdfT=NgE~W}Q`7D3nojWr)EZwnKs%%EUAvR8SX9di z3f(rF)KEc2>LF{zwH)}$Iz#Bavg&xvriUnFr&4dm%G_x(q3Sy$e6mGZ7v;x;%xr&} zaj0UAzBIoaVyPku(HV#UaH*glnqRubyhWpAG66{bVZ8$q2uMjWf4NchQJ_Dar0jPi zaOz7QG8MTm$|!G zmixOPL=Txth*G{jc!Lwx1-o7iK}hCd_J#n;1hD*!o&v?7M-27ZZra=q#`p);@^v zK+?J|!R(R@c_XH&;Y*E+#Yz73!2BT@ZF*LQ1*7vx)Z?BsZAD@2K|LXNG zE8N?r@sir!>P-91+vP-Pu;~Sv2e2)^0LF$5k=)Hm!3_21YIvctQN}4UP4%8S*2+$> z5k;m|p$&kL{l}gofvPhgu#^xW{L?D^(}vpP-{gG56njwFmFiZcd%54kg+TPE>sj7V zL$NS>Kw;bv9Zb9Xw!A;w(K9-6)SHqBB@=+5#`akV0`jf*-A;v42LA^T(Z1iOZ|`5_ zI7$tdPE}Fa%w`!)Oc)bmL^}%oVe7X*d$03YKgstvb0KnCwm}c`lcHf?f-)%Kc=>*W zb{zSM%k7Si3$&oz-1i~D;>c~!?BczS17lzF9CIK^)c3$en&?gR2{UWmpa(a(<;)EN z!UiXF#}gIF;=qsiIcSUfFkWbiIZN=V(MsmWz-6usoBdcwStV*au2NqiNA^3I2SgP> z0*nF9*=X9;jBb*f4~V?L0g8o*GFOWy#z|IE?seguJX_CXKZ z?SMeg$;9*djy=I7=&rBNM?)3&zy0lBy!qAk!p!tJ^uf1i5`F{iaGnKb%)H(sEEHZu zA<+gdoFAbcXb>O*z%~u~lSZO&2HS@nKqJB+z+Y9kc?KK3-o}-g1=v0N>rqE>&WAS-F@Hc759mi*DOZil6{y zAI*-%L%hFlx9W;<-`vSZfUjhiZIea6q(hOS2&+}y#27iYI}n${JJ5VE54&*sS$PlC zMS!cHLE%ofFnS?Mu5;y*C^F=J6dct_PND!P#O7hle*nu%D5Ti}r}RL0*^miBcGO~U zGxmY?bQy^m>2(nSG0H?cTHhwgJ-}p{dCowQM`LL%`w(;gQgb1lBMb3<-@x z5dfyi>8Z!P5qv@&J{LuD$k%7zNTnzG5a?7hM^jV6tAU5CJTe3258!=r{_zvRfa~?G zIs)0>c=lCH)HL&$t_!^H_xlwIL=+WCc-VVr{+mDTPquFjfeW3v0&|*_Nfdk-MT6gf z#s)6?T@xd6*OiX8{Pw3C@MZU@{Oi6?@E;h2?kr0%TPI zV@Y~oF<0lszFw!e>IJhPY^8NpqGdKhm3+KE>A33z$*N}z1JI3B;Y3|zVI~&)_;}Tt zHkPWj_7U3?v1%|2wG3HrDl~h*Y! zz-BH<11aWNJ{BDB-k1Swd~0J;4FW#=(Z?w!b=%*_l*Pqc9@A5ULU_JO zl!DUuAvSa5iK%Oc_{|uF1_b>*+{jNhZ??{N+>7i-)~;QDIVc+l4YEzkf znh_$JiWz7C*=iR_)5}q*^$Y$JT8b+-MQ$}?7Lr@=TcLDZ;FusaW>&%wh>rW9!`y)< zElG8q=o`Gt-5EGKCiq4W`nDbl5o1q-1bNLzf_WmP5S@||NW}hu_HqOwsObhxjtgCX z?(a`IAJE2nt(ZU6f`y+=(eHLT_YuE=%mr^RPf4-bjT@rW(PR=%FuSAHNyS_tBrV}R zD;vUn^JmY#<(M)Yi#!ki0saStC5H!h@Q?w6EV_17lLelEPzdNlLO@d5Hd7w)##@ac-%YaW zG#|os9(uDiFXUWGethv|`_<#0mET?esn~a0lj+%nCPONr)f!`xL$^cg5gtSAOJOCF zVPiHMXH(?>#t6|hvIHXQfCm>i1(`dIc@BiHl}!jQ6(00S^Cw#iCe;Q~$L-BOBZKJk ztg-(B3h;;rCf4$4c|cIYQ1`ET98cw;fo29AcGBpC{j?rrUD}{#lO87mh&#GOO ztA(~9hJJ9yLKwpA9IQd4gq#7PBigH#wTN~x&66Nxr+S^YkH4y}mlvfUPBN&y`2wdW z%nyPSLxU~koAFtRjyxc!acOUvtyVj6b0%p;aF_t<5Y3^rv;||;Xb}(dVp4#jg<@PWYmE`y@CSl{!)8uS-rDDDA_({t zI9wK?f^gAZaQ*-K^Z)G^LGwnN2yaPnO8Ga7O?buQn{c}}TM#~IFpnWKNHMJJ4^?;} zl@JETSY9M#I|k$x!|3Px!zI*1(EjFqAUW@AV}nGNlTJL08iz9cHLkro+;Wyz3e&#L}aljDp`mu`a3Y-l1!ls==4BlLqeocX(E4pnacx37=Wd3>FZYW|a137wxZ z3>wf+oGvZLNXzl(hi1?`RM20cs%-J7XrBC}zNWny8JA|)&G{XarTCJKADbBsUS;zX z`Y~~j<0}GVvXd@2ISw^1FtYK(1fIdD`8W;v-<;<-#z;qC7N(&LQUv@2 z6nDW0LSa0%nj#W=X2%vQ=<>Vb1(~uV(goKauCdRdrS#2*B@TJ`jpS}9^QICv8k)a z&U;mY+*xad@WcG!M};WoWh1Abw*gb8Bd#$n3F8EcSix;b=i+V62xCr}REmbs18W&8Oa+^8eTmfK4F;hZMsc6ypUm0LK%5 zGbt@F_E!K~lD0g*g(#Kj)stTPlV?9`|K`Pipfih@m=sy15Sq#$=DhFd;FaPN(7^;S z)1KN2qXjBS!2io+omWE@j37x<2n9*0Mjc9y36QHG^xkunno#URYioc--WdV32fLOg z4nZ3P!NK32F^C};d;*h)rmp|guJ2y;i_PnPAuqMvvoc5y=ne6IF)|FY?2ky5RvkUX zJVoU#)|F#!w%_Sd)vlW}*{2X{(>7T_pDp$nE5|hdvRY5e#awY~&UaWpEw-m0mz(*c zYKxuOY|MrhQ5DV5^p(sJZY-Jl}S(NYX)nKFYa6Kff z0tC$F(vT9V+S}qHD7X&j4B-c9O;gN%FklR{1?sXXd|t#9v@0u(a@GI^Jg!AUOoTSx2lxEAY*uR+Ti@8yhFq1 z!042m`GT4Y{!Jb-qX>Z@?!26{qbiW6wNE}mVM1J9_z6WDSPyW#J6J(Z}XZU${ zc_*i~4x~UZvW9NsOQyxNV%cjVHv1cU|N2`RH+XXrJ_`YIT>;XCux)`108d0Z$hceu zVI(3}TpBs>~3O+VCPMhdFt`$t8+I`2+DE$`{|K+}x*+5BDyl437& zem&ZdEV+(j+1hZDv+omxC+2qTjz36(TXZtHZ7#Ia>N*c|jxOY&H4vI#JIX|p6GigT zxeVkIeK+ix5t4)&1vos*!+nVfF;BB*90*ayqPiA?%^hfnq?ad8zH9DZ{)lqk`>tQ; z%!d7#8s1QUhuKluqGal07ch4(`Xy!StY;iI?@NfuNsb%g?WG9-qIRUc`mweL_1Fve$B00= zqp5pK`9|u>(ZRFCAHl{=I6p0G=?a84fN{e~Lh`I)V}Tl2L%h5Yh}K|m&w4a9iVj~w zMt)1-;hFd09v1*IH6^8k7L_*ri?9A{^Y6a-zh>`tmj$aJi!vjJz9U0WXaE7AQsQ6| zXu(n!d_nuD>j_?R?Bj`3$H!dH zHwahPF@b=+ORmjV+EhXW4P`Hvh6Va$FmPN?yJnxIZ|C!J>4_=E#LCWNO(=?CZCNjJ zFOEN|HfHDUXgnzbuxC+kPFr+;z6w@!A`zWw`+Y}oi-dNJ=ZD6yvcqYbf`eA?!6XQ` zu#`fQ66PVV0TYI6?0q!5A`zx4flnMs%@%ffAvjr#JWTu62gM^Y#Mk*!CKZ1U7lfn z*>ltm9uq;43dk1C3cbM{ZjQv(VN#9~6gF=)oRRA{C2tu2+9ddH-`M}408(&C^CiEZ ztiJZ@>Vo}I1t7iun`TD5zlJVI+PS+sO|yN?T7pW%I{^`h{zZSz7v-Yn;L<_o3K|mN zIZ8T;z9_Um-G|vX`_BKTKl|6eu#qaA)Z@!u5D$}ZM^7~!l}_5(Cgw1OY%z!E*f9JK z0TLo=Hxrp}%G{IIlhBmF$Fr~E2_C$CH!QSxFjVi54#H!>@gYdEf)En~gC9~EiB1!7 z-qpEtFxTuj@0QI)^|vqnx<*Yn(=s&N1~^JRCD;v1iIH#h-6Dh3@L%E~wBSi+2iy*`-`g;rwE92|Zn6mv9?NCAx?_p~aGVMCLrvn=jg_dOg zOb9!~wB|s4K!V)mn($0Nb5D@n3tE__Y+@6{CM5cDwYtL*Mcg?aXHC93bMV6~Zy$A= z!x9^xCZ_lx_#N%fzR7+!rlJ1;AzqkL$y*Wvt{jxoimmUcK>i)Z$a_tHATJ>~bi0fS zM$sA$Kyg=sk3%6$LV^fj)||!$tZ!KqIOXJjB1Zux01>eAteXTdyOX&&nw!p|=EInX z%sYckjn2#?e}(KoHx*e)XB>T{p8kv=!JOc^(7DpS$rQ}p$D(f0l0$l)KtUt}5CG#s z`5E(ZWIGUC`5}bacYVL25OF8 zTqC>8E77QkNC=1FkJZ@#49HCc+4O>o5C{W|FqrjXsT4CF=&3ehugk}v5KKltFco`1 zG&o&62ceXd$7UlgtJ8W-SYQun*%^xxJbeArryo7qZZ4bI^PBHVY!ZFJAi!sa5LB@i zlMm=ySRq_WLX;O3MdFNW0ztYsNC_XX6wZ8;#vzF;QVz@>-t%ve0~!=YN8qvey}u6g z_%)tEN2;&e$(aa{2S#C_c|!y#ed}~OvA$t2ga_k8S)*PC`CJ&pxIr_L)U>R17 zJ{z3iPDzYG_rVf3gVB~=o_-)40=FmzwE)K#V49u12-%|t?yYbjK?)qmK=sbhn0Fak zNGM}QdFVZ8es@fI%CiZmMX6JOR5fS=G~{p=l8o@osY0>aHY<1JdPZvgFaPX+{wpVR z_YSiuNNaaIVfTz;5*P!i9X2s(x`ui))m-B?;f9ie5Tu+~UO*F=L3RqqH1iq+y1MW_ z0;W+aAU_KL;X&94l^9RmUk&r!U0d}%N#&%-64%D*BnEWhi)b2D!8Lwz{(1Mu=YOnk zc2`bOsTf$lh2k0K)WG%87ZAS0We^{xgTfeGqjm6y@qG82&}60%Avk5|QLdGPz|-%9 z%7OrJ0X9VsxtJvg&kzYosKU?GM9`=!g|Ou-;FcmW+M$ambp+KiS94QkeNmiL<4q1y z%02+$YCZY9w3Q8ubLUM9bocwC{pIdxciAmT=a!#k$FWnBoQcuPjP}PT5S)6__y`>R zEu`mZ+=oLe;bjQMAjlXUgIX^Hfb4&p*S5WUr*zRxS$ThaMb-hbYLbYJ_?WY2f(tgG%YAk@wt> z4-M^5#)EedDEa^Rvw!(l1fQb-2(7jN&eB3`V9_VjPwTNrUlIbC0t3kyA@=OrN6a06a;2`1;i>Rmct-+Ay$0gFQ>PRE z6{hz|YJ9S@93AL)8@`ACFpnI~@Vm?<2>^L}Q)wge{f7aAGFPY*e*;HeOVjzY)h_%8 zUSKa#hADm!dW(bOS?bG3RN&svbhMwf!+mwjB_b5MC_4=13v8Lo?z~J~~KmrIlekV%77YfGrb4mzd&WaiN3Q1KL zN_g?YEz`=z49p+KkLk$J9cwRz#TpEpBy5rZtvmHL?xDQ=LwoFz7}<0|g(RF~iRq*S z8>(JIP6q2JG^0aYFliDgGdTgn8nePef;{(u;t&S-4G5d_JMAb6*Tj_mk|KX#hVc)FrH}yTg1rYLgUpEffC$4geB2-&6hz9HxbJL--veZ)bYEk33F3)Ul`x-X zyTk2arE@AUO>JKJKmYn)2?0RtgQ`mH)C-BCx$G2FJx|u3q`O>v5*If5WD zVD8a~(DNglFhyi+iU`0F7e6W9fjFccVQh$$_3`E^IoXxc>OlFuLwC2~{b#W6mHXj- z7uS4(pv{o)n?sJ1j@pE+(LysQxkzgR?civo8A;4M)awKGeWMU8*@t|V*P%K7#y&)& zNw|lJt?Hx6R5&}3)@3z1`U!OksUsXAPLR{22B(VtLJKe->tcwwARdl6kS1+)`S;y1 z`x5g@Bs9W)QR~87K}@9Bs)UxfI#U_O<4pa3=!#>HtU?OqgZKCkyC;f{RFkzn7`nh*fKkAMJJJS7kvx|za- z2=$iZ_+}df;_j+jisnMXoe+R)xkLmf=_v?2P);k&NcNbJ0a7IVB;}L_704rC9Uf2$ zZTMnQU=W1HU2}_^N+8~X10I>#sh% zj$wnj3DZ$nL^>~;A38wuzpppgWC#GHbNxCxPv~qlxtW%fs>RvY$tkDlH9y z^#m=kEbGR-2p<| zbq8`X`?LUdnS?Dk+VwR>;a1o*oT28sCi5Ol}g83j!vdq}X`{quNoY#k* z{lZS}6+T=om;LeaYTR$yl46$CbV1_?>{coTRYFRrg)XYv=vzTjhn${`g_A`gJeU|e zL(Vabp(kxn;3=!1|q z!*EBw!7A4Vym= zI19U*&!aRl^8ale5O9@gM7r`;$H!NXKEJzr``h*;MN3GSPG(jZ@E}>K_0z7{r^w*; zyqL75WE+&(g9i1;O`)y8ERkQrg_BG@?YS+f<3gn}XGJsbA&x@rzb{qm53{dUw+Xob zAuDj)ZfBSSa_Bwi5&aaY`g437eH$WZjHB?*+B{3B98$9DG5Nd$vxjCS)a_>hvkLd5 zs+`kPqV@xtA$@1ILbv;C`2eUfr`YCreme3xn=UDp`(_5ntuIMZle!e+JX)O*#=+B1 zGLbY{Z*UUGo~Unho^YVU;t~B|#eX73G}ok4-krfA9tZ>Nf!06>sT9`Hi)M_r?&fXs z?C7KZ?_U0Osg&RZH66m684S};BCwQ*hldDV({t8T_Z#J(8<6hB>^^hn6O^_(({mBJ`M%wu?rPKtg~(945%I z;h_Xj4{M9?h4F>N2*pU{xVB~tIE~4`WV}3x@k2XYN9Lg#f0$y&o+sodjcEV4hR<=< z8je6{$^~sY(e2q3jpiuiHB>TF?6Gnt%j?d15Oq#do$5U;^|6T#F2y`7tF5p#lWo# zfEaZ_2-Q_aSEO7koQupe%sl4H^`bQidl&E=x~X`Z6H-6{Y#Yzij@hHcX zhLUZsj4_RYFNlS4;Xzi7dq+p$ZN^Usk{B}2$3{JwKN`NOQ;{dM4ZjRi zJY3Y@R|H^kXQ2~$F{1r@G@ELaow!pGJd9Bq^LK+mSW*B-r4`#O6o*YM+m(^#st`cs>2%{m$q}lfcg~)iTQ3zxSvYy;mQ;V5d5Mdxx zl6NpMc-=o2n5J=vuV=#q47MN4rf=kI)!)GsblV<5>qr2k73(T65&0W*fPj#nB%x8M zC;1%BPrL-XZ=s42s$(zAl#s83dy}yVgMjuHUc#-cN01~Y0#gggXNxK&mQ0B?-acI6rMnsSvg8EjbXbP_<=3hWxJd zRbGLZw3tXV2?A_iYCxQ}gbR#V&zBdfseQY7ox=oC%0ndN*=Kbw2My32QeungmWHH`-DC116s3-eV5l0)?wi~6dUu(_B$yZo zibcH?G{4Jli49E`t0&{DyBB1Jh(DO8CL}Zf_l+`TxUYNo^{ffZ&b&PGpy)$V8WMb4&kpT-gn@@e(ejIOWw`o~+#asQ*nA_8A>phIc&(0hw@Kp+2#ghy zl$4^)*l6-mtlGM~-yVmIS4hP7&`c9FznLcQQqp$G89=!-%?uO((j$r0#zuD|C1V8| zksv^rAPBe@XokJCzZpBq)w^BUSTTbnpa$0sq5F+Ucz8S6m z-uCX{r47~N_0s{Scy;$Yf3kiy9@WR=_5LzPXyCmCj__d?qONn|{v7}0Wt=dJ6CKSL zYEFhx2+=+Hawsxjs8@_N5kgXY_yO|cgyf*m*LN@S%iTLQn`H4!5KJ_}0BX_;Z)v}C zMnGf214t6pz0VA}CfA0{mmj|6XsD2O_TRm302BaYa*d!2Nn-=x!v^l6x0iT9@PbaX|%?&2+pv>wKpV=bn&<>s zIi5_oj_o8{G1gFEf=Gh^9U*`~2&}K1ViNR9+w}&kw%7ClE%9MhU%bdOf z`NVr@e6%(u0!~1*Id^2QKnASa(JV=1Iy$mp{q+20y&~AHDkuauf;9>Y)|zpt&b^{2 z%^`5Ia3?5b=-ob7*b>EoKos;RVnWfaCn@y>-u1HVj23q&2_g~Pns8%&m={fEv`=0I zHnhC_G7Cf?(6jA_@=a_9$aV2N7riI=uTdf_geK~OP@Z!8@%mRMM^~x$i)>` z={QB}$lfn>ZXa2Df>3!Wp-@9B@JC3>62r4u4-Up4*hK?auSN;vc1X$QjPdjw9n4I=$|}0-81p__wAZWE@Qr7 zIbUj=NK)Vse{iU1cNp@cCnYXoCA8oWlv4u2Rh$*7loIt=ZD3h!P>9cC%_aa$v)mnS zGY$$()6#Sd@AtAbd0l=B(?pL~&j$Pzh5}fB-Pq-?ke)V8Ymf-m>sVmggPO%<`_L&q z4dLR!M*ESDIO0yuF}OiVYCa+&)@JYz!nKtTwrs-q*ijf>K-{07rlE?G-uzN{91AX+ z+Wu>D&O4CC^o!*@OM4wJJ|%Tkhi4{5(I?2)hHl05ean3EpU-jF5e3f?SDS zj)J6Rg-YaGQT)DFydH>71P12D^DnLM8e1BFZi1j6-1h;)V>aGLMkwDivv&j z8FG?`P4!m!5{MnPzIP5rXz`iAW7=vIg9Zql!e0P>qeoPnM$SSoGewu-6MM}_SPY03 zf)VikYr*Uahy*QgJUb;;ge_0-Jfpe5X++)7}0iBg&E6o;^GIphGa-H}?c{-9gxSe0o)_ zMT^9)Q?rIb(6?x>efz`g#1Y5v02e8~CRC!^YZ zdK;?1h26z9NogyiW~5y%|ieZ&SZ`rFW1CDT0#JcXJQU={(|I&v@-iu zYsWBwyW6qY?sp2ma*tiNg-I1>$LXf(#*BOlIqF1Nd=N$!x(Lo-d?GNvd*Bf|L-KC> z+63)oj%KD}ax^%vrYjRoSV2zSeB~htXj0yUfQy+f+5O=zMHqbf_`hmCI{CDJdH1|P zh_j!p*F5wofVQ~5!p?A!jhS^c5hUAMXv}UaD6YA?9|hz{`1o;CUtfLG9>Jit4zg(x z81(XYL7QYG13^*UZAQ){v zFs^MRh-Tx$jLn4D`GapmLS-b>=5U(50d>txD>6u;FbJ3RY^6vEJ_HZFVIFb+L32G` z9S!Ba%Gj^1&P`0yquB|EYs=KR+P;%PO+yg=-nRGl{^_`=7861hA&S#+QLRuH87AGX z-A(vjSQGArgB9VxYn)FQdi`rI9OKUTL5LtSgvEdTqkq!;^x4JYB>&d-AG-iqj3AGGW#sRA|-BM_;SN#DOjX2{NgI)G;8WQ)DK=>hTXM)<~ zT$t%OmEB=~6ifpTtwg%Jy*&dNC`9?_(d+izyQdNabPFOMpy%5Q#-g&6g2ZowU^DUr z_C?=5qMFRI?^<E=WrEEaN%WR7 zCYEE?gSrzG#|SXm;rQ2Ix{OQMzDUOq8k7JAOJ~3K~!rfMOTboj;~ZP_+d#q zNF9Kqz5+^2LI`L^x(pN8l$wd9vfyMZMN#<9xVuW=bg4Q3X{=ZbsYh3wUK4N29Sf2u zs?9+Pxwit(bApD5jtO4?rM{G5Qwxw*m0HTv7OX$?oB)GBe80ua)-`hOzcB3j0% zd0*Gy)a9I&1G##kZb0KkD7dy~>>fGszxlkpG}!lUdqV1N&$hk)1R~1u1yTqb!xLc+ zlkf>lM`qdVDA5tbelX`;wg=vQc~U(;M{!I!!gRiyZ3+_EHfwp5Q^BT|<4jeBKrkeW znaW$QEsDe@<`k2TBz-5JKKfBlDDl(7k7?|Yn7uzn4MOjGU^wAQv=MGw2u`^9hg<*f z3=xbBFi2g`OO(f+&^T0#%{;`>sWe!^CO`ah26JbYH{R5xNKbncEdc6iwueFjsWr2Y z^dY-0X343wnKbhTidEO%llkeOl-Y#XIS7kA^20$Xu|;qh1kZu*wlB&{hzN!+kcZ}B z563aeGhW}nC``ML$McgBp$T)6jKg)$Z@(+>uP`1H4BBT*3D!hW0-F$yJg&`c4Vuoi z`f7GGJv;iaW32Z3=xxXH|b%d7I8m ze2UG4o;4KSrlwCm1>Z@%hH2tG_(E4+ZboEBWyGq+6yqN_BT!6EQxJDXO^WRsvyc1z zoT|Sp&w*Ir@-{1)S>laZl*18qm3K6jy((K@+8^@+jm{cOxQje3+^#oXN~ltF--(Yo zRF+N>9))0!yw%QZKwO9fXex&geIn6{Fuq+&n{7Po5SR%CP{W8OQiYG*AN%cM{d8EI z6$g$=ib*Tu4tvdQdxt6>lY~f-kn(ie1W60Vb^;l&=z7f)V-ymTmz})vB!g)tn$@G- zx##i8vi(u=B{rqH%CJ++0YXEIM%x|8%Eq%Y;{3?`Jd8iFhMfH+=(qk~zeEE1-8-wvW<460WmHD0hf$8kCF!F?5ZR3}@gc)&he4#Vt5gHH!jE$WQ{5G3B zzu8@72uYrExqF-APeCAH(+|1MB2aXLxw2d^MzHm@CMAcAuyT_}OAwem%S@20GW4R9 zUQKKLfO4MjX4zxJc}R_R&&wt#PHp_lzNCBXKKQ6pDlNEwRA<32r;u z)rr{r&Na#vm<7fUjX(IEa$P=(jgK#Yl3-N^au{f^`vv{A^-F z5*2tL$vDlAE+PmWm`th2_RaWUvbNc1CSgmG)nYkZ3{TQ!yA(7=lQMpJx<#B`Y%(3x zq{4pBCiLWcAs~bSdF!6SO@Xjwj~bz~W{d;mg!bm%INXR?dqGtaN?rZro?6l#2Wy7E zLtbe%>3sQQtY_uWw`2Bh`zA-rZVq>{MSDbWB(y^rqw-OaPc2`yWkxwY$tXRf%K7l!80CZ$h!J z(UXIq522h)%}TZMe3sW(uStT(JNqCACKCcMGLW7FOX&JzCXECs^ZfAq!MHaPx>khj~R*Ao`YMpL+ne~Ef@t~03D;{6S*1co7?+}b)=Y$@buzvp7(7QnwEHG zoN67=_ygf#b_#U~qR-`YWIoK#Fb9f@17bYI3W^b3I-|!g$OHsX%oY9{LtnlvTA!bk z>8j-y4rUeW9OlaT_i*5HX#0}61SBKPFFzTMKd;vG*KioOatKnWr9)*vJz5hYXD%S9 z{!D_CnO179Y;m5p-mWfJpNMH96EN7VQcof#HX0&_u-)eDOiQgtG%LxP;5PkB&@khgA zSra&dV6*+MDL@(2a6pVOF#E|q!~B=qw>iuo{1}=)j1rk!j4v6W&&j-suY=M&RoJ__ z%*%GVFzx>2=!1T-D!apWC~o(+84f?z3A_Kv`dK`9MoUcu<79oL12$lQ`7-Zl=1E_` zfDk-q0YED|I^;M^p6~*5gP?Y17U66(VJR~KCByP4q_*?xJiWP3NJ7-SMh;0agg#L8 zra9;v;m!SnJ`i0pJ~5O8f_^f75RS@t>C1<6kD?#7`U(n0asT`K6HfM_8jyoyCBT>m zdJJD$I||8!`~|8v{r8y48^W5*f%SltacpXknk6y~hW2$qbTK}qIimwn_Usu5%Nau1 zb}<(~BlS>LiI+eO2T?*$z~Q=@(U?L-7(9^jUD6(jvkC^~ZpRPCj?LYqLpx|bDMw$B zVzNOf5Cs+@2s&Ks-;F|KS}_d>co4f9CbX^VdNSr<+m z;&?7&V9bmZY#L!CLM5UPkc}U_L88h;`>Bl7UQiZ^Rd~x`M7-#D%m>oa-=!;=J`rCE|^oO4Kepoj_V~E~QkB(r# z6$o|xX@5GMDFYFB58{z^>T+D2e4~jV6DC0jfO2$XoKTdZ;E?a|kO>RFc#I$Ba>(!9 zi7KUdyG9NAgZ5vc4H!$7(YOqOAA3m4gTD=kym_TMW;Ed2F^YDc8U|(|wO;*W5&LC(LUPD?s(Iw^qO~?}`^E8x<>uY*+mjd;Vc-xogzj@xVM;1*H5GcP zG6%7Ble{mCxmz|~a|G`H5Z1;oCkh0lr>r8B2$$jP0-Bq4l>m;t6(o=mW~^!udVFEi zvAK+kf)`*r+CEg&C3QJdQWNFR?8vd*|K}h5RkJ#tb#Kh_5JRupRwlgC^F0gXp3>n36wa4KpSkbE0u&c^h$;r5Cq#gpBwWZt=2lt7xG4t zP<={6l+A&B5f8t@DTaC_q=j}kElRtLqLhvAv{WqU*x_EgT2p0gcd_>?sAX_l+wc0rmf!NaMW64ekgar@7UJ}}9cg$oA0xSmDe zC3*Ty>(f`>n@|XfFmd-dX5MYpH_2oFaD-$_fK7JUR2o7v+VbA=cAXD{dTf&=4fKXz zz|4bw4l@(WPUh8OTy#qacZ49n`NPZAFTVQh8iFxxH{)3l8?ohT@DyfTQOVrVi)qMOq{be>f-o@@kP~X-08erpzW(qpnxkjMme$pv6@=MSv+dak=J>_A zG5wdTTZA%e8|`8GE#PB-d9k}88nheH0!$U)GFq8$G{4W0*n9N@vqvV5BOWuoaO#0p zbPXGqdL3|n9Og}`_;I~S87L_hfSY_l}Q0TU}^XAH4^lJ7u&+qz`&^RZeKsy&gWMu1Fy1|-j;(lGc< z6wyKGi86I#vA+BprtFk~C`Kpq59eNJM1nV6+MnD65P)6tkjW=CYt-_7QP>OU1E zwLP7$9OC;=^;?W)K;#n`9ZgLk*TePh8vr3--l!0c@rOv46BJApgnZj8ZzRnPibH46 zp6goA&yfHCGIa;L3a?=bPKGl*4vt3P)B^}d%rsk$Ivf%vH8{(mku*x+cqQ`GW%}XB zNZl@rFi8@Aa;e>JM&(y)c|Zu*Q5bZXV!dcdjT|`pUACSHCu#+aH^-{4vfAK3cJJJ7 z0@Llwoir24EO#&a<;ln8{q3uE0g(d%U66Slfi_hpK{y-9{;A_k8Tt(L4(+MBLo0Bt zHd7j3Ivj=`3A2$Lvk2aPj|!B^U_-DV9E@5)8e>rua>77>5D)WaBOwv|0Udq&vELOQ zYm+BNxR_tI_D<#YMX2s>cbo6Osn#$V((|)NKknD(^??xV$H^yhK%R^!4#5E)y?@^2+Kah z+%eP*I&IpR5sl0db7Ygg`px#l-dBg|*-75gD}u%x ztQ(r0PLV_-F{fHWfJRdY2+RbWhWD>~PQpp%$7O44&S4ob94)bdPl{vK$t3lxPftp5 z#OsE{S9)B69JtvBtFEY0rw?jC#Y zSy`2}bXWCs&!QR5nDWr1MA?!F1AgH*e&tW$Z)HG$VapIe39@oC=f&k6%rKo}Nw;^eDj&}6o2!^BqD%O=) z!UlWj%9~z7*aL%6y-~G78pG_YQ*gYr94AF(Rd2s{GrO2=wJOMQdjyZOuo~nMpi|U@ zlCVH3MK^h;#kPBQP3rmF2nTFEQ&cWUZ1X!c#RIEkRW73z5>fJX^?Wt_@KOJE>rOR& zbF%330ne(_qJ;>6A3njEV;V{d0w~Gk^9Lu+2ujjtVI&aEeot{kdV&kY4fCVjSR=GN zM8vhE9V0lpRZnAP^SB)Z&4TlHtcX-z_gqCUN>t*W+)If}_MupNffJK^@AdbUWOFpS zSMS`fCR#aLa|lcR#&AR`>xT9~P~#Z8ncjjXva8wKjBp=m!mNq=e4LoH>|#@21J{LW z;vqrD>!1bKlx{Y{AlUPU#)OC%8_|XB{w~Kz8;567 z>dbHOrhB&`3N*Z8$^rI!GytE1Y-Ejrinb_)iCN?Pf=TJ=XXea`>EkP5O^jewt;Jw; zhF{LHZ!3lN+#%^OE++D@CoH1^yA-N$v((#GQ5DCRc*}kop%+UUe@>bbhKJyRyw`Km ze6Q7)AGjavU;k{7&f#yKVNdgQW=s)Q3h@-GFOV~ocDo*t!7)2nt5Hl4cG5g1h$|7*z_>FnExI0kB&fu1STQ^0_%2R%#XQ- z0PqAc&!J$S^qqvJECy2~qWa*LB~AN=Fq*~?$d z4%6EK)h>$pvQRc#*-9`f%+4^Q?T%?+%cfM4Mh@Du zpUsZwLe$$T=)t#Oy~O->Z8_@SqD|ZRvInhT!^12%2oVmL5I8!yCyax@4NyHEin1hx zxH41e_I1-gAD?D~Rq<2c`vUF@MdpfT=myLQfe(E%eh{1>Kl30jfpho_p<;TJeR=$K z4qxKoHBgxj^Q`BjBYTT=_xsSKbra&-m;jOKy<@arCj?+92c{=d)R5ezXR%}O zt?gGcmnkNM2t?8mP@-wEB46@RfJzaF%m?L5k53^0Ns2YjI4^e4{^pdBr)wmqVzRp# z=a&1i&GsH)jAAw$#`NjiI&&qF$1+2l?#M*yG=BMKcR z3iEIlte(pT*FcqiNw%A5PiZS4C|QIw7J2o{`N7Bk?eIDF-udT?9Wz!c69AOgN^hph zbcnmW0?uKpgadmYIq9 z%iGyaZcLv=*zYwG)ZhDAe44bFJ^b`vAr=z^@`Fg|!5pj;Opk|OCSrz-?AH(mBpO4) z(r7r09(#z3gGtah5&#nJnOb^_@F)u(-6^KVyq&NAa(oZr+#hyU(WeAs6EcSRIp|X- z=cJ*oy=gN9gFR~jV~x)mopJ-ZsQO7=_%o!4K#}S|6Am$b#QdwBS2adB}3)3$gnVfM90 z2=XecO$rutHIcMidaO4}C71wul%`;S9j|FevqiZjK>_@tFaytg<)x7*^Zu-Fl6<6@ z3YK=<=DzrPwLLnTPapoYf68LNeYP5It+zW!C%mEx3ugE_$X~CNYj0XnDbRA9w3rB9 zn=X|S27|^B^XqwCC9<&K!%<(}W{5=$(=8MR1NKMaXT2dzGzUbHE(nxFA>^h8kd#tq zHUh7?3bFLGRDuDfyLWUn2UTzgYdv$jkvU`Q;{>Ba5)e0(MyqVvlbtq&RDW+CI9(8Q z{0{M><&|X_d>d$ceoDwGBEahoFL`aNzb%IzCJzEoOm4IgV%M@?L<BksR79M+gd&=$@-))MD5i8MOZCeQ2Dcrhr9Mn}CuZMuQTZ$4e@z$g-< zOaPiJyl(&)rA|0?;*={+L=d(dt*TXf|BvQ4&y?7rtsom2lHB!F`f;zm1e;^Q7o5X8Y8z-dQW z>}adM!pS7qS>TY$)e8<`#(Xw@lhl({U<{c??qZM&6yXI!x@0FJ5xNhDlxG)4o%VR= zVSV^&9bl;0zcI$g-p9j_$rE9I6gCn0aDN`oAn++TL&NAD!ToO8 zU%O+^`#?k?;EvZ$+wb=pd>efP-5^~>LC)MTZ+HeCZ4jJbc!wq&X^j&3>My3ppZ@a8 z-CjlM@|_xg09rq0l-kb~t+c3~%Ljwoc&cr+v#NB|RZ-Mm{@L|okc3cSs*o|&g0jb> zW03nXE;0Z4xom&-kh#107QFrTjj-jX(4Nf^`tt}t^qPA#z>|Iw&H?8Lu1VO=# zF2`R;Ne=DS-+BAz@n|)#>Bqhg#Y-w?()!iT{N#kd_$~y-AOHyg%tWOw2L#GFof&8r zuV*l2l>?A?K7$lV%L&UeVH6Ds$`zh#D)xm5$)ttxJn<>TPlh8Ag2lVa;@ZYhJ<ka~ zV)^E)<#u`9uX?jy!KpSeGJr(&B&Sx|&fp-S#trm>fmE}<4)ylET~)aax&!5@OUpwA zZoLe&;I7y-@E*FIg-gGSA;w1 z9Ci9nh#e)vvFn%LuC^{F=b0Lmxn(vzkhEWeuDi0`pdsjyXKE}{z{HNRb#3n6IGw`F=g(6 z#guKNyOqiyxgdd$@Ktsriz9^^{UYuGPaKsMbmY>6I59P(S@M~9fxNi3p%5?jPH!ib zM7?~vG6neZNzv<#Id+M{2TnpX$Tl_!Jy27FC4K|Zo$}v<0$QUj(E|7nctIOhxfJJH z%wKsj3%~1tOF|Yk=mcU*6$b_rNaG|`(l(V4M*^&&sAO5@%bR6E_SxHin?HK;&E2gJ z{;+pS*O;pptKsCl=`N>*pLmP?g%c(cvi)2xYug}?7$~2>z8M_edMf6q-oxWBs0xz@ z=O|-l9p!u)i20ZNz6_2la=ckwG#+zdU6+%jB*cN%$C1dIERYm*3$ck33IujhUiYx#C?cMpm&#rgbCbQ;E95|xsezE7KLCis zH9h?Y#4L(>5ROCG!Js=2i!t+N-4O6=(vcdc8PEVUit`JI30gr2kev0ifA#Xom*=0A z_zV~WPS~*DVqBP(dn~S}Tg%(ou-YD6(>5Ew%j)|2K;3jqD7^mNVwY!Br7G71|D{Nr zQ9V(Ib+@rye!bj2yLqkp0M-InGEEo&03ZNKL_t)0Ir?@SKD((G6T4(e(vUSme~0|x zZ)YMQQ$JyOh0RYCte!4=_#vd}1LKR?p)bLE2}wCBW9H(CVX(^o~IY5`7kiXd0>Kg5-LM4vBrfu_7shMW77#{$O1ry~VRx?Xm zbZJ3SZx#18cnM(-_KO)>OygnRB%O?Mho5VY^~Y+iXcM!h%Y`5F#p@soHWi7clOp&Ui%N6xDTvX9v3^8eVnvj5t{InGQVezGoX#WMU(t$O8S=hGrH!IRJa ze);E<2Y>dL|6!k~1a%4$I)ZeLGV$b#8H{OWTk8%&Wb1A@>Fn>HqFraNUVTXJK5MtQ zIvp(RdQ2=S78D0-%GiPE9=4MjMA*N5u^dp6jMI$yyW`9|5wWl2c0|UHPF?2AxDntI z@_ny!2{cWm-z=WMOJ!d@nGFc}vM;XDRe9+O%iEU8F9?NV zs%7JXfaW%Ibb|Roc%V=O7O1Vo;%dEBo@;sudi`|sv2ec)5erai?)?E+8x*b(wyj&C z&LS+LHyQ+r@VcZ2gzC`2z5ECYo~5e3AVpN{U@(YmCsj9@G_;MCT@$JSrTw7C#28_I z+v-q4=HLShkUwYvo+Cd(;~9ab;Psla1LXq%T8jBM<3Z9Q;dmB{%KdNvCCLaPD{k7W z?Dg$Qe){v5yS+~bJNx~^W_x#_%rlS!Oo%X$zhdRm7wsv#xW5m<$}eEVBF%#KgScTx zo+AWn)Tc*INZ=PE=#MaYhiZUi=<19woqWmPs-7+?3#N`4>Q|-C{x49>!6V z(?6bUE7bSStFHrKf%J0hvF8!$>vyl^=h|3+g=Y7|RWU_9-cM%ebV*}YevVZbhO&!2 zD5U9K=jd-Jg4d#dL%xh&?wY=I-HX z=|%^>@w?!)&p0b|IHL)twVFdDCTJv#j2LX=$-`9$l1j67y`}ZBjhADL#eVko>$1IU zORwek65ilo(8C#UsPJ3IRM@g8WR3ve30Gma#xv^~R|)eQ(dQj|X47o4yC7~MjUWg; zImV$oguuMYRBgt+j>2RCc0ZeOeD~h`2miz9o8jI56`7W@NzrtR6z9Fm0ACkngLzgY zExPI7ARC_rg2AYy~ul{0s|Ns8&pAUZd>TfEZiwS|xL^17RmKofZP-m?ru+vR^ZJGcwK`{!by4zC zfc`P0DMu>^4*}mT|`$sChahqLO8zTr^*3Fuz+{3s7ckDM(4DvPJ~c>O*? z3w7OUf0YTy>mKG+BUXJQdHGwFovP1R%AAw)&m%+1S->3ZmxDEpVL+p7jvSdehp7MOUbuJq`S>L>#0#Z$MlSC>QWSzk&ZQLgO>82|#foLKFR+tf(sq zrC>u7O6Q?1k|znJnxR!w6aXm<&tP&{^?}I|(*dTi;PM5y=9i;K7)En@6A07(cT{3jw$97@CR8aEr$dWa}` zm1AfLLeZM!j%3uTs-x6G5rCX?&iEy{33z=Oy9i(M*i8Z3F)@-}!-19+>)xnr_B*5I z-j6yL{k>vF^9c5!r3o>nQK(IdYYEXYr=9-p8sb6v+uhbcMw~}HRLpPxK|C}Fx$|NF zcIQDkF7aO&Vc>uRE^=Su?;K8Xd$Js%)p=h+VK<}BeT<+70#`db=SDj@`R-}@_UO}p zee>l1_^bbY_qVTq-SLW2A`neGOS~m`v~B6^?2(Ct!$s0!QRQ`~Z3wDGGzKJ{U9CHl z^SY;b`t8E$ytXZUrMO8%xgc_10BcRc&&AW(h&%}SZ&+&>1EiI@PXu$ootz%l>~<=l zH?)3;QmNfId(Pitew^Km+2*UJ9%y?!`O&7wjF$G_Kz>H(zsu1VUSrU%1$6=cu|JLg z{1hf=524O_;n3w;5DNVh?r%R-t9$rVt=MrOC%(tgd5a@A@=YS7h!A*})a20({8a-s zofSkXxRZ@RO51<{un9vLL>d@^TZ^fgLk>Dt-XingX3@f*BV!TsAAlR(jgL~UJ z*-SrJ!5=HAAE8_rqy!P3V4RIgI?ASo?Q59=*r!Ng##YdEm5El0IdA%_P4V6;+ocE^R1QJ&LJvW$Hmy}U_*X>Od945P*e940Xb7hKpHRX z40an%@9h*b5e%AdX0TB)W7{zD6*w&QgYWOXJ^Fsv$(w7xi)Q! zmgub}S$BVUwf{Xs&@V3TqupSB+2qS*H=)P{$Jcng^Y-~_`1$`ee)Mnu_Fs=aJ^f{e zFb~N@{uLBWtJZAFTL+F4+PcXroeoUxL|K+@6Pq19Z(C*0)lb`;Y{6*LBj73|c=P%^6?LXSJvI*6DsM z9v`Ez$|rYzu%agfuW`+&OMwtA@V&<(rX7ABOf&HiG!h6VqIqPq#zwwQ*N8h1Wv-NB z)e!_u#dB$41#74Y&?d|!Nw;cwO?>Z@WD$H(? z6XX57S``@;cXJlS9YNG}v>zRQZ*}-#?}iFH-Je!fz212I?aS{x|M~3B%}L!y)Ae>T za_>!m&W;!x2!L$6)uPVdOn&nd1Ph&maIA4E)SJmUwFQP;=)PU}K$(-Yzvrx@dHIT3 z4f~dLH-R7+z?xT0U)mqUWU@WGemkkqhBzl77=#=I&GoBAqjK12(;EXxQ(I#CZRSgmj6th(XAA81@H5H5Rd&P{Q08zdLe-7yJNq-s?_*{WS4p-0YgdeZjdK zLw+iN<6WG6?g$jUi=Ih=7iJ8#{vk|Gg->Er> zXRuw-SVxK!zMGC0#Z_{thB-uys;;9o&NJ z$-_$s1(`1x0LpambwtAfW68X8`;o?TI(j}=Oj9hkN}4vUhYLSsL??XRoYp0 zGQx^fb8@h2*N_bU5lT##x9jrsH)ng3^H)9Wq%qrrb%3r*L71NZkW{VbO;FBE$Wh5v*kv|IGS!UW<=_FX z_U2slvD|>pt&I2#r(>SQ@SsEZQt8lmpJ{Xc>yWeU{+1Kq=^Pck13iZ$0^qMTK{iy z`osFjX<6hTb<@_Ibt0`yVV4U_t*hOFdGRMrZu}rtB`vi@QL1t3{hde6Xs?>b^k#$& zPS@H@sI8XJYFC0Lnn}>SBSa4e%Bjwn0v~dFwH1<73t!IhF1n4-Kjj<@1FLQ?wkTQ7N6;f14B=sZEnS8YnB1nn*#uQJO(;3`< zk6vZ|R(D$9;UHg@+uN5M=S&1dNgyTwnw2shmH#bhsD=(M2v{YYN(dCz0=UZjmBqpK zEBmpPPa~L4MY2U9Ez;W&0qg6glt)oWLX9445RWuBE=WUM`6QTeC1 zv^qn9xjx+eu;!eFm|VvFp%J@9g2S@vtChL#FGT}A^DMuud;fFf6T&%FgbIP+0st_| zbwJ7}1JL?tPYuK6gB*K2#$m4ij{d}700Rdx@}4}|d_nB8m7IV#i8mf6Wr!XVPQ4#P zOZ1BSDHow!cGvV+v->Fh(x9uSX~sKqlddtY&)uk_=Kg}adX_sA10%Hvy|M=} z=gRi-1+oR9v{To(8JTI}35#KO5`t0*G6O`~g8))`#t=uZ zQmC2pr!{mAm0eTqELK%1xt^^o#-5m;`|?GKD$rhW?|Kcx6Y?}OpeN>po}y@pt?7w% zOS1hf5kLe?i|Bi(F8@2G( z`GB_WRJ_)8g~D;@?AjCBg$zN??^S27{`RA~H&T-mxJ9&pCRZqwI0{@X|5^0cIh{vkC}9L$!>BsAqa@3QJvAm4Cnu*G*%_!E}52w(Y`%%12*15>TR(36aD*s4@~K z>fmGWxoV6;JAzR7CHhEJ6{h%4Nm(P7y}j4HyLX@CC8yy7J>JMrgYkO1#jL*%ya#zS z70oA*(t?%)4xwqY`E}c210-A^5@CZtkZbpHGtR*LHGT+_)G~wW>8i_6d5>+t)ds5Q z*mc~~kw%(9(8GBp$0$O}_c&2FiI_4B2V<$RiqeK_x0l(H)?0e(#2{nj+xg9Uxi%Dl z4Z@dBMUnd=<@Y@6NEygBY{}JHC3H7F+B$Cb`v(pF2Z#Qd7pT~0x%bWNs>K;doSSxN zZw>OBhD(QCgvX%0ntCG_POOF*f{iRcf=mtgH!D?)MWdAC?T5{*o+=lDVS-9j{AAbP zfG85$z?JH*wAtX`1jw0_?4)0*= zOCkC;I3Kwh%`61lV#xZT6D9ATgwCLqP{0of9Xl z9qbwa0IXzy-Rt*J`F2w@pVr1X-35T;MO-XkKg6G)SzU^8KPJQ=`CG*@*ivC(C2{$Q z2R59dns}8MM=Ui|j1|U!mD%5Ax9Q9c(XLozvKk$!sM#ot6$OkoD;1*g;*JpN6x^?{ z^_9>=F^{CJ&=9loLmd*TA2lo6;qJk~tMu+&nn9=$1<6n}fXvFtsO&C!K6csrIE-8f zrS1exE3K7y&xI{jhMu9tX2RS>r&6jl&svR_Jr^9z0EwFofQT*(Ee*Qb3(<4 z3G4N0&EKnfvc|#Iq+QQj2SE&rgtp*!_70btOP4CawzxRG?WvLL;x?o8IZO*NRq z*Ld=gP_=7!DL)jPm>*Od5Tu*@LO&Ac>x3#y#x6@QeSm+t#fJo%0hZ99ZS{w-hm{4(0Wt4fAS8$wYO_;*U?`30F zIViObgu%#qM9ZTks9GzE;zmb3UtJxN9Y*6vAs4E=Y#kVrBtLUy4S-?=jZIJjQLx$) zhO|wt0Z}3G3)4s(E27#c7DnAIEyR{!L| zA1r~e_!n@>5hb7k@8ISrKi+;&J$ig}yZ^Ynspo_I+lwy;jAOTdupV~2o3IL85P~Lr z_uE4lTLnBOfN%(EVlax`Ge=7@UdDvxAZ?cTH}?r0kieBB`Xpo^KG^x7W=?8iYuf(! z_quP4p>+6&Qhd#{q_~GfALyK;y4v}?54u}TW!=w_n(HX+nU7wDt?Bc+@xG<>g^$o>~`x|zBQLu4>Epnp(J2d`YniG4bikS0D16ydu3=W`ILx8mFheUW_Y8@DLJ;YYFP%b7r_G z6xH_=mbyFT9A5(kR}wY~S0ZFh({T0-jWMZD?_B_c(&o7@AzdHGO_1W2H z9b0U91UXX>D1Lr>T5|neGXd0j*EBH#;q>N3{=3(My?=84&wF>i+j)C*cHDh_^=)x~ z=TUv75GWxNJ%=e1f!W)|4ix@9&8yFv?!dU~L6%YjejJrqs z^HJxI+TWc1UCCi96dnPi_jmS`z737;e^gx`{p9E?dh<^&W?j;Tll0>5;?2d~*~!TV zX5u{~(qyLQJT(D~K*%fa=qu=*>44FEew|bXfHFE0QwuX{SQ+EzOCwU%A#m!H5eKpSAKv@P@;l$Ze+5#(gd7~?bGmYIPlX*p5(_t* z-QJfy0gaK&CLWH?O%Q{wUwXcbuFr(BGc_yZ4?ZBr5_YjW^%%2Y0{T#401wDWN&|nS zK|3jK-z2s+dS3wW8pJ1LfmfbtDSMtxW;S^8B$_|EivIjdKHiw7%i`$bM8job%qHHFM3(*LnJZyAtQSGhK(74#Kc<)bd zU*%uF`Mmt!e*V7<|HbWpU);NUXKwJlySKfwI{JR+l6@zn7DK~0b7jgnJ=rFdyjGDA z)xGUiFbs%+laDYtKpV$f4^`8!^QgSN|D!)jNm6?8>ZGS88Xz#cPg(gngX5PM$7pqS zgkBY^UMnQEo-BKd^Ydd`df^B}!0VKsn2SR9A6v)C(`O&wp04}zTW@KO!eHEj+!^T3 z6WWBxY)?oVA0DPNm{iS4uM}=gM(viOGNA>Wk^9;N=|S`4(FfC*aZ0CWen(-UG|=t@ zf+I7FL8W98g~IeRM6=+fsEGAh@wh;noaO~Bh)L@uQG$zQ|3gp$$zpt%y2a4J)>%i0 zk$X=un4X&>{Wj)-JM{9{ypc)E{#kgupHpJ8{Ih?I(}k8#yf(i{XgelB#OH%{RJe{< z3R|_FH!9)HN}Z`D0l)xd9uR|XWVpy2(J_sToKojJ$-O-jm4B+&CbwM3A@~Jk6LPSK zvi;otUoUQ!{qAn>rn57+h7s{Up#7&8i$T?2b?WJ}kGaR-{=uv6(b02!6rAyHu>C5g zjdHzACmSP4)<$*Xc7+kR6qtMx<**AfqSUljYv}+(Sq|1+Acl>%Yk~NARd7_Z zJ;DS%4$qXhc2;-yw=M!=*$pSt&Ll|AF8l1&{JQDfzFBr(Ts@^~nl5FimHxU0&OJU%hzVd;a#DLJq&g zBN+#S!)7?>OBq2UBs@L-C;gNDZy)Tv{%W#?qQaR=3?2H=N!0cglBk4EGtiqcIeU$O z$HbhxIDZJUxMR3nA_V3a)2*lf@ANKBSac?a`ZLNBNriHSU~nF%I;jf($KfaC75fGP z%Pt%D;Dzye<%;9%ojF%su(qPsgYsZpg0!l^xvR7~5{GL!<4g%$bsu3dyhAnWT$NjN4PtOgEp$x{{P5_6Jqwo~2{0^Zv1|$z zV;}$+Sy9w<9}>(7P{-Sna~qd*WV6kT=qb`+j0hd2f9jkAArv`k$Oi|8GDjnl?(FUO zP|%GgmG_RG%MsX~-ygr8Z!yN<-R=#@V_}SsNs?0J#L-Zuy+IEdg;J8wNh_^l0&i{a zkw$A$%N8?NQInw~)e1DTc~6;+)WK8#?$HlKG8s79C(Su##^J3t)WM5J+=hU9V`;9@ z=!y{K^n9}0uE%>2Fo$N)Ivw~wyINPzuf8d83ZkiTreSN-6@~;Fnvmd-;z%;=bb0b^ z8b^iz_J(^ zFB4&O+>8#Y+2JSERqy`&uhFb9UAdlSbe2?9C>r%VcFJ6}GOB%?d zF$_W(g#0eXb$BgCWCL8oDn`lA+H9ujMbhPZrG18L5j-Z6S3EhEeFAlgF2;&lf-7B_ zG4L}pW>Q$tCTKGRLO0T;ey`wsNfL;D{JQQs_)TpndD&DuO)-9c_VD(s?o$;=wIHik4U5IPHe@** z+$h9m<9)x+T`?NoRE3E%)AEEgmp9WbX%S<77=n#v9fTf8hO(Ik^d;RPQwdRRpRES@ zJP^y&t}PHS^EcOr6B{FNnw^yGh;uYV9+dr%04+kJjSew)l61;}*P@q&6v|e+qr*}WC{X}xAN1yA;&Eo-tH1i@ z$IM#?dzg7b#v;NQf@SPu69}|;Kf5{2IRHl8n5v=Z)Wc;xIVz=Spob9pA_U9+1yBDR zI~qpiI+#UFDxXhAm=(`TBQ#00X=&!`81vPY8ih$>TtkpN<9q+~;FQkOdAFLs{bssH zsWw8EaIccdjIN;Z!TtS{!3Q6F4)QCx?_@Fp5#k_+z8tZpZD)|G7vlE)VtnU&owvge zAAKGjl0twc061vW6hht9wT={{F9OUg{t^fJ7yOqvPf7=h`9X)qPpb(hBYAl>bEhAG zj`UKp0N!Z{04ZPoRT+TmFL}e;Sl*}y;o#yefC0f4WR2WgXzo_tdlx>8H&;c!WkN!IV* z^zR=%L%mvdMMe{% zB^*_y5P}4$F#$adVO}&iTWT91Jjt7!ji3_R%nt;G+4qiyoFRqENTx1l0+x>gtrrt^OIXi1~3ZnL zwZgQkZC=fD(1vN(zy)c-^e9{|I&2t4G-amb9y<8U89O%cbh_>;jg~_+2J5=BUEI>^ zaCLQczydM`X2QLJhirt1gK}6~RFDJO2F(GIuz9W!cnBSAO9YRQi2cplqKTR%8U_z8 zd}Y!wn~s!tLR8ftu%U~y)p)#xqmg_9Uq!8>gs>_j*)Mx;_s*S{_zP&1xiolb0?Lq3 zp=ZbeJ84UvCE?u43yp3db5qVKG%5EczOFD-HE<_W>YZg`{X9A5%oTeVzs_(lXwc&F zvNn8{jz(otR)s>SJcn^=j~RQJngW1&97VIkr9+Yd*JFhF<8>Hn$KB5O|Oa@Y7UdsGIi)+ka(>=G;4Y;q4*VzXaL>s{i(*9Zi$Pw(Jgdi=1q<85;K+=(p zJ)X^u230P*UQfKV|zuYeznxN+RbE;0>?{meHduBM@}2jvvA4M1vxE`XGR^=KFzla+xA8g?2? zL%SnwSq1q-l&=sxBFxF|UEj|85b4%&G2thg7yFcr<(>#Ia>^mH>sR#vEe7+`__FGW zep7Ft*=F7KGA+#Hun9J++uL2>`|{%AX!3fz8$0Bv+&E}{NppJ*e}qB)*LbX#2rNd& z!Ib*N1v)wX>=1hZV=D7d>kbm4PX@>eeI3tfnCj)PhCA4J1o4FvA zskAh`DHtm>2f;y9Xxw0j3I+NdwRfP+plsZfDLipPdIbrgdkUQ z60?^tzgcd-KE3FEe)gLZGv>QTKg=FJI+~!+I3DldJKakJGKdU?KzjJ~=ih#Ra@Nqx z!DMT->fbwfnIvzH&p%)8E@sP|@F*FMFh8Z+W_POo%X;fxF*UPm z3TkDYq%6s+Sr7P$(#ojU=rghI>z^LkG%# z6NAbbrw{t386RlFoFg-Fp*D_+LVeyh4`x=3Qk`EIJcP&Q@V>(k|H*B`=e#B%`XXAr zB*#X~bLL9!m*exsDd&t&#h~{*H|D>2b#ush0E7{VpQ8DJUBD;hd~YX97LEnPi8;Bb zIn~n8&9hI^oQ^{Yb;FXBJ|UBBh5~4L215&O8iNz&#DO_C0YK?2n*{u1!TA^jG%@>- zEIn@2+iBGh6-@v!_V?HGE~l{V)@5O0cVUFMxSDPuP=JV@5g5WiFqM6kTf=KK4ZZSk zJQ7ZYkSSCN{uki35>J#8n#!z`_7$@w622j4-2N^R7ND@qO*hTg9Y+d@;xFkqom6J3 ztqJ`qH0RD=MUsMma)+8RxYG(U-S3IuW-qmmu90I1wM-`yKtb?@E7+*;^ZJII}^KcX}W7kXMX zv}25xUcXq4&R@Un;wK@snIqtTbMklPoAJF7WB&fdKk5=)I7ptQK>gm`yH5!b5AGkn zTuxrg{OlcUpTkG}`*)vy_(vx&ZaMv?JMs=dvs5kJNuaXno!-1GUf(1IXE-*WHd{Dl z!wJ-wZS9~pP-5D9s9pQX`m;_BhJ#X#C5ND6UFS*}USb~#7DWV6sO&$$|B#x>x>55X zf2k7gkBf+pm8F*~wBi@V07O2!%@g^yG2Ew1 zd&GLM@rtrc=+NsX3%0a0s)o-$GNnQ>mM4$MBnT@mk1>aD@_7x#HLe zTlH_`n00TvZUTTol3jQ;dzrnsdRpMPBJ*%`^e^_*H1)8_L1iETLj*5&8vFa=?VZ`1 z>q9kG*(qk*-+lDEANHQtoqwM_eEFO1?l%`-l%y@wFolGy@q%hdqw^KD4c;Q0P^bG^ zFvtr28|2uuc-#EFju_WB(@(8zc%gVJ8U{ zZ5Eq>gV-cE#+e*DB8EoIF4PqzCIvacgs<>B?_hmRE#RlyznI!aMJp%$dsx zP^OB~ebF9_eQ}#q%3aTW$r!g*fMgnykn9=i_eXMt)+I&VyWM5aQFmJ-8d972EYf;y zqz3cN-qNxJR&d8nJp2?$x^5bI{n3s=sA?X}q3q==^+hlgf{$W;dAkdm!K4ap$3?8p zqb_W<{@OEVMwmy!xpiCHo+RIiG$i}KN=Pt4y7PSCb!>(y&AvfH_Af%NK@-?yXz0nD zxM#dxM8`9sC7Qv|EX}phChW{}Wtwynogfa4M>=%00|~lwSb4#5wS9uAL0H`fSw+vDE!s_5S{!EMhXa}^~6qN8oN=q`ErWc2ZOKK6+wirCnR(+4qWVbkV_ne&deLFi~wNg>|Zws^ekhxcH?J=rKg}D#zM7g zN}mUlH)|4;-%|?GN^)2^BU5`J!ST)vAnfd>Vy?P<(Etq1{So}03>64EIQaNm6cOEhgpYkAiOJ&`{Mk8ge7RH@kTSIWsAx3HpfPXZbK&`rAgCc zk@-V|%!|3mgKzgnX)qS$XmAVz&>NG@c&>N<{x^^R*<_n%m498``SjJ#D?+H)Q!u}r zto9}m4+M>rZ^nVG;_$ebtTLh7cE7s|59)|I?E|J*Kw2Wyz`q3%FV>26OGzYwRM0N= zO$?Mcb<(P=Qr_=0CFj1@0ikmKxJGsxQ%(klo7h~w`D?*VV1_#_-sJ|wK5grDX#%2v zf`BXvI0nIzlAfDPC9XuoEFc;gfyA=~OS?Pu<+p<^Y-4h}t3kcG&XOUHzIXyHp)|a) zD$;P~2qp;xW^}&@LauTrkp#@r1)HtE;J!PSD0-U?A#f3Bk<+C_Ol%Tep-xpX+Sm~= zYCF(~`=wyx3E61w?eLBfg8dnGTmvCnMtTjECni)_{)W z31=_j{n?*)^At0O-RA+uQ+}cXOeBr(d+1S_um-6MfK74KhWW#Ng~||hE*OXxIaj*} zI)tA@2l7CR=;X;y8DlIa6I1gr&7~mR%l)xaB?Q8l=~#)t1yzKJ@KfV+4KKm%zKRWE zQCwfiMaFERVW2*JCF4P!ruAWbkO1*^dG9&ej$|zrVr2_LW{$EFbv5b~7dn|oordrn zH#KKQDD4?v5_1;XqhN^HnzA6ITyzR5A;}?8f*8(69KF-$&p)pA_D**nO%5KuKJ1s- ze>H^RInnuIUFSJ4n?0vz!Zz$yiETj55E*ptL8_z&zW}Dl>HTC2PBX-uzU)d+NsyqqT?cqhWiFr{9GXc&w6VtN@D}^sw-4qFuZsa3ipvbK zeOB%O`3JTVM*JoS`1kV}}qh+dxM!v z1gCaXy}ZV@Cz(m zZ4X^BH-CB>a#n=gB>$8Fr@9E7frI|7!h1B3b%viSy%*tXT323vcldP>5~lU95ej74 z$-z6BjFj4t`I&}Q&j4uNRGqS;+I(X>9W4&?M~Mj(mzN#Xm&k;}f04pF)y~cv2^~VT z-LA$I&_S(viT~!myZ?;i>z@5==cs+%c2JaNdYB0{R+0?QLW>bK5Odgn zbXlSs5-Hb8&%Wq#J}5m7)izA~z#c*eRD!d9GS!qXfdKIEfrz3gNUEW>ePx6)#~fh4 zm9^IG%+hqxMPnotyY0-*Rme~xDwjU3uVZ&`14U|B4JIhJ~>a;dts(*S;bc(n>=(P2a)(mq1&?&Kus>{X&JM zy#Hv>h?d0A8VU-7hAskt!4VZ8JHpp^$uN{qrAF(cHwfnICi>8`e7)V5c~g;L$G(9aS#NI**K(Gj zV#frBu*E|+A2pb5IG$~lsU#SCzrQ~nw%gkmB#@Oh9{5WLd%5f|hTg-6Uk*$#OrJgb zn7KXqXM>kJKmPImGkoyv_loXWQ~d0cqv7dRx6Gdvs@80lltA-5*>+?=qC`0nm>l}9 z4HQ9yvoe3Z)mQj7M2>{u!hXl~(%NG-i4c&lKqNNKV~Wo5AqqrN2@DpnIWXB=c`e<%-1PLwV^b?fvr~)uIo(UBsFF1 z;B6)>7i@on)TZUtD^IoAIA^e3?b$fNm>4O8lDzi&%}FwN{-0-e9{sfUnni&5aUh~a z<=9z!c4CYIbd3i(CK=T`AZSh9(|a1^ADpG@ft+3`OspT4UUC`Z*15SlPbU{n{-6~{l9$tQad7p>{<^cj~@WBV4J+gh@ ze|&oV=&SDb*=NVQFTeSC^xM;4Rj0Qvb0Pv>tX0VILfbUTuE`S*jcwD}yp!jrBuG;H zlr0eea7aYC^6x~yNzv=*cw?j?p%H^AERO7F`}<~Pi$qJVa$9xLYXiC_dZ>%8%gSsg z%2w;y#eEn)*muT!^?=@Z2H*P#5;U$_E*Zg73CPY7;!qFsvRcm;v#gi)GUD>K%N;Z> zcD%Cdg0_Zv-C0F4iaR6`1Xtthg1|805u^{f?Rnt=5uybM4jeFS3=AV8M!Og8sEf`^ z7>icj^tTZ6xYksrnlc#iG?&!+7s?#Nx#QJ`t+W%@ixCQ?)QjO6KLP}Sh%jJ&;=Wr0 z0DIN^@VlM28t1P*`+bs#AcXxN9G+m4LdXbT))5UW$0ZPxXJNM!wxiI4zr%XK*YEv| zI#gf!12~O3hQ+uj2>7e~@-EyE0i#t?8^k@`MuObuV}w&+)A*(>TSlvVluS19h$5##oa z(qf`5DcLUh;nPpqN9$YHRu= zXnAvgzU-OJWU@tJ5Xnb$pBnYDS-n_#RKo7Z{_p+%kN*1Lr+NMO>gr&1eZBY7zrFwD z)!)qz;H}f^mpL7kK+zl}4*ipxP?>R$Q*uxZah7LfE|Qu|7#tc$i257@Dj+ICyb?Pp zEZ|tFu5`Jjvkm2=cj1KuHcL|52I(-V%7V~LcKY>nWQO^uH>hM<&{NHXq{^C`(4RmtaW7A&vUsVLRFw(|qzK>z)33vCr|< z+7#O$Gh1fW?(P{HgV&}nUp@)hj6%_>yrcp)k(Sy#Q^HC*RNot776bcl5(My5h{o$B z!!Mrh%wIwWs{PPd;&%`ZLH)`~(B-L)|J>YnBP84uc!vV?KFr`x*9UChoKb>F$Nsv| z!BB7~cYLnTN6(T_l|O=8aeW5rDwa}QAf`~tD+Ly$h`#aqQ<-V+qqv}Pb(BIVFbPKn z>ND|9@c1h6Q#Q3R5zcKfwvDo>3x&MnK8vB8R@Tb6W3FTwB7BTiFt7205g8z1ORJdp z=irt8{o}6@UUZuZ=7|JZXMg`S-yc1G{M!eAe10Eu|A`k+Wy&RKO52u zL8aR(IxSDqt!CSv-%CKpC>DAo3=XQXqo9@^E^F03;Gk6NV+s|<7SC3cY=|~%3 z8(58o*Mvq^v$KaJvQ#FybX+qnxI$GMWs2A~`#=2f*CT^8*WX^fuhp2pfhr1qR0)cHPfu z5h<7&RBLFlgI>WdTGwVL6?+8&Aco@--nB2x0EKAeD{v`^qzC@FM$42A4Y5KGu`tI^ z0Sf0GH1RtMC&k<4>;mdmeVm7d?A_J8k`~8SeEW1c+I>{ra@;nEr*~)jT;_#+&0oKM zaP$1;5J@h(9zI$o3O_=aimZ_7#T%|`gg|(&#YOl|AZ?s?Vy1AtzbYr- zPpm!@IN|RxM_@wdO?_=lA!qK5*+6xX9Cf_Th4S|Ed7%KmhsJ2v(n&M=BjWwS_;}3) z5XWtao@G`v`0`01BENE&3MJuz;bCBc==D{c) zZ^)4-y2Vh~-&LkF0kqN)IUp>AXtPd_d)o*}Y1fo-eRN1S9Ak_rs zAJ}Aku`&9L36Q&g^xdD`|C9cBc5?FJ=wB9p@MnMimq*Xuep@VZ^%V5P9)GebZgDb> z?hP&z6OwX%+T3IDQ?Y_+zj*mE?U_%%OTbuw>sd}o^N=j6_U-gC$1y@{Dk{=q_@kY+xBx}8 zVotK)u*N#FFBLOJFreY(Z;*s%@vxN?r`~8OApkT8e>)`Abu2b$M>e@~A}CXKTijD@ zm0d>9!<7xKVbkZgoWPsw*l9w1!}~j@c#P>X+}_@nx8J<@NVVkJ2hrt%!dJqj ztL`5d5D32cUC-w~gk)S3UJF`U_fb1xzXve?AHM(YUh$zp00dzq^ODO_n60QOg33Zz zFE&u&b7KBj*b_20vHMk84ULXQH!=g7hV*gr0SH2q?~0#5!hki2^up)G&}~xN8x$06 z&Y6H_!diH41dXoFJ8auoLoOIaAuGIadvx>6LKNJ zg0#mx&)pd%J6)N4A&JHKc+jo0^SeE~pXe{*waf*AS?tCT2n&LMV8b5ikxsUe8Nw@sY~4X8 zf^8vbqDpuWbk>KxMrHZ`&zFRc3yh8C7n4Bvj*N6!e9iyzf1==FO!D^gdf2bcxj~Nrbf(cPjvmmr?pMeJ%H^K|k{k#9uPyfyQruiRN|7P{TPrUAU z;S*-FDfRV|%+z(KduU42cvCc^N}7~zJ}tHIl67v!yd!*WfE`ixiv>OI6n=w{Y0b@^ zm{h7v7_dpxSErA}H1y63xdpQOGxyp8jkHiE0IJ<=0SegrpuEAk_vq}S{_X1zD3R>_ z_KVMlI5_;Uhq2luaj&gM7JbaRPgEj$r_2xx>Tt7@{7D1aT<^l0j&rahAyraG$ zf{?zkEPAdqQ7K9>TX3rJUKqQs^)-pGPPE+;^4y#Y9}=Sk8RZD|^jfkRxw{Ed`B8XQ zOxIP2(gkNJmV`N4gBv#_QFJiuVM3=Uf&3jus-q|@E-GfPe3p(Uo8Km{*_;V>Q~Ln z*H?msb%qJtTTbj!Pm}gWo9Ai+Pe_j(mH4}od&HDDW;Hj?T(tK>rk~vh&9B!bI7#*~ zX5uxT1Jmm@(~#=4BSpvMp|~dV=novh-bJ*OtoX1EKlrAgIdWD|K1@|;(`@}X9v2+y zHBgXjq_Np+)F$v;TB6~DqmDo&-LqMrDp$s(d)U|~Uw{3BdQn%sJNqX*0~n9sV(yRs z>)mgE|EnKwfAQ@%14v+Vz6Et0HYU(G^TfX(M8*1|6VN~qVgbxyvR#8y%asym48Bsx zL2ip{o&b?aOBKZ6IBX=cZ9;6Y*h?2}vsf1?$vcFoSd5mVys^XdE6o>H&0^K9wz`w< zes#I^fFs5WIdJa$hw9^}e?7Zxu&* z@gwZ^_C?SLC2XtQCKoiiNFJyT)5@9D_19ot#tOx`Kny4WrzN2h3S0u*^L3DuFfgYR zz6d;iaSh)A{tHpbnc3=43=Y=p+;)q5M|ToZmbZWKgTDbyO<$coB%fmZrjf6NwQ^wv z%OrXbzpK_Fz)Kba0EH3n2fr??^T`mgi%uL^B za3JqSm^kx0Gl$n;^tdh&N;|VB*hA8ydNi+xA?tTEvNBN}%7+(qAKo*D7c(Wy7X*Np zpH9)aDPFxveSFVB? zzrloQiE7!kOS>QE7sRi6_|Zq7ZXI8Kr}LuQmvm2wV1(X;1J3bHlI7LyF6bT9o*1%V z=mij+i83jZUJ6%aRf)SPOX^zaV18ZqqBovo8wf($xLB9{_1ld0)!05n0jBG^hqsy- zw_@y5H80SD0DFN+^XpX~0w~??Vk2}!H5+#PK!KZ&(KZaRo`Ajc4q%bi}pbQMG zX`@k1*ibORVUd3PQ=v`Bq-Pm1G>(Q5b2vdtyLgBP?d~=@{F->f#bEmfvqMaec9->? z_y+6F+y#ar&JA(HfRL^~L_>r@I4 zq+(caw&L-BxBq2Q=&+s^0we^tVL}N)-zOm*C+r{yq+0>ou&J0gKzG(ojLZyty8n=Y zBV>1Bq33DFJTq!^7Y2c!ya;9UrLp8_AwH#h=wZ^3z-1Ubeq*mXW6Lz)Qs*{lRUtjh z1N)qA9UkE30Dp50DQGE?pU^xuz{?FrJ(|%7Qla+ z(b~8XB3qn8L94KT@TG(%($QtbA^%!E9}_@XrCsks!F2`g=AfR0hAYOXXz=m*a)@Rr zsz#bmwyGAw5b;W9-W5u?X&u_ri45XqdDhMOeI-p~w7Od7@+pLn9Yn_GmghROV`gT4 zMWY2e@Fo|Fq1hNCdEAZBsGwteA+GMAT#Y*0C8xhohRJLCjA5^<7xfUYeb{a}3n|i| zt&D84K&aAbMvPPzN+Y>uiiHxKMy$>;8)8mEGEc~of}j<1*GJtZ6wM+4(2A@eL=Qpx zx9FTVBH@UkaitfMQ$=_E%cs0oxkO6uk2U6*r^C!V;9%M)Zn3(*M%gUPq~hJ2FMPB zI;DrX^K%3L18;M$B0|nEd#>bKF|)s>*NrJ4P`eu2-E;S|ObuQl3>MVaeS?7DbG#NH zt!~8YQ2^+1{G;3!Z?ekk_G&N@vMfZxust|^$po&lT1h%1cZ2l@CG#l~XgDTL0aFR# zAYcRXQj{wso9Q$5@Y&hboz9)xm*Kp@2DB$L06zqCG=!k}Fw8aRt~N8?khooob8vck zt;V#yquvQ;QEZZm-uohaNRk~sFBVtAKG5AJ@4tgDBcQ2}9jSEPtXD0HZr#Nkz-AEB zl}G6zga_xalP`~R zh1>z)@8rVZ?~*?1+3Lf+C$-YUZM9iY#=v58@Mp?-@9F}R)n-W0rA01sIX;3fBlR3| z6^umYWKlMSE}IHO7j!;Hzs?;&T#rOj)$4{qoTQxC^;h^iItHc9B>bEp^NJ(i)3mIM zY)}nZltzbDJg#o~4<394QBF@T@A5gK0)#uYe&Ly7czY9bhiec7dZ0g5j4kSsU`oKe z&X{}=5eiJre3V2*%k(5Lbm@pd^XEQc%#ks{M)GZJ1cTN{9}d3bnfLE!h-v-CSoJ`g4$T+jl~ z<|jf_b23HXMMBhnvxaW&wqAWojS1m2=uKmVv5cXbFlW&ZN+k;0#OWWNAhnIplJYzJ z&UZd#E@*n%d-04kU#r0_Y2RiJT&`>`i_4pRTGAKvnj|NOT$QbI*1dc8S>EYv5}h;; zn-eC%$(#woI*;Wv_EyGod(f*3{~MJyB0qIi7hbmm=3zV@)lTS?|Ju@ zT>1Q~>7t?WVKL~A)*Lda(o0)RB;3tu-6!w5lV?g|G3lK2cC$q>-4b(P?=bO@X%-1r z`v#FJT;s4vwmrTNptBYf=4r{YbNkxvEn%ol71jFp`-DqqGcq2G86hBOC}_$=m7FRK zPAMl%w+1$^A9OAcKCZ6v!QkfN7r*|)w>CEsV~QWf+g2gCU_N*Fsab`CcRbLLx`a&X z^JCn+uIf2yT+Ue$DJgup*@)vyA5`U7Am;G8#1m)& zsILK$kVDKrpf)*CnPc<~B@>ddTEcWZDWXEw16sl4q=zKREAK>MCS?@XFfxD8x_I89 z2eNh%H-=qqPp3Q8-sKT>0n5wFI~z04#)q>Izl71k368*`TbMAaJJVMe_c}Yh@$h(X z$-E_`-uq$`YueGBo<92KKTeN{qTL-G>qs}o<8=&C2&6;+sEX8Dg|&8jemSMlh!I^s zp%_RT$K+U0;E-MtDnOg62Ew(12;T@)lGLR?xp}c*1xAukL4O$q&D(XSUW)nDF94r` zy84iyLn4jc2g2v%AfB0DH>Rl%sF#hJ~f)b>#R!5o%p#%p6 z7%s;5#*sj8&nb6CQf+}VuECMa^C4WCYo22Kz(e8d9(Yd9N+n*bH!Mx0kC;>Wg-xI^^mM=%N*acjU5SI_BeQ_LBIrt5mPEH zm@%WRq7cZX{DLNv*t7o-B#qm&5t(>YCUlR1&BGc=Xon&ryPVXzjnI?Ohe6&K{(%W& zXh|y$W5dMG$f$we)npMI)exL3lrSNb7P z6g5CI3hpBufYs2k3qu#G_j&tHDD^W6tu^p20eoxgtlkj~IJo`|e*u0+Er)5heK3s?J@ z4x(W_2ag~Bj>Ith1JsIW{^1KA{rpd#{}*39`?h~LJZg6Odx}P&gabl$MMw8&LVzwz zcXUM&=CrlK8mPx*v9sj??rL_t5(~Z&H%O z7x&cqYpoy_(@F?h5RRCk#bm>`0K7(+WE{Y&zKubnDb0)hm5_7f$5dGn-`h+_my21+ z35l@X2@TC4N1jjsFXD(Jbk(R*&sc1&IDCNh8^~_o1rD%OXeGdW?uYHl{ro$G+}xQg z#^>9ed=+iR6U2LP>xJW_Nt&) zUU#QdvkhrJ9^)xoiuuacn^qs^arvcAv2B6d4f{!R+ZJ-c+{1 znZg+gt!IpxsAvI%AtXS%D*D`coSmDf=UgvQmX#!Q&hc2@0D}9n*!1?}bsiZRk}(LT z@i}A|W2K@Fn6~h<`PkrK$~eO$!Sl~xJ76ei zeDSC3eXipDpv`#?jm7cS2_3#5XkPdE$e$u)fyU(Gu){F+#$LyvMTXwHL>(6dDKbiC z35__)G@f-pVMJtFM+Be&1c9z=xi{ISG2YMfFiWFQ;O4IVz0%L40XZqZboSNWNLBbc zvQG>J)D|JK(F^Y>y__3{1Tm*sgh1h$qU~CML0?gTVosuva0;@=k#<0H?i}>~7_6}? z7LUR#E^l|4KQ_I6KUHl)I7*KBlMKc@)xC%sg2rz5erBLeWPWn;!OhnvPdK@VqB0%& zN*8GxnKErnsj5$4za;DM7DqEv5`sebx?d(yS6m2>#gOAmP)pK`Aczs76K{ODh$lF8 zL}QtUZS29DT5bssQJNTC(!?|y*9hHph7R4`wv`>WR{A7`LhnIMd0$6&{hYy7^SS?kvZ;SttmQ}?fZQ?@PF}}VL96|%^ zm8X6)UZ)LBM*>L=LlK$?d6tH#l`{PjB%G!#Q5)8negND<*BO;4>XITbd`;vFVeLRs zjT+e)+BDq~x=~4x;MWPsPB1t}x2cGU{C}~_HuX-es zL#$0whQm3o)1RrqBZPoWK64@$S0#aHTq-);CLz@pNE#9Gj?;-pg_2O`Mzp8HEG9F$ zQ%qTCg;t1%L%497GW_2$iI zoHL)avopPHebuh}6&s-t7*@h7sW3gv{3L(eG^-JvY%`9iA_?U7*_#LCNw_06QK-|= zg3m)3pPM5%qwru&8NG-U!gFB(Gj0SS1Rq2J*NDMkTv4@~b`FWt%^ZV}+02*gv{qUu zd)4>R5uLBS-)aUOow3f@V=N4E6vl?SjEIzg`K!X!Z}%61h(f^Ozsw3325zo^03t0Za>d?&8ma!5so1JtBa=ZOU<69LGkfO7=K_LY z{Y12xd)M{ku-706$rgJO_a!%_8sv4kvwfL&=e^ZrG2lItdgRD+#I!M;@LzBy1ZBSn z03t)YhvTvs4jmbUFS}-!>p8wI9=;3ns}G^K?nM}P9l~oxCT8&88 z)bjHIAh>Mt#0e*SfS(DFN)tx>WqQpg5S%m_&B~(Wjl{|TbhGe>2Ir^JqPF(p9>o&o?Lkl5Mb+QCMM`;{+drh+e421UdeRH>0Rfiye=-U_aQFq zH8cngQ6IP2iVF^2X`Qjf=n$4aSAtDEp)qNA@t5fR#~HasbaQ3;PRny6`D#2_$pwt| z3fK|0oR2Ug4PJL=KUo!N2fvzuy5B($#(pn~O;S!MT%jKy*=p~`2c6LPBHH`*a}7b_ zIFvNeMkxbO%)DGCG9#c1zF2FR--V19@wgCiAHaLeub(n8yu{NiZ8~kdFv3GGoHL zZR{~GKni3!9nMX|_m7XiA-osY4!;Qk0yI>NM%T%kH^(I9sk2p}3z^jsod#bIht0Bb zwy#1HdcG@m2qY^=sL?{;%p#UwpozTY)y6*4;vz+b;~>>ASD! zE06{!R0l(lmndP{l7U+4o)B#O@=56ZfEeEW9Rx;TenD^;JDOcC6#aY!)|xfdfg|j> z_R}*}9ZC7xX=cnb(1)9|P9@@FZ)5-WOeoPa<=ZPi&OE)CZFm1a#@@77jwDOdbB~C7 zxg>L`s;=q<1I!0OGv5U0|NjO7f<6u)2|a^W_jE0pNis>^CBkLT^PXewmzfgA$+*6m z9Xq=nTm5_Xz&mvl#{P*j5kMyM=+G8u9uzVSpT#|X+4=M5clY=Ae{1p?5qSLZ$3MOK z$AA3inzMjAR@OaC9N7+9zAR@xiI+Yvu*#RNc+0eosy!axT|yz4+t zN6}0yRgeLCF@=OzxMW3E@;t}0Aw)cV9cLKT%S}+y5)O)9#@7aHkb!ct0RzdOQPhpj zw-`nRCDuT;y*hlI2OW+GEM8j2U41xu?5+_}Tc~}C;`rXhEk~t~t~dP}jkuR`k-ui> zb?Z!WnqSrvH5huUFFL}a;y@F(zD zQFat^ncq^ND0r(QSPA!07neI%^kOGw^PRSTC|fb|6G8E~vlC1F4z<0h%9ViEbV50d zg~tOozA&U^t$x1U=m@~AxE`Gi2}r&Mg(7+u5RjIu_&>{aWyKgZ%v;WBMxKSJpLA6L`o_VVgdYK`Z z>W$@<%Q0!Q;n5P>pm2J;g^rtmpoYo;BIz{L;6S>KP6MaihOVD!pD%RIOphHSSRJEJ z4|auz2uo#MY@>D$_QV?>fZ$T|du|$wj(B_8x9w1%pJzfs52F|wR~eH=1X3KBQ4~mU zZiMQr^i9QL^nhv43MjNN8}e~>tdXW_FL`l}gF1x&We?#4_@QW|?KJ==DUH(L$~5>y zTc&gLf~X1u34N5yY6dfbOAg4|?8}hq4Zego6pe)Wt3pw$NPmpK&P;ImmSN7FTE5%-5?wlk+;ylBseB`oYxq>npIK3e?835fzaQA& zH^gy;ciB1a-I+n0CGxl%Cb>(sqv3&t!y~K9*&D;(R^7h zUl;;@nuBs1hC1SYW~eRfapU`uGClP{OLgO+EV6H|~9g-ahh#BScBr!yk z0_cD#V|7^Q3qTnGbFaiNETqCIWiAD@wBe^CYlfuJdrBz9cebC4wD^b^JAAVBvqFdS z8~Ime2RaoC81_{#d`U0=NY@bsEo~P^ZSr9t4qdbYOI4!aQFb{IYfuy@RCtw$FD?&) z?w{H0bm&GcU_po~bfqypsu|%AI2rB(-bi+PH6yK*6}oxNe|W)*iU8oB#*Z=V5f1J9 zXZE9{(z-o9vq@|8rX5Skx?wk+SiB4;wP9rFMTe#?onkXRkLEmdg_53Ul$C{R=+*sq zD~s$FKNzy%&@+4rABM`aOV1O1A}kVOVvsBM7%gGP;`DjgET@T$v$Q#l6L`f=bG&Ox zprwX%*PA<6*PLwVKg{)pVL=FEJJn6+*KO z)*Tx*2WkXCWJ>4~kzt%D3UZiNB+R``29Kf7gz64+JT7+Jte~Axjr3D7R7`(SMyIxo z1l=QAiW|+Mra)mbhVN*A4&tz8U>;F26wDCc8U*7h1M+!rmAmXdXO06{ zwG$%TwaY({t|EiwExgRh}50PJqI!h^b{4HhKZCZ^%l)@hMuy(3N__2?idczApUG(R8%fuE3iw0kAZbP<^^G$GfB4}q@RBHjhycCz zdkCXBd3A-ND2pI<2$ytLGlGVrU^;)nw^2x>=0bj1gn7^tukl(IDf47$C$K zc}bIwm+SCb?@j9G@Y2{#a`ogLSj=%K>^0l6u@!P|O)W28SB7$5?+@4E4k40=7? z7}NC#5ny4iCvG?#2$T}K<_A22)R|ELP(ZK0o0EnbA2g}`3B$pKB$Z|=R<2c!YHJl7 z#9y^>D~|tj!Qt~7EOg??h63Ppa&}wu)z4-*R!rdxOB%w`&Tm*vTbzr^PP3?bvxxvs zd^=)gBMXOs?2~huvmmOb*8v>+0P0(S)OMU$;NmPqBxvoXaoEY){VpSZOXEhkFN{An zgK^Vwj08pn?5DR$H{?#opiZE2^dcN@(5E+lm3 z=7^nRGKde^$06lgm5EwD{O;B7fB1`?up3=r5t5!?+fKp5ReQzr2Q7$ih}lvNZL_tuIyz^^v%{k7OZHttcbKX zBGY9d66zzI@E^8@zPK zUOM>b6s&2xL;*AnoDJ!E|DS*UJ2vHYN25`s8}yDecb`68*v@|ay!b_9@X`3yZ7Vi9 zC(oH`zMS4Y{djuw$M5-K(U*(c7O_J}I&u)8VTwXVNar&x>Yj#~xz|Ypxpwq1NeC}J zBymFw@ znWuJOf|o``1cH!SxhN|plqU!gCq>q38kKUA1g)1$6pwifb`<)U$cs|3Y@%@6rjjh* z6Tx77-E^{C%h4ZkgE99c3i12@_CS=6N6>3L3QSpOmyfBf8A{(%yo|b=g)7M zDacURnxVt&-FLYdfu2auLkvE87F;SaP?v7$M5g)0_WU6iJd7qX%F{!0ydos0IaHAh z46wOi%N-X;%@W)9IC7x`-F~3-jVF0AE`>i@-1MIqK~eteg#~9T{Q)cna^z+slD=aj zQ#$f85eR26A|bX5F9EtoVB3Vz^`M=hRU!i_V)!r|#V7c6dH?xan;)g;@Ex56-}H&a ziGlaShpXS$xIO>;$JamHf4V$=e0Y1ld%Uq7a)_mt60tAE{0Q5xxD%`DsonFh6p%tk z-F%JE%7{1OtqSR^`O~~G7*-&7DwqK)Pmmps7ei3a9vX67dB7gN{K{PC+1W-b!EC@g zdi2s46*f{sUcQ^>O4h|Lm|5tt^+9i#i6%Uw2IkAS6Aa0gRQMbxN+<@Bsd7M%W$#L} zq~bW@O&8}$M>2SwnuPQkXjW1N7^HBWT3D6U!;@i#zUne*U^NC$wNAzpwNnftpv(Am zNKOMz`yQo3?nQQv4*Rdw=HJ)q@5aAEj114uA*a$vj8`3?8K@I@cyO|i!&`TE=-V?j z9fq;Jeyx$4N7)ENM^X;p&Q4_X;)zeHr=t|tOHw<%n5Yw7(@99TPSB`FeE_zoB`OL( z+5raj$WiZ7=XzIcQJOpiHgE-fZh$RH~K2mMCZZv4rJ zK=}0P?%lhe*sz4cPY(}tzCSMy4_30+sr=LUHF%qO;xWWO$~X2@u0;bsoYAMhJ-=bb zq6=*>#?!@qmr{y!)F50vQjAfe=nt^{5)W^i}7a|10lU&rWl$KGfb*b z3=!fBfMmJUF7!2Utu5$GA~1_Af`t(bXZx-W-$9eY#D$RqD^)9|aFbU2gsJAFI!Ixh z0!KgLIEA%Hwv+%;DF$!Bam^57mED3V1*B)b9LNBN;gTcK&HSv`Jdj7}8Q@N789VG3 zqZJ3lRHGUr8hgT;YVUv>;j_3spN++q;tX=eu=&{m>Jnr-BGrL72NQ-@j`m>m)eOuo zJ=uScMCd{{DWRB(KzYK)E$vDs=Vj>F*Nq;I!0 zQAvZsWBlmAAe3QajfIwX_o0|}Kex_tbOIwI;^2-Dici`EKX?I8cwGF8aCerf*O(k* zv8{`znr@!{2tQUzB+VK66x`q=#$L}E68yq*zPZ7|Ah!!oRLLt##B{HZ}{u3Od`bfs?*~GJQvscIixi*MG00Y4DuK{c_{xOYQL936kKc@3c;iW6nfh^ znXrUKk<*prxkubN23OCM%{({iP;Ig6WN|(*(1H;SL0^YSzQtNKi@O9uhSbgcH(PC zVVWE@x8J?~mj)?J$+m`1jN=yjm($U=;T zkO$?^Q?kg0Ft){$13Vl80b&1G!Y-p2Y}d$Tg@NH4X+i9v0fF*#LgJ`Sva5CrI@7pu z0t~}#r>7xvX9bSDEE#5~kYU4GXD~YhS3Bd{z{E*KBNBjDr_}7bapEN8dT@>dl>`+0 znz<8p9^xFG2Fv6h+j=xJh|X0A`DlqLGdRTXYeit)BguZ>ia(Uk4Jlgvkx55;*+sIr z`QpbrW{d`5<)}`jDZw>OJ(;As+KrzEth1Gfa9Z4LN%|J3KX=%@92W8K4$Ek~PX}W!Db1 zDpV}Uu%EpPMPm%vpJps7sifJ}h7t**Ke-oXs0HpYhYVF(69 zZKyZ4pUuEnk9r$zJ3^eRAN^Cg7_Je%id;BwAk@_vgkDQFA< z4$~RdN?&E@A8cnl4AZ!8{ilbpK& z11FM-21mc!Q#!zlHh`B@bng9Z;KY_+j6V~i_)_DgZN>$Au!GdiLSJ=_mdw-|+gD-5 zxHe9fkTy?WsVLf*?Y2sfEW%bt26&{ZQKJRk%K*)Nw`$_sN%i`Dc^N19?N0@~{3QJf;~mcy#F8My)Jy0OeHbK2d-y zn(hB{m%A5*{x}*?&44+**yk8QMPWuj?ha+&WMtmP-+CeEbolCVA+3DmT{}s21k-Ox zl9l%{bauS5Po$eB=$>EU83(o_?a-W#Z7bB3{*ZE<_I>mupn9&n$6NNtBuaMe|A5In z0CzFM@s0ABOTz9)?oj2-RzQ~SALx-{aGoB3?UFDlMk>)!1vz;1d^_7~M>mRd#cqfB z)ulp&lr}W%WSRv@5Q2>oWNbIE*eDF6o=Y`{Y%H%r z$nnEN@?~K_De5+$l9wQP11=H9~|qC> zCz=612j-eyd-UV-YGvl&$K}s|{--!6hhgWZ{9HNP6|^=PZ6+^wx#@S_EiaZ^4s|gS z$Snp-ywt+oZ9_)#iht?##EWVbw%z01AumENrW&H?VG60k;g~EWrHA40-1Nfm-F)HS z81Q0_s+U%v%?q_`0CXTg3?f~$ ze71yuRkO4))9wd39Ujiqv1yzt1K6p?sqQH^#<>w;RRkcWdkrNoBh5Bs6|->$hRWG% z9H?!j(lm!=MMWJr%st)S1i(4OSW|A*&xYvbYa`wcpmd}ofxM)E zz>Gm(FR*ll5sW6)sDYEuXo94I*?*~EEC`xlHj%(WO;wT(O>usOPwP@2u*llCS3|-l zMPK?8XPgS=#HY5Yy*1Wq4iVCajuj(yjz8Jc)XAJ1l*uw&SCeKgjxJFRabFz3R-Yt0 zv0V863;ZRY{XI-2_6tFOUYu^she3wDxSd;~dNjA{-=*m+J5b*jcic4rhL#rNZh*U2 zmw1$BytRjrTAX$;L7Tm;)G|?v#ZmMy14dN=C{JoSkszi1#gI&e?saDcE9mMlV5ZPA zb1vXmI_hQ;rBoW4OkD}7{3xXRsIBa+{)ov=S{5j)3N?p~F|UqKgDDS-E2RiNQWV2s>!S-X~p#{Jqh=34FgL``v;@}?pu z9SI>j({?dnuGL_Cc^2$glUJ?p@ZWgfp}y^Y_*@f$>|^`%$%#_X&TPpzw&=;qW}|Es z_QB74{f!0we9?$`0xaq8D1h_~yOh(fNNzgxc3z&L$*sJgzrBPpG(@1Nkwpo&GFSUI zd|hn&$~7J*TvF-LUEs9*)yd>&5%aG51AK>|8IiE94FPdKj-fkLNr>RIRFtuhKoO2L z2oDkB>1Zhb)NdKq^98tc|9_;rNr4aI=Xi~?bZlPBP2TzN3 z(nC-9U^F!0ZPLB)7e7VA6)Cc~FS(XZJ&m&sKEjRBngY&qy>|N*rS-j#I{f!Hr4cle zUl zhd=xqQ-aa|S3kV^<@WsfPmceGiu{L`bGD~TQ%1dyB^KQYsv{E_$+XG^CrBMsUgE$Q zP@tm^TPLdV9eJ(9_^(DpH)3E7yVB(xkd(N%R3>^6CCguG$r1)c4CA*SVld{+7`TGP z!QAL@LB|d?K%mD70>Rjk><)L;Ba_D5>rF?G@Ez;>a3Sma(ohFWg&Q{d5XjR9#KkFoIR@U>#1b(*h z#{n3&7yi<$9-`>2IfWxe?%ak$S|fw;j0rz9ZO*LmfA{V^)8$FS_&?tWc=P?kyAS{U z`R(J6KmFaefBF-j7$cfkwnVu3e#hhHPRGcVEmoK#GCvDPY1*iLZR;6bQf_O{PhJUu zt!YV&q0ww*LyE2`!q%`3V6wQp2(F;=hQ$^cu)e2o86gZ(g*!5Tv)oQwWtbFIF+&vB zjT|u`heeq6G3wa!LGex77v&J#k%ldmp~nx9KD@<;ZIx8ve|_+#l*#eFBjVuVJIkNS z1GX#Eaq58P49mcDMgp?;fXj$$sp#|V+0Q2Nr4@0C;w*$xFs7BW_&g0h?ez%o82TT| z2FFYeNoU@y^=%H!c7DOxc@Rn(&?LMB6G2+@YP<-sjW>>>4^kvUoIvu#NtnKjzmMj4 zuCs|uIye&n^c3KF;{CVZqpwV&h%(uJZ#cyF|MKuHxW4*!Wlv8Rnb9oB-kT-g;tSy8Dnm1U z0Q1O16}%O^9S`hH{gt3RREFG=GVOHk=^Psm?EZ%IEY`NLRqAh)U=%c1mP#`=rE zYVZ4udDc4j>d?`fuB>8ZT(M><$Y@>-hVlunGs7nj53fgAOsEZlQH{MQAJ&hk295x$ zUs|RdoX746bpoXe{0Yr73UHmeU)*sDAA59NG5#Ct_)=v5rM%b={82?ML!~T(pP_$E z@Zfost;pfMwSD#Qr<2d~>@?irm7)agK6YtXK;5?Ce_eCGxJk{YqH3Ua4hKT__~($o zJ}{ulmk_`5YD8)G_kD%}IhWxdg(5GC|3|q(qsEV+7#hd8W6&kbinQ7xB;V=~CwfL- zM$vvmJoIKlPwqL&dD*t_XCp5|gnTB9Tc-L#3&VTExLgW~!LfY70Esb;Q|bT8K%~NGLDS!1;T82HSvc z^qdg|OQ{f{ejs0ntN6Y6Wf&AS8B1(gP_iuUQvidzXSJLFd#XLK7X9@3^TX}=@r^@) z6kqs5(ovoZ1$XhL3?fkkO<`gvS0H=%{p!1mtrV3pkD&#SX9JVm*8d3WBuSKMUD_tgQ6^w!7lNyM2NG3OPvDI>5n8EWL$ zHJ)eEehocGqQn?gi*ELT-1GDyD!Xoswn_e6IuBZSyJ8EKIhI|+{&)WEl zN6mag{8kE-HX*;fXqt-;&G+OC4|ah}14K&@7JnxW)M?|jj+)X*GE9yx~b8EU}{xQ`hr- z{e(ZCN5N+~UtLfhwLtYRq}FUxnu$%=iq73s`lIn5Qxl$ZE<-p%@_iKS>&J`wa&Q8q z>k!|yClvw2sf;i?(Lr>RXAZ40nJkqIeYC@`OU1bF;1;6 zr=j&o2NCy}kS4hrfUepZj880Cbt{!N_BO z!+`PafBH}V`>WsoC3%`l&8Iz!Z#7}yLw^Yi zUevsP`t_<7sNl?uC31#SB9IuN(@(9D@yBSbo|9roXIfrjsIoMIXs!Yo)TR|2Os)D7 z#sl(hl}{+0pq&WD0n4|AnO}TvN`(vavz_rSG-t5sc?qi=l1WeNEOUv^Ng?KFq2 z;i*}IgJ?uYNE(Bp(=7|DZ~}XWB`p}aotz<_Qe33I5?DB6cDsyBeBrfgEX(QkX^>Gc zoaeDaHvOSi?>ChWzQ3TfZD{13 z#=eLCL|!zpykcEDWB*4##) zs6#V{p`(OEH#o%^HQ4dTQs3bST+qXGWte(x^DrFK5DElLuGV-YXjyK6;iNd zn)*X}cMTri5~6E}raF!0WGB2X0--kB}R0 zkHMD{Pc#@$*ZkFxJ4?wUC6^cv#;+m8uuRT1dB&Z_(!ZIe^I*&s)_2=}NoQXS|JTug zl~8^i1?-3^mvfp%Y>4TXpmm5VpFdx(x9x(CrToeh0>kgfa_0rT;%?j!LtIk3XE75n zLMK*1GeLV`4@CC(N9Qp1=TD!kxm^b^YIZjGz@P`cr_rAOgHCV2a7G|}%5dTL@M=_C zek1U4r0bE8$AA9k|NP6JuWuM3=q>$IX_V}Vl-n;n!1Zu?)zgIL?NGvTfVs_|xjlB58SJ(da>~LLKmQ9Gwj6FIcw0PmJ<(eF8OrSh@>WVn|?TH4ri4MP;RngMyO!9LKL zS6hBDhyM%r?vF^@{r&qt&;;OvALnnr`H;;`Y$DiHc=h|=|94azdT%4o$oCHC!8Ysi zWMK5;e|`Fv4nRI{hLghqge##PI;5ZBz>6Pa$qqyiRec$b=a%f0iDUm#VKN`?5|lwj zWMxqymvsB%_=9y2_=whv*`cP$uTC5SYg4^Ln z`K?(>!B9NH|Be834C|VmjS9tLLzJrz@bDMDT82f22R747sQ>^V07*naR4NTeuH{ZW zuRleL!ldTm!xz=gO18zta>*2P!I2>c#2F(6Mryg5-DU^uk!boEf})N>xEL|szrw*8 z7M4Shp9%L>+Im)oUrysV$t5KPfha)HPN@`Hva6THxMQ}+g!X&RDEJ=YA8P+{0(;xv zv$b{LHB@oWj|#-;)BP|dMvv$l05#~sDTBcfgAM@R0;;eUUN?oBAEZk3lF2`D08Hx* z4?>Tfoah*H_DRcneqJ*lHF z{>Q)l_{a6kM^%^8FMoM{{nLMY`j&5d682x%6=Jt6X)*dCD!4n_^tF$yWm)IU1dIXg znKik-St90EDl^BvWYA;)5H{<=BwXB}F#f-k_t<cu~N)Ro%E+3-2D0q6iI|7-;O;n<>AI|xu z#@L+%wLxlZ&dP+cO3`Wa$PM`(i5!f*o?+N{wLE9YXrtIYPu2SQ9oP1!!>^+QVYKms zUzejC25x*4(x=5x^$p!A!|!}OS`ukeKcrGBE%tGEVHE?iagy>SbNQVDT#jJrnm4e)*{TttFAmkO1)s>MyuIsy9+3PuD|p4|M^)4c=Hr1HRLFqcfY zRumX_8U0uP_LTeINH!s3LMwM$(NZHYGNV}an?QCLheLf$wGslC(*4O9YD%3CQ=nhe z934tShqP~*_s@U%4Sb)NegkA-Mib$Ohrj;y?|=Er z_4VhUudjaj^YVsHKX&-Vz}aVzZ%P>E>)Z-w^Z>_@$wp2$CYsZ_kSYCPgN{pv@LCZ? zGc~mQ3|HhxuH(&lM`Bbz$tE8iW9&49ST$a6W}lj&iYg@m2OpS6o$j) zm@=TLYJy!Ks`lq23hZT29=k%!OwM3v#+i_U+K<9(*T3K)zS5n<5^f^&Y&;|U+>K~+ zLl0VOl|ScILod6n;!=DWU7gDi11~tt9F+%tZTQ$;HgA<_1;|m8+^sDq5{6B zad#axyM{k#aS{-KubJ;p-lCVLDI0}4vkybZ0l*_Z5(>}1{OiB|$4`I$@~t0^{bjpj z2#?vM7(5-DjA$_9uST1u{;31^HhJA=vG}FTa5g%H@thH-*(9BYSq|XEu^l3S{hq(6 z8>0-(WD|6F)k8EYaT|Ms;aR(o5|GLrqfRY4<-9nfP7i^y)APW5dXp~B;f~NVRkNIc zQ!FK%i5FedFcM&gljX*+$x9hu%_~>Yw6?ylGmlWT`pZRXRBGQX6EgJ3`MIQXya0l= z8+rJ*W&{A^H*a?w7QZZh=Yv>ySLO~0PoG`w)sCz56l(Q&@V&S~&v@d_a?zEABgdmqV6l>j%05sq<9d13MlbU~$ zIU&zy$}zg!XNXAZ!Zl3=9V-1sHK(M8ksss-^GndNt9u^Oydgq(#AsQtMD0pGhyRL} zk@oJczKKAm9(0ac|3_+mv8BfUVZqcLvClSidIq^HJbsU?X|e6b~;XpXXk z#xNl8ETiZ=(yJb#(rt!yh>uo`gq9ecHE}7>@rSn_Sf2Q3>>L&ilYxry&q*ASi>19N zkaQ5*j)DlIokeh*A}ZO`^EeVToKx5Q!7bsV<*PU^_I^5m(h15iUyPlCT}juGTaqvg$xShn6RBix zcyw@n<;1Jg*dHG+X-CNTJUD{WQu-78*~yy4@s9uVelYAUywfSv3RPzRfjeLJiD8G{ z<}Vc%9|2{!@adON-$F+lyAcedO$)M*BgpU{<(E?^U9*;tIkTO$Tj-Ax~sd2%J zCwaU(x9Gafk`h*;fSwJQRay+wkPIGJ=c=m7!$0%XuuU9*uY{jOmS|^B)ld~#w5ti~wT0m z5)~+*^76j3HrX&tpjl(emyp!7hF+5|VMg&`wJwS28~w0jQY)?fA2B?*g*mjMO=Vr< z0IK)C+U%AWv4rzE4kXPZ&r$AB>|qsPtWoF>B_g{oTXU5LmNLOVt_zo=nm+( zJeABnil8-NdG+E~n_fnMM;Ue3puMkODC#oG*!`8kx8dugE4)wSmX-T}Bgt=M?C;|1eOD)OJ6=2R_UjnzhqK9eGjP$M{3T zbOfZ?9e_Fy4c+-je@XP8VZd$H{IX~&bup@Y$>RWGgr+M6_UJKK@U9< zd-fn(8=5x8OidN6s*2@WULTB~^yM_Vw?9L)zy4(;Kqqi8_-1-&&W$uRZB;P8!cqSs2cWFzhT!;A+lg-#iW_}L&~m-9>eOZC3!8L?AJpQ0#KetD zS^YPR`03}Le-B(f?X5##L}2jnHr_pqc0NmeX_wzVe}3y0cPGCEc+b?~tlSe{aE~WG z8b7~A+Lud50EtrK0PL)^AA%J1aUXFWl-UIMO1YGK2Y;Iz}OhdLqX5V5r+hp;avk$8r zD|DUX(lwFg(G=iW^(I?0@u2$%=yAYl@bm;JtI9N8LD`993NVh+2h^DxpH#?}-OSH+ z9t5w-_-UMgbDUhjghp^mjg13<#Oa8_%f8Oh0$;1shc^AIli!NJuo*-x3MmxjWoi$i z0Tc*>p3n-W0C(esNAv^Ku$z2L^S}rJ&PMDRiV6N)V0gSC_y?CC_ z8A4|983iEHnqBH}ALIYG+vWN+^y%?YU86jl!Rx>O`~N1a^auP%h+lmz@-!RPR1^ff zYqr8mr6@IB<5%sk3`6MtJk4$2O4zwbVP{bxIOG@|v! zXa#rl;KJ2V#=F{&Eefa@wZJI(A3}?V!kB`OS9PC;Nyy&rpOIduF(y zjO`rb0QOG6JZDG=pjfx8edq_3$)gn@VYQ%x;f&| z7{UVCllRiP>zjC(0R5`kzZ?L%yd-MuPqNuxLLPWZ=4x(gJi8SY8f|AG7;uwVzGu)U~ML(BVuujWfWT}sJInwlu}LQvaB*nhb)$w zr@as!M=2T4mmm_)^ilQ*O;m@>sEYP-p!v#PuLO+XwdY_dC0{Fd29 z&rGg3g*H%&z@fRsMn9AeQjI@O!%n<=!~QGh-t7h)gyL%uG4wRI>b=hnwSF7Op;j8d zt9}eQI+!pzTe4N{FK=M(NdwmME!V68;BUv3NM*C9eqCS9Wx9(vvS8<}tNk9R#e=7) z4w=7tdtqed%k%s9zhfynqX9U9Ij>+myBoakwm%>HK!2=6N{{+zxgTw<`8A*O>}J0N z6OS&@*P68{jM>4vUR+Br0Ju>ctf{s||WYV_|*_AJmBPXYaslVrFl8J0Md8Ok`Q30Z{>KxETgjZ|(i? z8g3cRGf&k-C@2s<=dOkiTNv&uY1vYTGqhd)rX1LD;*;S#sc;MBlgU* z=KVy)zt%_#Y>{pw1ZJ1KDHxQUlK< zK^yXha;#v`57dsl{M7P-R7(yV7$|ac+O1I&!68tKd{J5ZVNSQ z5z4`x&$ovU*Zbx4x4(b&r$7A*bSVS)-cczI7%bqI`6S{uj_41OaUId6q9Hcm{Fhg^ z3|ZRbU8r_=osYWkc(kYqQ=?Q-%9=M_rte|cec`*9Fu}8*q*8c}g@9$*8^VHquu^bg ztM97*RWX!62R#cO1)8R_V2n+||E>g@pLP+reIT;o^@jDSt^%9UZx4m&u2e3+us zn%?P*13^HlUnbK@Rp4~(&L$;8_ z3^4#MP{RUrgx&IkFX%Yu;`KBh-}S?CHi&4n+xS3<#y`T#3(X)63Z4y}8a4mB>%;Ai zzOGiKc?^h8pO|>|@%;22QmtJ3P-FhQ^n8;M2SMuUZ~h8L>ep*rJ-baW}tHi*(2*_(?Gei)}9M=+Gxum1k*#>uJr?>c^GrfJ<>V|ct0dH+!x$d%{^dsjsL>% zqdVc2wXLO?1vWMRVhnX}+Qmv}1&uggGaB)TYj|y5@)4;bCme4+p^d=k2z8HY@(rDO z_56k1c^5WxO8&177Vi+wBO5w~zx?~Z{|7(uHIhI5^wS@(k-K;Aem;Hk&4=L#z%30# z@F`wKr-nL1?s!W)TO=pNO4E0>nMu^517MFqXu|;zWyI-cd3QPdH~j^Kw;B7pqXGi zFF)LJY9}em0r%s^G)fE&QlhTlI@|txo+y+~I>L*%iZfMPzdSlcpEUYPTM@&Yu9_{s zoj*Vaq9Y}~FDK9u2DN>yK-ZZ~eybJLVylC2p$ z1pa}l{g5@hvB+uc^73H#rb$$gv>snW3k>0O`gR<}(?q(wZ5g6;${`RUJ7~2Z8&^bK ziL#9wKl)h!7NenjF*_BZrq+6mF4Xq#M9Um^Z%@yc*L+KJy!!a@`(7u&0w5>p3>nqJ zA(gxFjE(5GK=X#uVdNu4^(57S-7R!2pkE);b0al{kSui1r}sLhM6#jqR|NjVCzQ#9w%G*2YzN+PokoX;qzpo2( z38CMl4ZaR_#p(i@`9Jd=^XI6$FlH%>%%jo+IY9wOR+)eY@T2mytfGJXlrd5}%B-46 zdkx#pWTsVGk$?2nx2yV=YdbHigDSvzEUwYrOa$9pYeVNO@&g_tPd2&W5l)$p){Mw= zM5k};5R_W=+ByC6I}>FOzIHe{(YkX_3T4;<$w{-FPkf_e&1Sg+%+UWE{b1<`EGOTe zcp^gzNvMInpldn-B7tPGEDx2;sjX{jzIxn-BT}1&mZ6M;@y&yBF%I4|D^0aVc&Pz! zLlmEPD+BZ`Q3^n(^td)Tj0`B6{8wVFVS?1_WG^}Khb9U^5P7iQ6GlKUEC?y=Y5Y6h z6q&*sZz+w@fn^E9d{J6(InXEK>u<55{uYlXFIs8m_0jm5{8LkhJN%FF^DZmWdZI~3 z0h}%1^ZQz&B<#aZd|qMbz_^0UgQ`+fy{Ab10PJcGyb zmp|aussFzZ_*OCtw~xnxk4Fbu&UGGCG{IFBFme!5;BLfBBFI{kq)$0hY)Ov}*7$wq zfTV*qUw;8Gp*}S|I{>GX&d~pyktm`GCt}gRgtM|N=&SNMQwHqr&a-Hi*sr6JF`6c2 zFAEjtlTodDP)##sG$K5=1Pg$84*vs;5S~atv$si5H8M;J;naX9`X#&x66x6=X9kYi zAZ{6lt;s|Y2(qM-0{v8?CZ>49-Uu{%x%0iinXh%7j>tj*cGjqGVfm=hvzZ+gYj2bALFm3 zyLSHNT^m2jMw`;P<8*+*w*XO}(5^&>J!nmx3S%AwR*9vrP> zxn@Tcm1d?tP>6zHSL^=F8iB{wKI5rcnG$C9Q38Gd-Q^Q-j1 z3Zilf&dR}y0*%oLDk!G3lsuV+x4%|&nHVcOM6=4WJ8FkhcK$yAn8nU?V)2&0nOi{t zj0jw=6$LC(;jkj*SHA=vQgk_miJr>N;-r>1r1SF-JtJSh;(46f;scq0h`0h5Jn@DAt+(W^^tMu)wUko~5FDOkg#XR(5?AD9n@K+IomEa+p`UTh5Sfau@ez~*59dLd zbi!EZiI`&iDQ^fnkPk&E!4S9`|CG|dEAGFK6UmfrvQdT@~gp^L0 zN)^x!p`(Iahp)S5oRm~;)?poaNJn#Or`C7@A66oMq0GdSa z6`Y9v_*3iTd_x13F^e=2#d%VmO+o2J4pS_I&NBNK4~$MHRxwV)U%fYfGc;^m&UeF8 zJwf!q6ajV=tipSe zDPEMZpG$T$1w!AAur<0^l>4^@>rwE0Po#lUq@8uz*Vk*1s=%K9#nI59G3S@5j^#By zD=-tjTBGO$EPHO`;j=romwNR{QJMGv0y+?M%WLS+a8o#%jV%Mb8#O$YNoq^;}c4|u~3Iq zRfxqAv(2zR293ta>==B1Ty#tS=Aal@WO>$Uq>lnef%{yYpMQaqYnYl&p8xO`X){ZHPS_ zTIrEOIz-iK>YO8PONibB5BdhF$KB3f2A2=rsW(DVZ=ve~_UK}_4s4+txwA_fu?u^2mh01Qf z?DXTZ;Vp}VoR>jIU=|YDHmAdu2yWXxe(y(2(LSZb&>~VqQqmE`VKp3Y-OUp!D*N-W z2t`e2mrWpl>=p;mC1EBZhas%QIs3}VyLvB}&)2R;Q^URzaX2*ib_(SHBy}+Nz`6agYQDmY~O6-Vaq+ne?a(e!9J3oK<_``Dg<$D(T+`U=-IOyf= zTQ}`w2#_cM8Q$I9{e)xKu-CiyZ!5w%M6Bwgg4()s0tqu2=oIcF4K=W8$8jPkF_7^4 z2_v0-006N+pSFeu3ituRX~G)4MjdAC_p$@y==K=}hCfMfm@4#==FYg4M#jmW`v`@y z2>EdiTQQeUJ4pl;J`MvoM53$S&mRoeGby*Y0GkakN8EH4DoMD|lQYWIi7;eFOT|pK zMfFSYIt{j&jwB%Ox41#o(t5X04WwK6INuzmGVB z>XZv0cs4i9bSiH;oU$6P#*!;W%Sth|Sf=FHph|!T;=m9l5IywpVmcPNQZzzp4fria zP@e3ZFq6H1^=PTw1q?nk@bIdR=36jWToQb3qf!1CD%UaAKrVa zptQqbaU!CTL}w~Yz3BwNFN{itFv(sFn0jOA;&}jgDh0RWYUH@AYyeCQO5U1oyTsGl z93cN<=p*Q2beb3y=u%IMB!YLV~;#-`!~z={)g}W#wMYD?rS*B5y3e+pd)1%kX3?wE8x|?<-;FTY&7d% z(jJr`F#5(@D!fHZpcOm9aVYC0!<%w+T{CO02-1+I_Q_2A(hIdEn*~=bX=o3RrEj#KFfi@ z*9zI{ros1tVdCzHEINOaafBD5&A}t}ui=?qnf9*?IMU0tNvG_0JnBjq@(Fo_p^ZoM z+h%6O@Zy;0nF-ub7M?a<$3*JS;YlhKBZ`R4%7P*63;ZPiR{Id3n**y?>vI3@)h9Lx zfe(BuaPB<(_AMU)W!D}Ywo8Rw=3Ps^hwPmtiOz~e{U)i=HFZzVj78y$z#~nB-3gos zxSJ3SqL?EUftSlLraTA5wR-&e@RiOc?N#=m8w8*q7UH6rQI^9PYJtA0dt_O&?UN9u zqNk>F)!T{BJ~+kFS_9^)fRtK}BZ%3~8xDnFv>eKGEdD7Pbp}X#OFKR|?bK;-aa+kn zp1zS=6j}8JYNIo$a1AUgt(m^!nbfOqVOuCl%&Z(fm`=g zj=B2GMK>wEhvH01%UXaZoFk5RaRuvocUs)M<@@jd#)hG863WQThy)lRK$b&pj?B1L zfF|0BS+|S`3}K=F)bG+7oo{DQgdMr3cA;iqTI?Xlq@5Obgml9>Sm*e}w=u2|8<$$` z2&@I@U1|;ujCP@VJbF`bTa0)m!s`UHvr zH~}Uv-=5CPWkdoe@#_vjjfr;h&H!g*(f`Uit&z&6O`)to+14v%LWls_$^e&A0?Yzu z*0k|_R~?zjR0XaX668%8Dg6+o^mvqT?9HXJ z+Mjk(0H)lQR^erl;7Wb>xSd_OH=f_WJw4xlKSKI<@Aw4Rdx0?A#}2{l5CpGq(5Txf z->|?5KsdWR0r~nwWrG`_+og24siJHz=CSiOcVXzVr*lW3sf5|Ws_&9{SI|5{3NQqZ zs-EuN1P;J-9@&u|sob)~4nkRQU)GClv3LhjU-qyYNvw;D zdy+Ok`4544npYK`t&9vHmk5d4lp#yHxtYDvH4391vxy@5f$8Xl`HCza`_3DLB3m@V z>0FrnS)E2fuF3{Rv9DOuO5~UgS8a}+S0{jDIi>S&&C0I1gHPe2(bbspoZk;?$`}D; z7pgUX901&rp}w!$UgMqyT-TvsLkG$$>dk$Qqd_;~NSdtR1D$y~>4oJuwWdv~@xaOs z?;peG1u7NqR?jss%`+i=I_Po~hUzhQDf#7LnS!Qlo;3GmL#NX5RL}a7^u9ICRz*Uw z98dv#==UJVKxM|9nt`;n{~*YZ4lHLTDc2T$ zL%m5`!t~c;zcb%BG;V)Z2vRFKgw%ctDd7Y_3pkswmWa!EbdwXj}1y*^I(`IBmTt%!aaLra0Nm6=<$>EP2Ph zVgE}wYJlRZ+P)n@IguQ|r1v9dA_?=x(BofA^JL&W4!qh~aQ60{Egs-w&cZI(Yd+n8mW`4U_DU_w`}v@wL)Zj-B-r zy~4SlbDl}Yfdj}CM3h2izhomBsM(?dYdR1VS22Uq<*0989u%e_*vW?QVp=FOeynO$ zQWA;O3M!m{8z?{!?f+`)1eWKlt{RSs&c3sq92IuA;q8oAN}w}d79cDMuEXd$ONz(7_K(k5DIcNtxy{1ua8pGy4F92pE-kpqA(GW zo6n2v(gI+hBNI-jHt~eN`h4o3o!PY)15L80_wRq_{(+-*uPwyya10LL;sl_MCK~%S zvT>*@I-g$?MOerv|B17_B!AL@C(1y>Z{jw*R9I>jsUB|{!{f{;pH9vB7uMud@!>eMaUcS)l-rng#$ z65Q6^0g{XV#mkAX`HP-*dI7tmT(vc3A+026FiaauS+Y5rj3W%OTn+|_lS z6+bFZv5lt=^V#`Q=`5dAUV#U2DW12|8iGX~P`pnhl6+94p4Agl`OG}Y#&9o4SV|R^ zvQ@-nuH$g&5oSgacB=NHWdV?Em{;XMIMj08#pZ%iqj#cE^FeBD1jfua`BKYYm!~Jv z*!c>duY3Y5leSupEEP5y02XsQ?%qDUVK^~MhQEA6c{ZCz1=xL1(j9(*$xh$IZQ4gW zhPf-SdKnO0OO%Nfe;JR`N_z+oNm3(%Ei=ET_%s?7%X}bJkW=XVX$ZwE&e)gh2R4WB zu8+5~?e{|7au@S;lf-cg@ia+99ba9kbEKlutH;?yx`o$FmD9vG?_`%Wal=c*bj-Or zsv=4!DK0uS;-$q?keUZEZlc&e6kf5{b2-Xm|3jdZae7zACV@&z-p_Y&1SQiznD5)@ z(^t@j-iMCCHh#(@z1K7C%k)uR>}I56|aO*UGUg1~PF^=WKQ4nI^-tTkQe( z9qa$upOSXJI|_{^0W<&HD(vLi0HG5p&^);R_T5izX8ZcJQ@q#p!w+^jMOV{~%lwHQ zXK-n~iN3`~`uR}4OKQ(}2DA8bGcnp+ZLKz*g>bAoMW6?%RIX+oPNM9jhHHw+Z8W*( zXB}ndcP8(WopMOr&Y@}98E?uEVEf8uh2=C3l;M(#Hk#mTKJpi!icqTL!=VucUg54D zV&#-mHS>;>&d(W_fIsz2z^cUqgk&+l|F@d><|iU-%eS0vn1N9 zh<3__n)<*;hF?e9|24z2wEwSP9$q`@l{E>2mrt+nf48t~k}*6rqw925+U@*qnDu(1LUtEsM!LVixwbq60K53p$AD+=x;&5Q%6V+$=v z%Jg}Dj_tSj3f(>|v((e0FPfgZu6wy;#YUcXoKZg=85A4uN-t#>P*Eu0jTgL#a2ift zTgrq{k%TlC%sGCK@mn1_+;=<_q*XyjJ;fkIUj`iGXTY*bmP|33PJkQoDmjVg{K8?( zqYYh}_CAKH>K$PSDsZfN@Uy+ulFvOcx>0&uEUAP1drYT1z=J$7;MSe_-XxxOFs(;C zsbOuCln%^v@cRJjfJfgPPM2e>TyVXCjP$~Owq8$~D&-Ol7> z9sonQ>B*0K@=Za8b1%RA^6mAJRfS-(GD{Y*!QtqCAs>LR1K@^v$d9$)i|(@`-Napb_Iq*66c z@o!|fJ;xRGd<5ZDVIGrVf$gh$ZrVe-QY;gBAE9Af^xkLq4n#2igAr?hJ+#+Hcm-ke z2b!Iag2g4w<9CENpOL5Sxzg~15*~7$Va9YohFfkBK{z)U2HIV_+!&&rwkEwgPoq!a z=S41UT)ogM?n~x!u}}xpe%)5iRn=R0B2h9tY5VEXxcemRdGJvA2HuoUIX-D$!0*QN z;QF}7^F5n%>Ks_n*;6xy#t}?1&jBU%@jF@+)!PKNfhiApz^Vnt$uHn ztiR2N!3$|+saJ9X75-qj^W4`v(IG1r!s1Lhjaesb#3q8JIDMO=^UC{z1zzHDyVk@1TCC7K7gM6hCgkGi>M^aHR&&r8^ zeW;Uw{1rD|@I~27ogSG8|5tR9t=Znb{ji=N-YlP=Up)`^eR=!(7o&sWG+Z8hx!gH{ ztA+;2L;nCxi3P{~>ec7VH{bkBU&1zO9a~gcycfn1RmmK%YW)#IxuFfN^<4l&)jUsx zRz!qn0Muxb7GrA;?8jt!8GbLi&=Zva8<5hepwkwr8W4m?}K!dNsg1(A_5H_i%6Cmk^_kr(v+XMN{ zH5C+eK^Azv68>}oLMk5Ac}dMqz8HVL&TB57S7!PK&;);F@Q&yEERY+S<_l+Y1EkK4 zq4>_<;V4J6UmxzH6tk>Dzr}4vf#rY$G`yh5V6&B6IkzF9TQyv(T%uq5k$m$PxUhyC(`*JA$lV49V0x7toZHRn4l!DX(>%6zUAXb~r1Ps6(J0IS>Z(rv(MU{$MUFPa0X6@+hWjj`rXs(BO=s z#Y?r!3@oD2j2M(?TS-bm%-Rltcie0IQf7I5r2H*TKs1v^0#N||6& z8T1C?W(_U=C~Fnca{@>u-wY6e81SMNH426$4}WCJ&CYUa0BQ#obA?cbny5q54#kG5 z7g=__H2ge=u@Yb9%4IKkij)ybm)NlCHg}O`uFLp4G6_z)-QvR{5bztkxK$M3+>I*hOj$MS5YBGlx+uIQ)S86yDaE*AiwOF@s43;^ zBfb6Sf$F7OF+Mw;oIFygbe@$yhQh<$V0|OXyQ=i_W~>iHM*LnV5_u=%+W4pG@Mm{A zCILnWs|S#XQ>4?69Ll2TLZa4uW2OPuo{qO8^s801X1Hfg{ljA^lmIL?kQbC$=A#T49qd3FD z0z8@a6;tcrX{mG0t*J9X8yvuX^AJnfVEcN0~teiPs+<9sp=IHQF|Ro z8BTn5Md#DgopS?j?iBRYO#xSSy0q?e#fjX`e)r+=bbflpQW*zb{B8$j>;K?mN3C%N zOFY%iwEd8b7xy6sQlyDzrL&!GXJPmdL*RWFNJ}P;qZxlVN-tyGj4%+sl<_cG0*F+i z+C@xC*vhAChJst#lxvbzZKC^t8QNQAE@TA;LFCfSfK+}~e6$Brr-+wy)jQ5F1item z$42e7*dD1l3C?Ec6}7--MabJQR20gO1pJHUj|(#hYerj|HfKI9ompqu|G$O^>zYzA zbPEH(u@(+UtwSMs=;04(;Gs@RdqtAKXoPYU{DSlHT|1gQKXM20aH}rKeY6vqhL`!l zXaAVdR~F#&lKM;&xcj9UqVR!TeKtj4%ldE}ePr@^CQI~YPZCtXoe~IVn2Qy$`Sxqr z=#KMT(vkPX>^|1xoZnsy>3ioUrVf`JxAQmOd{DmdFZ}^5@4jPR0TY9iHG2uLG0gp! zw^#NcrpBy|e&wgSzZgmNJ%}^?VlpZiE?+&^k78NzJbdB4lXH%#mN?VH8_8(Y=8`?& zph8%C9P(pkY$Q;9SPRRQd9K#47`sDkk`>{krl_znnS|rDx_2dCF(8wf632pTrq5VJ zDWln>gc8D_?yb&CX~HV|Oh+CeJDY<7+Gv0_K05}F zANvLL;-I_lzWbZa28R6bK|}%RAY2!k5==hgGh=KJ!yJL9hcoz}@6J!92bn8!b85Yt zl_U~a52FAjcWcZhsSafmy3|@3Dv!z~%a0|DOBSsf(RXI^0K@ymP zPNyECGdt?ka2ipCgW~z#`{;4f&@W3p=re$;qn3ixh(Jv{aCU~+V#sFhLb&OZF4D}W zbzPK82ZH8s0+UY9eE!{N3s$~3kqmoRKf?l688sj$`d{T(h?#t32Tzn&N3c0GkPm)f z^fWeRE(xZ7Nni|;ZE^knB{%!J2wKy7&xRkBz%*g9Ncp77X?O-4hjPsG^-Yq*m?mJ^ zS^i;7Z!B`qa$htyIC3YN^LeC^XL-L#h1jJ31r08gEhkg}y>v3!+BsD8R-4GwsE&?0 zJSlw>R+TM9Wlr`d^=eg?3y)mSPws)??!Xu?TZ9A52w>FiI0Z)q!dK=Iyqc4%==jY$ z)*0}%XCj0w4Y&3`84=(>LPtIYw-Pg+m=SGkdPmLsfaVXl3{lPG=Cfdl$)xEHvY z-psqo*07;tLukO1_Tb|YJh^}s`$MklwiF|G004G=^q-_QgmWXp$u?4{{Tp?5JnW86 z-xP^nDEh6MNl6oCNGRgZ?bldSCIXKd7hzvh_M6vXL!t=Isf&+BtyJp1jlY^VVwE)W z^2L!S8VT_0UcZcD$yOQk!x;bo8d*t1K~%2E9Y*-)!0u$2e*)uhKQHu<+yIXU*5X?l zWBTY$S4P?Zfw+}A`!tch4_uKSjTL&3l360!Gw-5_*8IZeWuB||=hydNN=B!{A|gK? zdj0h2+s7Y&{8KmUMh)-#wKsA_HdV{_7I3bs$X=0ta=Zx12Vc>H{_69%&O{(tA!9)G z$lG~@0W;k$+`cM;`eE2oujjdUcPR)Z@-Us6udb06>WZs21)k09xB>eIH2$O&R*GA7 zGU{VB3osUpy+UvVy)SGcAz_9VIsrwcxoszMI25LiNl>xLlq;?Zq=al);fOsNRMxd_ zJBJ1yXP^=?-Ut9{8j3E%j{#Us1|OSK+DL1l<>`3x3;Y=4>d;iqna@&e$PFd+Ds;gwQvq+5`s-t6if#!1 zh_nFIr7*hRH-;1XmOh`l&WmvdZC!V{4s|Q38o`Vf1yC2!sA9xyaW{Sop}rpr9clTd zgT4LL(Rcg&`R&u=;~O^zrCGq&gmDr;9!`LWKw~B?{RcNj3|@V4)nJ+jS!PVp7(Y(n za=F~-L>$*?0yJWc-H}nbhm}*YDt+@+5g${VY$_qVC;@wv+6t2QfDJTw)lmTKjY$b( zb_kYnA-I$Sj2g42-eJc?9@2s^Bud`?rN00N0!nczaTI8$vkUoOq@o)B4d#86OdSmn zsigz6U$fF|6?Bea%m@-V!va??`4<P$R%QEp@tXKlx>3eJl|qLQCJ zCJzoMjX!qU)n=|N_~G5k2VL0;Y?Mzw00vF7;OUo#Z!WK&?l1HcQ0p(&DCBi7$lmsy z2mm9P-%C>??M?FuaI-vH>A96&Hf-%Y%msne!oq_eZK_o)2T3^~^u* zM3yv-ZO&|1Y5ysoTBV#9@5RIMM!AE)eN9+hWDvs z!zVyzH?K`+$7wGIr`dcc=~P=#SdJPx2C^M~EYty_&Y(&^Vp7ogTZqUS%2tLuM|R`p z7ydSSx*}bgN&W|q5V4h^aT&hMN`N%KlE?7?m7Six&$(cEL_YH@VXb>f?mfCy*T<5( z9&3M7I+KiWIV|y6Wj*{(?)a09UFq@1SXm>0delgOHMX0%mIl%cK&}HCLx1G0uwJ9S zJznpymk}v^aFfyTF3zlenI7(hv5jHjH~?tDzsEW6T&ttW?VAYv#nZzf1P$`YtT2H; zYq3NI`35F+Zqr)Pm@qjoAnGghB9-a^Zsb?bBo+ymk&FOoy&C!-lm}i$IL7Z)ebE#M zika@?Ps8E@7>(c8N74F~laRXf`mUHG_FuT#2{@aP7G=S?pGuRcgdD!nqlyg#0!@f2 zXAobgae1AYXdDqb#!o07{Llo5p}7_&^7ua`83uy%Il%paq<`LM5#n54p+8Kzp# zCzfiOQo}@Ki(hpj>`&)jYJNHc(Hf`to}TaCjqv~7yPt3vI0PI)_X&*qduw)PhYm+^ zxzEHPoC>g%Ny|RUr9LbS8E^SYUpv1#f_JbC_LKPkk)s(=1j`Q2@FX2*0*{eadoDPn zOcG?2JjE7frAfI{pt)`i>nOOPIY3%_T-EO!WZ0VkDKpQKU)~g+!pkkGG_o4TD-2>` zh1;*v*L_6+1{=eV6ZsGvv*zt)KSbvT1kH)!c;Ug_A%zD9T(JEe-wf?x8fpOv5-7-~ zawQZef1brjaGzf~lyp1ZbJ`{Xq$#D?-`9O}m}8#+Q*XTg`q-%|K$XW|!L0ii#$VWl zz=8tw+`l+{85pSm8jry8n5qG*>cIrF!`pSJ&Tv9GRCCn#@;lB!bxy-T2JZP&fSZ6k zj#~cBsDaJ ztUmlaxX1xqFXr4y!_d?6aa`lP)4e2XtYQpfh6g*3nj zMfFU{)2^yRal(c#2SfkrFc|>KYd0~Q9d9}ksWlQfa)s2g)1pSc-p9{Yw#|s7bo`l4 zz>_3pSTZpB*o~acPpf@zBw=lX(FT}w*n}z1KL7ActuFj|lJWl|$rT}77uuf3nm@lJ z63fth$lC8f;`j@t$3lL^<9bw7B>jvsUY5~$dAh^d^L>pG zuD^zv)qYc^669fNm6t=5!-pB z`;JLMr|-Z2D^r5mE0CswO}vqmkpI3W0@bJw9gWX+_Mx+)6CKhi)bHhDARv)3h)#yg zXB@v`(yyIdXMK^613pIx&A_N{rzr;MoCgV(VD#9>7O1d#0B>PfefXxxDtN1D~&F8{#ks#?}vePx&7*hKJvbS5e7Vexn3Ur@|Sz2{^FECwW>&GplcW z@iKjsbUaI297;=hWYTu%u!j@w@$4760T2YiWLMp; z>{6a7OghvoL)}>40`Sg2JJF$Z10HrSH~}j9iGNP7Vbr&A<*SQxj6Ctva$1-aeUFooB68t~ zU4YO3{r35K{^v=fJKqi9-GCRi`fet4qL<#BHphqY>M(JA>(Toy{CGFheP#i=@2^F{ z^O-|MABpRgVxaQ!>9~Cd09!W3{YV%AY<_dbyP*1|2&wxPq-;jHHFqKl#?57PuP8kW zZ{=hmtA;d+PE)FEm3nw%)gi?`6nq+ilQBs$MW`#Evgg}yXz)lAne6?LQh5UvHU0Mt zARPcW7cXqWTi(th$n6C^f^f{53e8l zb*3jpP(_9||1hFBe@2+P(J@T){|fd5UDZM*Ou#NL&XdaTa%I! z6zY^!%1$KP5Xz)ms_Bk8hQa7`C6nBuGx6g`)@yxK^nSg(J|5QJ{=s4()B4w^m4ieN zEE0;|R+>C*0@|P!2qB=6d@Hl%Rap_vz5q58)sb<`2D}rHM~B%Jcsm0aqVQKgxxN41 zmMlO8m?w2xgbGJjP|`Q~Rh4 zzcL;>vr<5u(ceh;Gg0u^D;$-k%5tvh9-c&byE=mxBmd-pV z58gzJ@U8QUuXQ~67`%Cn;=wPFWDOwNXecc3Xs5@3Jkpo*zd24^wX942t2H|gKV5J@ zRY}C6OSi5fR1)6U;B;^|F6AnFdb!>$PfwrD-c}Si1%7=7b9zl6wi!h`7 zl^1^Uaaf)V_~Ws9=1*Eie!UpPYsTFHlt6Y0V%8nJwxUY`H(|+U1R4;nnO3{=B52nq zXoJ?yHt1l_q!snTYa}vJn+Fk*aJU@5ZXJdbWZD}Kg|pre#B4wgL&aiF({yhSB|#s5S9rD~iGLqDzA=4EeF+^*?wyyI}oq(?)D0aI|D&E4tt5vO5Z242! znm9@f~dzPPk(bGgCUh$Vl* z>4wsbjCGi0y+8V+x0jdKs9}HA0Uqz`3%`fw*=XER413vsK{llcbfhdM0GbH&8blL*uJW0Y7adObOE=7 z8nQQM|4iS2b} zq9vpdNErF!{AHk2kPVBjTPs)$>8}cuUPi94aW_t1Rdcdt%jx#B2f`0$(eaJCqnux# zn9^&c?f`nGV{F1?U5W#D0`qL3JSHU!l{CenEnbp7bpla_?_^9-|0yosCwK?LcKMMX9Z2OxhZ2DK({YH>3`;7A+KSMcL`f$j@&p zw``rlg~xxVUw-))qd4{Jx&CEye!eTHLqj*%ItRWktWU*jJ%CaFe16eJBF~I8{jbqq zi-LJaV80QFzy6IhrKgQb$;pp!mX|=(t?Q&bkFp&yhIfD#jx9pbx*g#*_{mmf-(Vxq z${v~I`KS1*`|lhMf}>Q9pV3t(kl6tY)pXtNVCOY2FcLxnN8r7(^n1^M4pUXHflx*S zTF#q-ji=&Mn2=<*4q6||$dq3nsC>BNwNo5lua9aw}VWwfm!v_3Bb9FTMW`DLoKTxF~=nAuR*n4e3C5eT2GY+6P*wcb0XK=!$7k5 zv+uEk*Jm-Ft;Kp0kPboP&wde*CLm{tZzBa% z=>$YOhW^fLx4vcZG;pVz2}*53YUh0;^ORWfC?n+!Q>_Y$-Ce+RDpPo2o6d|W=EF9{ zJU%ex<9^Vuk@^vnFf4^>*DTFrIEy|8n0+>~ly-)Jxq!OvE;(ueRVlggY?X$l42m~3 z#h#cnmd37_vZj;)=t1q90tqLpYxT%HIckF>kEJI^Y!_W~WVoRt_X+Oi!?;nAZ{?T? zUJiG9KP+ol+bnH8ept@O&!2ze)n7ISu~&e@L+YHs^B>=SeE#El1po2lsslk7)p>D9 z{^3PksgPZOx*ecx{21|E8;H~ah^Z1p47(#N!O~9d9G#9aWSEY?ZQzQ`8k5M4iV|vQ zYvNsipsaVzCYsqQ^vi;magSO9qUef^5J6T~QTF|!TLFndZNh$u)wt3TXf6OPF(oX@ zP#~}5?x@?4G+*+tXMI%FTVqh!#l@wT8P`s#Ns=Mb7=b34;ZzHdjQWiJ+mx{G`FqPR z+e1Yg(zk@dpzJ}Te&x6z42Sn_m~#GV+?x)A5)~+rE)ukUyNDY?DHH+upp6No-W%2j z6dMZ8&Taks&)}iLEvic9hm2eo)fPttZ)vN|53(f$sP&hS3NzGYV#&y_o58NmdeQxH zxtu;fe%1Eir>76+-+%v=O+RUeyVpOzeR}?KJ-&Rs9(Cp)+-;7$-46VC$n$-vi!H=; zs*s-q@J#_7JOXbzT=a6up`cOHjrc$snUErsuq<{S^~}zSVh|6xt2`zYtkRb~Vozrn z!if3!FjvY2@S>MvlVK7WI8tXv#F4B6=;|LMz1IWa3SYPFVcKvHF8!1(LG_MtafFcR zs0Nwn$<4FGQd|k)3B@R>E*;|kw(|5F16jj+X%*k{LOAS*GMWwF9Z43pHfFsb>s!&ca;b zvL#XwvbC}Lmd!nO(hfF^;53dnzdL}y;8)6{oCc%Chg~l2wDqNIp7(Fgyp8%F_>d?I ze>g9E=~HS4ZW9jIt+UuV_{dFb)@A>O1LL3>+69F#>>ihuh7tou7FKT~( zSi&A@>N0B(?{BJt}6-^^{OS)1h?ExgMY@xYrYNt2EOl~k0a$ggx zM2fQ?n+Y)Y^Ez}SoY%Xz?ziDa!YD-ot}=k0IPj0DoM?rH>8`9bD6V^a_lF9lh^`?U zCRNVVTr`Q_2N26peeFF64>~2*ub46=#Q9qTfwbvBCXhICcsl}DKk6%&EF7qb9>pzP zDhIm5`1>%iVV%Tq2-7zXr?jRAddWY5T5p%2pKFe!xr{JGjBXpJyQD1IbW~46`0$Pa ztF5OgCAZ8eAcrp?tc_zCgxCw9w|$~_b>0CMa!(%*=ZDk%<&F-3?+G>@9S+LL!a5O% z_G1^J7XP*>fAX}sDC-6483gfW<05- z_NGcDgpvOXsZb3k_wrS@0eT_x4xh1g)vGg+5uv8(%pskE(p^ed*HDqqan7SbA?UY$&MCCuF%B~eb3J?&hKaZUg$%3d#fvShaPWnTJ&S*=epLp z0+U0#cA}~8=>SrW*}~rj>Cigp_y~EXX)4m?p#~;E>KRZfG7Eb|-+>#=j#Sl?%2vN- zN_(PCHS(_Lj)rxDG7}-n^(~cZJHchUNxOb|eO&(h@&oHP52uC4fE)v+N457K?l1R` zt3DNm1MA#BI(fIJXQ$hh831^B^e6oJ4uSR&WI>RhrUtf+Vh3B1#!D@B!e}*iIXVH^ z5SA5PnZ9*4^hX6_nql8YBW+yN?!q}r6zr31aYHD3!zZ?xw0)k5<|a!Ac@gXv>CpYIl3ta0E>7~Sfs7HATX_vSm zKAawdu^4RFQEP+*y6Pbnd5~Ns?dW!5e{(=>9taM~DOBm=;#R0Y3w;AA&Si-G7Q|5~ zoALB6!~bT&Wnog5*;F0msu>Lf()3~drGP9~03JoIgg zVx126z-PnxiV*SRVIo|&Rfc$1 zD|rZv?Zwkt01ci(JiE6;3P=7LH#Oz;L`bJRI$AVYvtOC8Din$>Ob(`VY`_--Ca*V>j*KX_1YbnM|=84fb764|12c< zR*x5o{OT~L4LNUJpD)YuQI8VO`cm=qGSDog!xF(`hMG@-?N4_Clu5dcKGh3Kv5HJa z?5rD@8*K&%9)decZ3s^Mi#&Lm8*au(wvW0YG>~{>5E^tAPYKFc;XgIZhTR`6*o;z0 zy9J{FU9?bC>N;N#;rIsfzX Date: Sat, 8 Aug 2026 12:11:52 +0100 Subject: [PATCH 072/113] feat(maps): add layers and tile validation --- src/Enum/MapLayer.php | 17 +++++ src/Validation/Assert.php | 32 ++++++++ tests/Unit/Enum/MapLayerTest.php | 23 ++++++ tests/Unit/Validation/AssertTest.php | 105 +++++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/Enum/MapLayer.php create mode 100644 tests/Unit/Enum/MapLayerTest.php create mode 100644 tests/Unit/Validation/AssertTest.php diff --git a/src/Enum/MapLayer.php b/src/Enum/MapLayer.php new file mode 100644 index 0000000..8e28c03 --- /dev/null +++ b/src/Enum/MapLayer.php @@ -0,0 +1,17 @@ + $maximum) { + throw new \InvalidArgumentException(sprintf( + 'The tile %s coordinate must be between 0 and %.0f for zoom %d.', + strtoupper($axis), + $maximum, + $zoom, + )); + } + + return $coordinate; + } } diff --git a/tests/Unit/Enum/MapLayerTest.php b/tests/Unit/Enum/MapLayerTest.php new file mode 100644 index 0000000..70ea290 --- /dev/null +++ b/tests/Unit/Enum/MapLayerTest.php @@ -0,0 +1,23 @@ + 'clouds_new', + 'PRECIPITATION' => 'precipitation_new', + 'PRESSURE' => 'pressure_new', + 'WIND' => 'wind_new', + 'TEMPERATURE' => 'temp_new', + ], array_combine( + array_column(MapLayer::cases(), 'name'), + array_column(MapLayer::cases(), 'value'), + )); + } +} diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php new file mode 100644 index 0000000..a63e8fa --- /dev/null +++ b/tests/Unit/Validation/AssertTest.php @@ -0,0 +1,105 @@ + + */ + public static function nonNegativeIntegers(): iterable + { + yield 'zero' => [0]; + yield 'positive integer' => [9]; + } + + public function testItRejectsNegativeIntegers(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The tile zoom must be zero or greater.'); + + Assert::nonNegativeInteger(-1, 'tile zoom'); + } + + #[DataProvider('validTileCoordinates')] + public function testItAcceptsCoordinatesWithinTheZoomRange( + int $coordinate, + int $zoom, + ): void { + self::assertSame( + $coordinate, + Assert::tileCoordinate($coordinate, $zoom, 'x'), + ); + } + + /** + * @return iterable + */ + public static function validTileCoordinates(): iterable + { + yield 'zoom zero origin' => [0, 0]; + yield 'zoom nine origin' => [0, 9]; + yield 'zoom nine maximum' => [511, 9]; + yield 'zoom twenty center' => [524288, 20]; + yield 'zoom twenty maximum' => [1048575, 20]; + } + + #[DataProvider('invalidTileCoordinates')] + public function testItRejectsCoordinatesOutsideTheZoomRange( + int $coordinate, + int $zoom, + string $axis, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Assert::tileCoordinate($coordinate, $zoom, $axis); + } + + /** + * @return iterable + */ + public static function invalidTileCoordinates(): iterable + { + yield 'negative X' => [ + -1, + 9, + 'x', + 'The tile X coordinate must be between 0 and 511 for zoom 9.', + ]; + yield 'X above maximum' => [ + 512, + 9, + 'x', + 'The tile X coordinate must be between 0 and 511 for zoom 9.', + ]; + yield 'Y above zoom zero maximum' => [ + 1, + 0, + 'y', + 'The tile Y coordinate must be between 0 and 0 for zoom 0.', + ]; + } + + public function testTileCoordinateRejectsANegativeZoom(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The tile zoom must be zero or greater.'); + + Assert::tileCoordinate(0, -1, 'x'); + } +} From b128da117516a6760e29cb07c97075eb5e857151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 12:27:29 +0100 Subject: [PATCH 073/113] feat(response): support JSON and binary payloads --- src/OpenWeatherMap.php | 3 +- src/Response/PayloadDecoder.php | 46 +++++++++++++ tests/Unit/OpenWeatherMapTest.php | 53 ++++++++++++++- tests/Unit/Response/PayloadDecoderTest.php | 77 ++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/Response/PayloadDecoder.php create mode 100644 tests/Unit/Response/PayloadDecoderTest.php diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 81964ec..a6e36ff 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -16,6 +16,7 @@ use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; use ProgrammatorDev\OpenWeatherMap\Resource\OneCall; use ProgrammatorDev\OpenWeatherMap\Resource\Weather; +use ProgrammatorDev\OpenWeatherMap\Response\PayloadDecoder; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; class OpenWeatherMap extends Api @@ -40,7 +41,7 @@ public function __construct(string $apiKey, array $options = []) $this->baseUrl(self::BASE_URL); $this->auth()->query(self::AUTHENTICATION_KEY, $apiKey); - $this->responses()->json(); + $this->responses()->custom(new PayloadDecoder()); $this->errors()->when(static fn (ErrorContext $context): ?ApiException => match (true) { $context->statusCode() === 400 => BadRequestException::fromContext($context), diff --git a/src/Response/PayloadDecoder.php b/src/Response/PayloadDecoder.php new file mode 100644 index 0000000..a71dd49 --- /dev/null +++ b/src/Response/PayloadDecoder.php @@ -0,0 +1,46 @@ +getBody()->rewind(); + $contents = $response->getBody()->getContents(); + + if ($contents === '') { + return null; + } + + try { + return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + $contentType = strtolower($response->getHeaderLine('Content-Type')); + + // Images are successful binary payloads, while non-JSON error + // bodies still need to reach the HTTP error mapper unchanged. + if ( + $response->getStatusCode() >= 400 + || str_starts_with($contentType, 'image/') + ) { + return $contents; + } + + // A successful non-image response is expected to be valid JSON; + // preserve the previous strict failure instead of hiding corruption. + throw $exception; + } + } +} diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php index 7c02c06..7c32267 100644 --- a/tests/Unit/OpenWeatherMapTest.php +++ b/tests/Unit/OpenWeatherMapTest.php @@ -16,6 +16,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; +use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; class OpenWeatherMapTest extends TestCase { @@ -47,7 +48,7 @@ public function testAcceptsAnArbitraryNonEmptyLanguageCode(): void self::assertSame('future_language', $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); } - public function testConfiguresBaseUrlQueryAuthenticationAndJsonDecoding(): void + public function testConfiguresBaseUrlQueryAuthenticationAndJsonPayloadDecoding(): void { $client = new Client(); $client->addResponse(new Response(body: '{"ok":true}')); @@ -70,6 +71,56 @@ public function testConfiguresBaseUrlQueryAuthenticationAndJsonDecoding(): void self::assertSame(['ok' => true], $response->data()); } + public function testReturnsMapImagesAsRawResponseData(): void + { + $contents = Fixture::contents('weather-maps/tile/clouds-new.png'); + $client = new Client(); + $client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + $response = $api->send( + Method::GET, + 'https://tile.openweathermap.org/map/clouds_new/1/1/1.png', + ); + + self::assertSame($contents, $response->data()); + } + + public function testDecodesMislabeledJsonBeforeMappingTheHttpError(): void + { + $data = Fixture::json('weather-maps/tile/missing-key.json'); + $client = new Client(); + $client->addResponse(new Response( + status: 401, + headers: ['Content-Type' => 'image/png'], + body: Fixture::contents('weather-maps/tile/missing-key.json'), + )); + + $api = new OpenWeatherMap('api-key'); + $api->setup()->client($client); + + try { + $api->send( + Method::GET, + 'https://tile.openweathermap.org/map/clouds_new/1/1/1.png', + ); + } catch (UnauthorizedException $exception) { + self::assertSame($data['message'], $exception->getMessage()); + self::assertSame(401, $exception->statusCode()); + self::assertSame(401, $exception->apiCode()); + self::assertSame($data, $exception->responseData()); + + return; + } + + self::fail(sprintf('Expected %s to be thrown.', UnauthorizedException::class)); + } + #[DataProvider('httpErrors')] public function testMapsHttpErrorsToApiException( int $statusCode, diff --git a/tests/Unit/Response/PayloadDecoderTest.php b/tests/Unit/Response/PayloadDecoderTest.php new file mode 100644 index 0000000..fddc556 --- /dev/null +++ b/tests/Unit/Response/PayloadDecoderTest.php @@ -0,0 +1,77 @@ +decoder = new PayloadDecoder(); + } + + public function testItDecodesJsonRegardlessOfTheContentType(): void + { + $response = new Response( + status: 401, + headers: ['Content-Type' => 'image/png'], + body: Fixture::contents('weather-maps/tile/missing-key.json'), + ); + + self::assertSame( + Fixture::json('weather-maps/tile/missing-key.json'), + ($this->decoder)($response), + ); + } + + public function testItReturnsBinaryBodiesUnchanged(): void + { + $contents = Fixture::contents('weather-maps/tile/clouds-new.png'); + $response = new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + ); + + self::assertSame($contents, ($this->decoder)($response)); + } + + public function testItReturnsPlainTextBodiesUnchanged(): void + { + $response = new Response(status: 503, body: 'Service unavailable'); + + self::assertSame('Service unavailable', ($this->decoder)($response)); + } + + public function testItRejectsMalformedSuccessfulJson(): void + { + $response = new Response( + headers: ['Content-Type' => 'application/json'], + body: '{"incomplete":', + ); + + $this->expectException(\JsonException::class); + + ($this->decoder)($response); + } + + public function testItReturnsNullForAnEmptyBody(): void + { + self::assertNull(($this->decoder)(new Response())); + } + + public function testItRewindsTheResponseBodyBeforeReading(): void + { + $response = new Response(body: '{"ok":true}'); + $response->getBody()->getContents(); + + self::assertSame(['ok' => true], ($this->decoder)($response)); + } +} From ae6f21d769ec3c3be44ae24bfeb292704e94a3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 13:14:02 +0100 Subject: [PATCH 074/113] feat(maps): add authenticated tile fetching --- README.md | 1 + docs/maps.md | 63 +++++++++++ src/OpenWeatherMap.php | 6 ++ src/Resource/Maps.php | 51 +++++++++ src/Response/MapTile.php | 21 ++++ src/Validation/Assert.php | 13 ++- tests/Unit/Resource/MapsTest.php | 155 +++++++++++++++++++++++++++ tests/Unit/Validation/AssertTest.php | 16 +-- 8 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 docs/maps.md create mode 100644 src/Resource/Maps.php create mode 100644 src/Response/MapTile.php create mode 100644 tests/Unit/Resource/MapsTest.php diff --git a/README.md b/README.md index 307de8a..085b198 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ use yet. - [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) +- [Weather Maps](docs/maps.md) - [Geocoding](docs/geocoding.md) ## License diff --git a/docs/maps.md b/docs/maps.md new file mode 100644 index 0000000..fdb3ad5 --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,63 @@ +# Weather Maps + +Weather Maps API 1.0 provides current cloud, precipitation, sea-level pressure, +wind-speed, and temperature overlays. It is available on OpenWeather's standard +free and paid subscriptions. See the +[official Weather Maps documentation](https://openweathermap.org/api/weathermaps) +for API details. + +## Fetch A Tile + +Use `tile()` with a layer, zoom level, and X and Y tile coordinates. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$tile = $api->maps()->tile( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, +); +``` + +`tile()` returns one 256-by-256 PNG overlay as a `MapTile`. The request is made +by the PHP application, so the API key is not included in the returned value. + +```php +header('Content-Type: ' . $tile->contentType()); + +echo $tile->contents(); +``` + +## Tile Coordinates + +X and Y are tile indexes, not longitude and latitude. Weather Maps uses the +same square-tile grid as common web mapping libraries: + +- Zoom 0 contains one tile representing the whole world: X 0, Y 0. +- Each additional zoom level doubles the number of tiles along each axis. +- X increases from west to east. +- Y increases from north to south. + +For example: + +| Zoom | Tile grid | Valid X and Y values | +|---:|---:|---:| +| 0 | 1 × 1 | 0 | +| 1 | 2 × 2 | 0–1 | +| 2 | 4 × 4 | 0–3 | +| 6 | 64 × 64 | 0–63 | + +Zoom must be zero or greater. At any zoom level, the largest valid X or Y value +is `(2 ** $zoom) - 1`. Mapping libraries normally calculate these indexes from +the displayed geographic area; they should not be replaced directly with a +location's longitude and latitude. + +Applications displaying Weather Maps data must provide visible OpenWeather +attribution. Consult the +[official FAQ](https://openweathermap.org/faq) +for the current attribution requirements. diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index a6e36ff..cd2af5d 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -14,6 +14,7 @@ use ProgrammatorDev\OpenWeatherMap\Exception\UnexpectedErrorException; use ProgrammatorDev\OpenWeatherMap\Resource\AirPollution; use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; +use ProgrammatorDev\OpenWeatherMap\Resource\Maps; use ProgrammatorDev\OpenWeatherMap\Resource\OneCall; use ProgrammatorDev\OpenWeatherMap\Resource\Weather; use ProgrammatorDev\OpenWeatherMap\Response\PayloadDecoder; @@ -63,6 +64,11 @@ public function geocoding(): Geocoding return $this->resource(Geocoding::class); } + public function maps(): Maps + { + return $this->resource(Maps::class); + } + public function oneCall(): OneCall { return $this->resource(OneCall::class); diff --git a/src/Resource/Maps.php b/src/Resource/Maps.php new file mode 100644 index 0000000..214db77 --- /dev/null +++ b/src/Resource/Maps.php @@ -0,0 +1,51 @@ +endpoint() + ->get(self::BASE_URL . '/map/{layer}/{zoom}/{x}/{y}.png', [ + 'layer' => $layer->value, + 'zoom' => $zoom, + 'x' => $x, + 'y' => $y, + ]); + $contents = $response->data(); + + if (!is_string($contents)) { + throw new \UnexpectedValueException( + 'Map tile response body must contain raw image data.', + ); + } + + $contentType = $response->raw()->getHeaderLine('Content-Type'); + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + + if ($mediaType !== 'image/png') { + throw new \UnexpectedValueException( + 'Map tile response must use the "image/png" content type.', + ); + } + + return new MapTile($contents, $contentType); + } +} diff --git a/src/Response/MapTile.php b/src/Response/MapTile.php new file mode 100644 index 0000000..08d0305 --- /dev/null +++ b/src/Response/MapTile.php @@ -0,0 +1,21 @@ +contents; + } + + public function contentType(): string + { + return $this->contentType; + } +} diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index be4954c..ba627b8 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -127,15 +127,22 @@ public static function tileCoordinate( int $zoom, string $axis, ): int { - $zoom = self::nonNegativeInteger($zoom, 'tile zoom'); + $zoom = self::nonNegativeInteger($zoom, 'tile zoom level'); $maximum = (2 ** $zoom) - 1; if ($coordinate < 0 || $coordinate > $maximum) { + if ($maximum === 0) { + throw new \InvalidArgumentException(sprintf( + 'At zoom level 0, the tile %s coordinate must be 0.', + strtoupper($axis), + )); + } + throw new \InvalidArgumentException(sprintf( - 'The tile %s coordinate must be between 0 and %.0f for zoom %d.', + 'At zoom level %d, the tile %s coordinate must be between 0 and %.0f.', + $zoom, strtoupper($axis), $maximum, - $zoom, )); } diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php new file mode 100644 index 0000000..7ef30fc --- /dev/null +++ b/tests/Unit/Resource/MapsTest.php @@ -0,0 +1,155 @@ +client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: $contents, + )); + + $tile = $this->api->maps()->tile( + layer: $layer, + zoom: 1, + x: 1, + y: 1, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(MapTile::class, $tile); + self::assertSame($contents, $tile->contents()); + self::assertSame('image/png', $tile->contentType()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('https', $request->getUri()->getScheme()); + self::assertSame('tile.openweathermap.org', $request->getUri()->getHost()); + self::assertSame($path, $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + /** + * @return iterable + */ + public static function layers(): iterable + { + yield 'clouds' => [ + MapLayer::CLOUDS, + '/map/clouds_new/1/1/1.png', + 'weather-maps/tile/clouds-new.png', + ]; + yield 'precipitation' => [ + MapLayer::PRECIPITATION, + '/map/precipitation_new/1/1/1.png', + 'weather-maps/tile/precipitation-new.png', + ]; + yield 'pressure' => [ + MapLayer::PRESSURE, + '/map/pressure_new/1/1/1.png', + 'weather-maps/tile/pressure-new.png', + ]; + yield 'wind' => [ + MapLayer::WIND, + '/map/wind_new/1/1/1.png', + 'weather-maps/tile/wind-new.png', + ]; + yield 'temperature' => [ + MapLayer::TEMPERATURE, + '/map/temp_new/1/1/1.png', + 'weather-maps/tile/temp-new.png', + ]; + } + + #[DataProvider('invalidAddresses')] + public function testRejectsInvalidTileAddresses( + int $zoom, + int $x, + int $y, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->maps()->tile(MapLayer::CLOUDS, $zoom, $x, $y); + } + + /** + * @return iterable + */ + public static function invalidAddresses(): iterable + { + yield 'negative zoom' => [ + -1, + 0, + 0, + 'The tile zoom level must be zero or greater.', + ]; + yield 'negative X' => [ + 9, + -1, + 0, + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'X above maximum' => [ + 9, + 512, + 0, + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', + ]; + yield 'Y above maximum' => [ + 9, + 0, + 512, + 'At zoom level 9, the tile Y coordinate must be between 0 and 511.', + ]; + yield 'negative Y' => [ + 9, + 0, + -1, + 'At zoom level 9, the tile Y coordinate must be between 0 and 511.', + ]; + } + + public function testRejectsDecodedResponseData(): void + { + $this->client->addResponse(new Response( + headers: ['Content-Type' => 'image/png'], + body: '{}', + )); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Map tile response body must contain raw image data.', + ); + + $this->api->maps()->tile(MapLayer::CLOUDS, 1, 1, 1); + } + + public function testRejectsUnexpectedImageContentTypes(): void + { + $this->client->addResponse(new Response( + headers: ['Content-Type' => 'image/jpeg'], + body: 'image bytes', + )); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Map tile response must use the "image/png" content type.', + ); + + $this->api->maps()->tile(MapLayer::CLOUDS, 1, 1, 1); + } +} diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php index a63e8fa..94cfa99 100644 --- a/tests/Unit/Validation/AssertTest.php +++ b/tests/Unit/Validation/AssertTest.php @@ -13,7 +13,7 @@ public function testItAcceptsNonNegativeIntegers(int $value): void { self::assertSame( $value, - Assert::nonNegativeInteger($value, 'tile zoom'), + Assert::nonNegativeInteger($value, 'value'), ); } @@ -29,9 +29,9 @@ public static function nonNegativeIntegers(): iterable public function testItRejectsNegativeIntegers(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The tile zoom must be zero or greater.'); + $this->expectExceptionMessage('The value must be zero or greater.'); - Assert::nonNegativeInteger(-1, 'tile zoom'); + Assert::nonNegativeInteger(-1, 'value'); } #[DataProvider('validTileCoordinates')] @@ -79,26 +79,28 @@ public static function invalidTileCoordinates(): iterable -1, 9, 'x', - 'The tile X coordinate must be between 0 and 511 for zoom 9.', + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', ]; yield 'X above maximum' => [ 512, 9, 'x', - 'The tile X coordinate must be between 0 and 511 for zoom 9.', + 'At zoom level 9, the tile X coordinate must be between 0 and 511.', ]; yield 'Y above zoom zero maximum' => [ 1, 0, 'y', - 'The tile Y coordinate must be between 0 and 0 for zoom 0.', + 'At zoom level 0, the tile Y coordinate must be 0.', ]; } public function testTileCoordinateRejectsANegativeZoom(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The tile zoom must be zero or greater.'); + $this->expectExceptionMessage( + 'The tile zoom level must be zero or greater.', + ); Assert::tileCoordinate(0, -1, 'x'); } From 288f99089c0bfec4bb1e4f038e4426d9d5491c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 14:08:38 +0100 Subject: [PATCH 075/113] feat(api): mark API key parameters as sensitive --- src/OpenWeatherMap.php | 9 +++++++-- tests/Unit/OpenWeatherMapTest.php | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index cd2af5d..d9cf1a5 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -28,7 +28,10 @@ class OpenWeatherMap extends Api private const BASE_URL = 'https://api.openweathermap.org'; - public function __construct(string $apiKey, array $options = []) + public function __construct( + #[\SensitiveParameter] string $apiKey, + array $options = [], + ) { parent::__construct(); @@ -79,7 +82,9 @@ public function weather(): Weather return $this->resource(Weather::class); } - private function validateApiKey(string $apiKey): string + private function validateApiKey( + #[\SensitiveParameter] string $apiKey, + ): string { return Assert::notBlank($apiKey, 'API key'); } diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php index 7c32267..1b9c72a 100644 --- a/tests/Unit/OpenWeatherMapTest.php +++ b/tests/Unit/OpenWeatherMapTest.php @@ -28,6 +28,21 @@ public function testUsesDocumentedConfigurationDefaults(): void self::assertSame(Language::ENGLISH, $api->config()->get(OpenWeatherMap::OPTION_LANGUAGE)); } + public function testMarksApiKeyParametersAsSensitive(): void + { + $constructor = new \ReflectionMethod(OpenWeatherMap::class, '__construct'); + $validator = new \ReflectionMethod(OpenWeatherMap::class, 'validateApiKey'); + + self::assertCount( + 1, + $constructor->getParameters()[0]->getAttributes(\SensitiveParameter::class), + ); + self::assertCount( + 1, + $validator->getParameters()[0]->getAttributes(\SensitiveParameter::class), + ); + } + public function testAcceptsConfiguredUnitsAndKnownLanguage(): void { $api = new OpenWeatherMap('api-key', [ From 32f929bd65ceab612e2b5a11327e4b0290295781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 15:01:32 +0100 Subject: [PATCH 076/113] feat(maps): inject API key into resource --- composer.json | 2 +- src/OpenWeatherMap.php | 11 ++++++++--- src/Resource/Maps.php | 8 ++++++++ tests/Unit/Resource/MapsTest.php | 11 +++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 0f9282b..db4350c 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "require": { "php": ">=8.1", "php-http/discovery": "^1.20", - "programmatordev/php-api-sdk": "^3.2" + "programmatordev/php-api-sdk": "^3.3" }, "require-dev": { "monolog/monolog": "^3.10", diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index d9cf1a5..1a2e091 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -28,6 +28,8 @@ class OpenWeatherMap extends Api private const BASE_URL = 'https://api.openweathermap.org'; + private readonly string $apiKey; + public function __construct( #[\SensitiveParameter] string $apiKey, array $options = [], @@ -35,7 +37,7 @@ public function __construct( { parent::__construct(); - $apiKey = $this->validateApiKey($apiKey); + $this->apiKey = $this->validateApiKey($apiKey); $options = $this->validateOptions($options); $this->config($options, defaults: [ @@ -44,7 +46,7 @@ public function __construct( ]); $this->baseUrl(self::BASE_URL); - $this->auth()->query(self::AUTHENTICATION_KEY, $apiKey); + $this->auth()->query(self::AUTHENTICATION_KEY, $this->apiKey); $this->responses()->custom(new PayloadDecoder()); $this->errors()->when(static fn (ErrorContext $context): ?ApiException => match (true) { @@ -69,7 +71,10 @@ public function geocoding(): Geocoding public function maps(): Maps { - return $this->resource(Maps::class); + return $this->resourceWith( + Maps::class, + apiKey: $this->apiKey, + ); } public function oneCall(): OneCall diff --git a/src/Resource/Maps.php b/src/Resource/Maps.php index 214db77..81f69a0 100644 --- a/src/Resource/Maps.php +++ b/src/Resource/Maps.php @@ -3,6 +3,7 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource; use ProgrammatorDev\Api\Resource; +use ProgrammatorDev\Api\Runtime; use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; use ProgrammatorDev\OpenWeatherMap\Response\MapTile; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -11,6 +12,13 @@ final class Maps extends Resource { private const BASE_URL = 'https://tile.openweathermap.org'; + public function __construct( + Runtime $runtime, + #[\SensitiveParameter] private readonly string $apiKey, + ) { + parent::__construct($runtime); + } + public function tile( MapLayer $layer, int $zoom, diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php index 7ef30fc..fd93375 100644 --- a/tests/Unit/Resource/MapsTest.php +++ b/tests/Unit/Resource/MapsTest.php @@ -5,12 +5,23 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\Resource\Maps; use ProgrammatorDev\OpenWeatherMap\Response\MapTile; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; use ProgrammatorDev\OpenWeatherMap\Test\Support\Fixture; final class MapsTest extends ApiTestCase { + public function testMarksTheApiKeyAsSensitive(): void + { + $constructor = new \ReflectionMethod(Maps::class, '__construct'); + + self::assertCount( + 1, + $constructor->getParameters()[1]->getAttributes(\SensitiveParameter::class), + ); + } + #[DataProvider('layers')] public function testGetsWeatherMapTiles( MapLayer $layer, From 2a5815790fa1b4ee63c8e02b71e7f65f2b2cd6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 15:14:31 +0100 Subject: [PATCH 077/113] feat(maps): generate authenticated tile URLs --- docs/maps.md | 23 ++++++++++++++++ src/Resource/Maps.php | 47 ++++++++++++++++++++++++-------- tests/Unit/Resource/MapsTest.php | 29 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/maps.md b/docs/maps.md index fdb3ad5..0f65051 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -6,6 +6,29 @@ free and paid subscriptions. See the [official Weather Maps documentation](https://openweathermap.org/api/weathermaps) for API details. +## Generate A Tile URL + +Use `tileUrl()` to generate an authenticated URL for a mapping library, image, +or other client that loads the tile directly. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$url = $api->maps()->tileUrl( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, +); +``` + +Generating the URL does not make an HTTP request. It contains the API key passed +to `OpenWeatherMap`, so treat it as a credential and avoid including it in logs +or other unintended output. + ## Fetch A Tile Use `tile()` with a layer, zoom level, and X and Y tile coordinates. diff --git a/src/Resource/Maps.php b/src/Resource/Maps.php index 81f69a0..7e9d359 100644 --- a/src/Resource/Maps.php +++ b/src/Resource/Maps.php @@ -5,12 +5,14 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\Api\Runtime; use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Response\MapTile; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; final class Maps extends Resource { - private const BASE_URL = 'https://tile.openweathermap.org'; + // https://openweathermap.org/api/weathermaps + private const TILE_URL = 'https://tile.openweathermap.org/map/%s/%d/%d/%d.png'; public function __construct( Runtime $runtime, @@ -19,24 +21,29 @@ public function __construct( parent::__construct($runtime); } + public function tileUrl( + MapLayer $layer, + int $zoom, + int $x, + int $y, + ): string { + return sprintf( + '%s?%s=%s', + $this->tileEndpointUrl($layer, $zoom, $x, $y), + OpenWeatherMap::AUTHENTICATION_KEY, + rawurlencode($this->apiKey), + ); + } + public function tile( MapLayer $layer, int $zoom, int $x, int $y, ): MapTile { - $x = Assert::tileCoordinate($x, $zoom, 'X'); - $y = Assert::tileCoordinate($y, $zoom, 'Y'); - - // https://openweathermap.org/api/weathermaps $response = $this ->endpoint() - ->get(self::BASE_URL . '/map/{layer}/{zoom}/{x}/{y}.png', [ - 'layer' => $layer->value, - 'zoom' => $zoom, - 'x' => $x, - 'y' => $y, - ]); + ->get($this->tileEndpointUrl($layer, $zoom, $x, $y)); $contents = $response->data(); if (!is_string($contents)) { @@ -56,4 +63,22 @@ public function tile( return new MapTile($contents, $contentType); } + + private function tileEndpointUrl( + MapLayer $layer, + int $zoom, + int $x, + int $y, + ): string { + $x = Assert::tileCoordinate($x, $zoom, 'X'); + $y = Assert::tileCoordinate($y, $zoom, 'Y'); + + return sprintf( + self::TILE_URL, + rawurlencode($layer->value), + $zoom, + $x, + $y, + ); + } } diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php index fd93375..34b5873 100644 --- a/tests/Unit/Resource/MapsTest.php +++ b/tests/Unit/Resource/MapsTest.php @@ -5,6 +5,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; use ProgrammatorDev\OpenWeatherMap\Resource\Maps; use ProgrammatorDev\OpenWeatherMap\Response\MapTile; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -22,6 +23,34 @@ public function testMarksTheApiKeyAsSensitive(): void ); } + public function testGeneratesAnAuthenticatedTileUrlWithoutSendingARequest(): void + { + $api = new OpenWeatherMap('api key&value'); + $api->setup()->client($this->client); + + $url = $api->maps()->tileUrl( + layer: MapLayer::PRECIPITATION, + zoom: 6, + x: 31, + y: 20, + ); + $expected = 'https://tile.openweathermap.org/map/precipitation_new/6/31/20.png' + . '?appid=api%20key%26value'; + + self::assertSame($expected, $url); + self::assertSame([], $this->client->getRequests()); + } + + public function testValidatesGeneratedTileUrlAddresses(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'At zoom level 1, the tile X coordinate must be between 0 and 1.', + ); + + $this->api->maps()->tileUrl(MapLayer::CLOUDS, 1, 2, 0); + } + #[DataProvider('layers')] public function testGetsWeatherMapTiles( MapLayer $layer, From cc346733db1475fe1f527d49dd3e7ef87f875367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 15:26:16 +0100 Subject: [PATCH 078/113] docs(maps): improve tile usage examples --- docs/maps.md | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/maps.md b/docs/maps.md index 0f65051..9499e95 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -6,10 +6,9 @@ free and paid subscriptions. See the [official Weather Maps documentation](https://openweathermap.org/api/weathermaps) for API details. -## Generate A Tile URL +## Fetch A Tile -Use `tileUrl()` to generate an authenticated URL for a mapping library, image, -or other client that loads the tile directly. +Use `tile()` with a layer, zoom level, and X and Y tile coordinates. ```php use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; @@ -17,7 +16,7 @@ use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; $api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); -$url = $api->maps()->tileUrl( +$tile = $api->maps()->tile( layer: MapLayer::PRECIPITATION, zoom: 6, x: 31, @@ -25,13 +24,19 @@ $url = $api->maps()->tileUrl( ); ``` -Generating the URL does not make an HTTP request. It contains the API key passed -to `OpenWeatherMap`, so treat it as a credential and avoid including it in logs -or other unintended output. +`tile()` returns one 256-by-256 PNG overlay as a `MapTile`. The request is made +by the PHP application, so the API key is not included in the returned value. -## Fetch A Tile +```php +header('Content-Type: ' . $tile->contentType()); -Use `tile()` with a layer, zoom level, and X and Y tile coordinates. +echo $tile->contents(); +``` + +## Generate A Tile URL + +Use `tileUrl()` when an image or another client needs to load one specific tile +directly. Generating the URL does not make an HTTP request. ```php use ProgrammatorDev\OpenWeatherMap\Enum\MapLayer; @@ -39,7 +44,7 @@ use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; $api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); -$tile = $api->maps()->tile( +$url = $api->maps()->tileUrl( layer: MapLayer::PRECIPITATION, zoom: 6, x: 31, @@ -47,15 +52,15 @@ $tile = $api->maps()->tile( ); ``` -`tile()` returns one 256-by-256 PNG overlay as a `MapTile`. The request is made -by the PHP application, so the API key is not included in the returned value. +For example, the URL can be used as an image source: ```php -header('Content-Type: ' . $tile->contentType()); - -echo $tile->contents(); +Precipitation map tile ``` +The URL contains the API key passed to `OpenWeatherMap`. Treat it as a +credential and expose it only where direct client loading is intended. + ## Tile Coordinates X and Y are tile indexes, not longitude and latitude. Weather Maps uses the From 7502d261b7ab31ebbf78378270945fcdcb3f8ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 15:27:56 +0100 Subject: [PATCH 079/113] docs(maps): explain authentication and tile ranges --- src/OpenWeatherMap.php | 2 ++ src/Validation/Assert.php | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index 1a2e091..bc9523a 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -71,6 +71,8 @@ public function geocoding(): Geocoding public function maps(): Maps { + // URL generation does not send a request through the SDK authentication + // pipeline, so Maps also needs the validated key directly. return $this->resourceWith( Maps::class, apiKey: $this->apiKey, diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index ba627b8..38ff559 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -128,6 +128,9 @@ public static function tileCoordinate( string $axis, ): int { $zoom = self::nonNegativeInteger($zoom, 'tile zoom level'); + + // Each zoom level doubles the number of tiles along both axes, making + // the valid zero-based coordinate range 0 through 2^zoom - 1. $maximum = (2 ** $zoom) - 1; if ($coordinate < 0 || $coordinate > $maximum) { From 9b6e7b4f1aa0438d146d0c0005887b17dea49967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 15:46:34 +0100 Subject: [PATCH 080/113] feat(maps): add tile URL templates --- docs/maps.md | 26 ++++++++++++++++++++++ src/Resource/Maps.php | 37 +++++++++++++++++++++++++------- tests/Unit/Resource/MapsTest.php | 11 ++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/docs/maps.md b/docs/maps.md index 9499e95..71f09ec 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -61,6 +61,32 @@ For example, the URL can be used as an image source: The URL contains the API key passed to `OpenWeatherMap`. Treat it as a credential and expose it only where direct client loading is intended. +## Generate A Tile URL Template + +Use `tileUrlTemplate()` when an XYZ mapping library should replace the zoom, X, +and Y placeholders while loading the visible tiles. + +```php +$urlTemplate = $api->maps()->tileUrlTemplate( + MapLayer::PRECIPITATION, +); +``` + +The returned URL retains the standard `{z}`, `{x}`, and `{y}` placeholders: + +```text +https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=... +``` + +OpenWeather lists mapping-library integrations in its +[Weather Maps documentation](https://openweathermap.org/api/weathermaps). +The returned format can be passed to +[Leaflet](https://leafletjs.com/reference.html#tilelayer), +[OpenLayers](https://openlayers.org/en/latest/apidoc/module-ol_source_XYZ-XYZ.html), +or a [MapLibre raster source](https://maplibre.org/maplibre-style-spec/sources/). +Like a concrete tile URL, the template contains the API key and does not make an +HTTP request when generated. + ## Tile Coordinates X and Y are tile indexes, not longitude and latitude. Weather Maps uses the diff --git a/src/Resource/Maps.php b/src/Resource/Maps.php index 7e9d359..42957de 100644 --- a/src/Resource/Maps.php +++ b/src/Resource/Maps.php @@ -12,7 +12,7 @@ final class Maps extends Resource { // https://openweathermap.org/api/weathermaps - private const TILE_URL = 'https://tile.openweathermap.org/map/%s/%d/%d/%d.png'; + private const TILE_URL = 'https://tile.openweathermap.org/map/%s/%s/%s/%s.png'; public function __construct( Runtime $runtime, @@ -27,14 +27,23 @@ public function tileUrl( int $x, int $y, ): string { - return sprintf( - '%s?%s=%s', - $this->tileEndpointUrl($layer, $zoom, $x, $y), - OpenWeatherMap::AUTHENTICATION_KEY, - rawurlencode($this->apiKey), + return $this->appendAuthentication( + $this->buildTileUrl($layer, $zoom, $x, $y), ); } + public function tileUrlTemplate(MapLayer $layer): string + { + // XYZ clients replace these placeholders for every visible tile. + return $this->appendAuthentication(sprintf( + self::TILE_URL, + rawurlencode($layer->value), + '{z}', + '{x}', + '{y}', + )); + } + public function tile( MapLayer $layer, int $zoom, @@ -43,7 +52,7 @@ public function tile( ): MapTile { $response = $this ->endpoint() - ->get($this->tileEndpointUrl($layer, $zoom, $x, $y)); + ->get($this->buildTileUrl($layer, $zoom, $x, $y)); $contents = $response->data(); if (!is_string($contents)) { @@ -64,7 +73,19 @@ public function tile( return new MapTile($contents, $contentType); } - private function tileEndpointUrl( + private function appendAuthentication(string $url): string + { + // URL generation does not send a request through the SDK authentication + // pipeline, so append the same query credential explicitly. + return sprintf( + '%s?%s=%s', + $url, + OpenWeatherMap::AUTHENTICATION_KEY, + rawurlencode($this->apiKey), + ); + } + + private function buildTileUrl( MapLayer $layer, int $zoom, int $x, diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php index 34b5873..38f9a59 100644 --- a/tests/Unit/Resource/MapsTest.php +++ b/tests/Unit/Resource/MapsTest.php @@ -51,6 +51,17 @@ public function testValidatesGeneratedTileUrlAddresses(): void $this->api->maps()->tileUrl(MapLayer::CLOUDS, 1, 2, 0); } + public function testGeneratesAnAuthenticatedTileUrlTemplateWithoutSendingARequest(): void + { + $template = $this->api->maps()->tileUrlTemplate(MapLayer::WIND); + + self::assertSame( + 'https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=api-key', + $template, + ); + self::assertSame([], $this->client->getRequests()); + } + #[DataProvider('layers')] public function testGetsWeatherMapTiles( MapLayer $layer, From 0c8d56fa516e293d51e066d28ffcc987a68c1906 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sat, 8 Aug 2026 22:44:36 +0100 Subject: [PATCH 081/113] test(fixtures): add station lifecycle captures --- tests/Fixtures/README.md | 18 +++++++++-- .../{errors => direct}/missing-query.json | 0 .../missing-query.meta.json | 0 .../invalid-coordinates.json | 0 .../invalid-coordinates.meta.json | 0 .../tile/clouds-new.meta.json | 0 .../tile/clouds-new.png | Bin .../tile/missing-key.json | 0 .../tile/missing-key.meta.json | 0 .../tile/precipitation-new.meta.json | 0 .../tile/precipitation-new.png | Bin .../tile/pressure-new.meta.json | 0 .../tile/pressure-new.png | Bin .../tile/temp-new.meta.json | 0 .../{weather-maps => maps}/tile/temp-new.png | Bin .../tile/unknown-layer.json | 0 .../tile/unknown-layer.meta.json | 0 .../tile/wind-new.meta.json | 0 .../{weather-maps => maps}/tile/wind-new.png | Bin tests/Fixtures/stations/delete.empty | 0 tests/Fixtures/stations/delete.meta.json | 17 +++++++++++ tests/Fixtures/stations/list.json | 1 + tests/Fixtures/stations/list.meta.json | 16 ++++++++++ tests/Fixtures/stations/register.json | 1 + tests/Fixtures/stations/register.meta.json | 28 ++++++++++++++++++ tests/Fixtures/stations/retrieve.json | 1 + tests/Fixtures/stations/retrieve.meta.json | 16 ++++++++++ tests/Fixtures/stations/update.json | 1 + tests/Fixtures/stations/update.meta.json | 23 ++++++++++++++ tests/Unit/OpenWeatherMapTest.php | 6 ++-- tests/Unit/Resource/MapsTest.php | 10 +++---- tests/Unit/Response/PayloadDecoderTest.php | 6 ++-- 32 files changed, 130 insertions(+), 14 deletions(-) rename tests/Fixtures/geocoding/{errors => direct}/missing-query.json (100%) rename tests/Fixtures/geocoding/{errors => direct}/missing-query.meta.json (100%) rename tests/Fixtures/geocoding/{errors => reverse}/invalid-coordinates.json (100%) rename tests/Fixtures/geocoding/{errors => reverse}/invalid-coordinates.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/clouds-new.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/clouds-new.png (100%) rename tests/Fixtures/{weather-maps => maps}/tile/missing-key.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/missing-key.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/precipitation-new.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/precipitation-new.png (100%) rename tests/Fixtures/{weather-maps => maps}/tile/pressure-new.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/pressure-new.png (100%) rename tests/Fixtures/{weather-maps => maps}/tile/temp-new.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/temp-new.png (100%) rename tests/Fixtures/{weather-maps => maps}/tile/unknown-layer.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/unknown-layer.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/wind-new.meta.json (100%) rename tests/Fixtures/{weather-maps => maps}/tile/wind-new.png (100%) create mode 100644 tests/Fixtures/stations/delete.empty create mode 100644 tests/Fixtures/stations/delete.meta.json create mode 100644 tests/Fixtures/stations/list.json create mode 100644 tests/Fixtures/stations/list.meta.json create mode 100644 tests/Fixtures/stations/register.json create mode 100644 tests/Fixtures/stations/register.meta.json create mode 100644 tests/Fixtures/stations/retrieve.json create mode 100644 tests/Fixtures/stations/retrieve.meta.json create mode 100644 tests/Fixtures/stations/update.json create mode 100644 tests/Fixtures/stations/update.meta.json diff --git a/tests/Fixtures/README.md b/tests/Fixtures/README.md index 1dab1b9..72971e9 100644 --- a/tests/Fixtures/README.md +++ b/tests/Fixtures/README.md @@ -14,6 +14,11 @@ tests/Fixtures///.json tests/Fixtures///.meta.json ``` +Name top-level product folders after the corresponding public resource, using +kebab case where required, such as `maps`, `stations`, `air-pollution`, and +`one-call`. When a resource and endpoint are the same concept, operation names +may be used directly as scenarios instead of adding a redundant folder. + For example: ```text @@ -25,7 +30,8 @@ Use stable endpoint and scenario names such as `success`, `empty`, `missing-optional-fields`, or `invalid-request`. Do not include a captured location name in a filename because the returned name may change or be absent. Use the actual body format as the fixture extension, such as `.png` for a map -tile, even when the response advertises an incorrect content type. +tile, even when the response advertises an incorrect content type. Use +`.empty` for a successful response with a zero-byte body. ## Metadata @@ -73,8 +79,14 @@ action performed: } ``` -- Remove API keys from URLs and pagination links. -- Replace private station identifiers, names, and coordinates. +- Never record API keys or authentication headers in request metadata. Because + they are excluded at the capture boundary, do not list them as sanitization. +- Remove API keys from response URLs and pagination links, and record those + response changes in `sanitization`. +- Replace identifiers, names, and coordinates belonging to persistent or + user-owned stations. +- Generated identifiers and deliberately public metadata for a temporary + fixture station may remain unchanged after its deletion is verified. - Public test locations and coordinates may remain unchanged. - Do not change ordinary weather or geocoding values merely to make assertions easier. diff --git a/tests/Fixtures/geocoding/errors/missing-query.json b/tests/Fixtures/geocoding/direct/missing-query.json similarity index 100% rename from tests/Fixtures/geocoding/errors/missing-query.json rename to tests/Fixtures/geocoding/direct/missing-query.json diff --git a/tests/Fixtures/geocoding/errors/missing-query.meta.json b/tests/Fixtures/geocoding/direct/missing-query.meta.json similarity index 100% rename from tests/Fixtures/geocoding/errors/missing-query.meta.json rename to tests/Fixtures/geocoding/direct/missing-query.meta.json diff --git a/tests/Fixtures/geocoding/errors/invalid-coordinates.json b/tests/Fixtures/geocoding/reverse/invalid-coordinates.json similarity index 100% rename from tests/Fixtures/geocoding/errors/invalid-coordinates.json rename to tests/Fixtures/geocoding/reverse/invalid-coordinates.json diff --git a/tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json b/tests/Fixtures/geocoding/reverse/invalid-coordinates.meta.json similarity index 100% rename from tests/Fixtures/geocoding/errors/invalid-coordinates.meta.json rename to tests/Fixtures/geocoding/reverse/invalid-coordinates.meta.json diff --git a/tests/Fixtures/weather-maps/tile/clouds-new.meta.json b/tests/Fixtures/maps/tile/clouds-new.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/clouds-new.meta.json rename to tests/Fixtures/maps/tile/clouds-new.meta.json diff --git a/tests/Fixtures/weather-maps/tile/clouds-new.png b/tests/Fixtures/maps/tile/clouds-new.png similarity index 100% rename from tests/Fixtures/weather-maps/tile/clouds-new.png rename to tests/Fixtures/maps/tile/clouds-new.png diff --git a/tests/Fixtures/weather-maps/tile/missing-key.json b/tests/Fixtures/maps/tile/missing-key.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/missing-key.json rename to tests/Fixtures/maps/tile/missing-key.json diff --git a/tests/Fixtures/weather-maps/tile/missing-key.meta.json b/tests/Fixtures/maps/tile/missing-key.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/missing-key.meta.json rename to tests/Fixtures/maps/tile/missing-key.meta.json diff --git a/tests/Fixtures/weather-maps/tile/precipitation-new.meta.json b/tests/Fixtures/maps/tile/precipitation-new.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/precipitation-new.meta.json rename to tests/Fixtures/maps/tile/precipitation-new.meta.json diff --git a/tests/Fixtures/weather-maps/tile/precipitation-new.png b/tests/Fixtures/maps/tile/precipitation-new.png similarity index 100% rename from tests/Fixtures/weather-maps/tile/precipitation-new.png rename to tests/Fixtures/maps/tile/precipitation-new.png diff --git a/tests/Fixtures/weather-maps/tile/pressure-new.meta.json b/tests/Fixtures/maps/tile/pressure-new.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/pressure-new.meta.json rename to tests/Fixtures/maps/tile/pressure-new.meta.json diff --git a/tests/Fixtures/weather-maps/tile/pressure-new.png b/tests/Fixtures/maps/tile/pressure-new.png similarity index 100% rename from tests/Fixtures/weather-maps/tile/pressure-new.png rename to tests/Fixtures/maps/tile/pressure-new.png diff --git a/tests/Fixtures/weather-maps/tile/temp-new.meta.json b/tests/Fixtures/maps/tile/temp-new.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/temp-new.meta.json rename to tests/Fixtures/maps/tile/temp-new.meta.json diff --git a/tests/Fixtures/weather-maps/tile/temp-new.png b/tests/Fixtures/maps/tile/temp-new.png similarity index 100% rename from tests/Fixtures/weather-maps/tile/temp-new.png rename to tests/Fixtures/maps/tile/temp-new.png diff --git a/tests/Fixtures/weather-maps/tile/unknown-layer.json b/tests/Fixtures/maps/tile/unknown-layer.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/unknown-layer.json rename to tests/Fixtures/maps/tile/unknown-layer.json diff --git a/tests/Fixtures/weather-maps/tile/unknown-layer.meta.json b/tests/Fixtures/maps/tile/unknown-layer.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/unknown-layer.meta.json rename to tests/Fixtures/maps/tile/unknown-layer.meta.json diff --git a/tests/Fixtures/weather-maps/tile/wind-new.meta.json b/tests/Fixtures/maps/tile/wind-new.meta.json similarity index 100% rename from tests/Fixtures/weather-maps/tile/wind-new.meta.json rename to tests/Fixtures/maps/tile/wind-new.meta.json diff --git a/tests/Fixtures/weather-maps/tile/wind-new.png b/tests/Fixtures/maps/tile/wind-new.png similarity index 100% rename from tests/Fixtures/weather-maps/tile/wind-new.png rename to tests/Fixtures/maps/tile/wind-new.png diff --git a/tests/Fixtures/stations/delete.empty b/tests/Fixtures/stations/delete.empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/stations/delete.meta.json b/tests/Fixtures/stations/delete.meta.json new file mode 100644 index 0000000..bde1901 --- /dev/null +++ b/tests/Fixtures/stations/delete.meta.json @@ -0,0 +1,17 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Delete station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 204, + "bodyFormat": "empty", + "bodyBytes": 0, + "request": { + "method": "DELETE", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {} + }, + "sanitization": [], + "notes": "The generated identifier belongs to the deleted temporary fixture station and is unchanged. The successful response had no body or Content-Type header, and a following list request confirmed the account returned from zero stations to zero." +} diff --git a/tests/Fixtures/stations/list.json b/tests/Fixtures/stations/list.json new file mode 100644 index 0000000..b3836e8 --- /dev/null +++ b/tests/Fixtures/stations/list.json @@ -0,0 +1 @@ +[{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:08.107Z","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","longitude":-9.1393,"latitude":38.7223,"altitude":100,"rank":10}] diff --git a/tests/Fixtures/stations/list.meta.json b/tests/Fixtures/stations/list.meta.json new file mode 100644 index 0000000..b356b4e --- /dev/null +++ b/tests/Fixtures/stations/list.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "List stations", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/stations", + "query": {} + }, + "sanitization": [], + "notes": "The captured account contained only the deliberately public temporary fixture station. Its generated identifier is unchanged, and the station was deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/register.json b/tests/Fixtures/stations/register.json new file mode 100644 index 0000000..246520c --- /dev/null +++ b/tests/Fixtures/stations/register.json @@ -0,0 +1 @@ +{"ID":"6a779284adde3b0001343e02","updated_at":"2026-08-08T20:33:08.107598533Z","created_at":"2026-08-08T20:33:08.107598359Z","user_id":"user-fixture","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","latitude":38.7223,"longitude":-9.1393,"altitude":100,"rank":10,"source_type":5} diff --git a/tests/Fixtures/stations/register.meta.json b/tests/Fixtures/stations/register.meta.json new file mode 100644 index 0000000..ec4b8ce --- /dev/null +++ b/tests/Fixtures/stations/register.meta.json @@ -0,0 +1,28 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Register station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 201, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "POST", + "path": "/data/3.0/stations", + "query": {}, + "body": { + "external_id": "openweathermap-php-api-fixture", + "name": "OpenWeatherMap PHP API Fixture", + "latitude": 38.7223, + "longitude": -9.1393, + "altitude": 100 + } + }, + "sanitization": [ + { + "path": "$.user_id", + "action": "replaced private account identifier with user-fixture" + } + ], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted and the account returned from zero stations to zero." +} diff --git a/tests/Fixtures/stations/retrieve.json b/tests/Fixtures/stations/retrieve.json new file mode 100644 index 0000000..664f49c --- /dev/null +++ b/tests/Fixtures/stations/retrieve.json @@ -0,0 +1 @@ +{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:08.107Z","external_id":"openweathermap-php-api-fixture","name":"OpenWeatherMap PHP API Fixture","longitude":-9.1393,"latitude":38.7223,"altitude":100,"rank":10} diff --git a/tests/Fixtures/stations/retrieve.meta.json b/tests/Fixtures/stations/retrieve.meta.json new file mode 100644 index 0000000..584e559 --- /dev/null +++ b/tests/Fixtures/stations/retrieve.meta.json @@ -0,0 +1,16 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Retrieve station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {} + }, + "sanitization": [], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/update.json b/tests/Fixtures/stations/update.json new file mode 100644 index 0000000..3a5701c --- /dev/null +++ b/tests/Fixtures/stations/update.json @@ -0,0 +1 @@ +{"id":"6a779284adde3b0001343e02","created_at":"2026-08-08T20:33:08.107Z","updated_at":"2026-08-08T20:33:09.01059313Z","external_id":"openweathermap-php-api-fixture-updated","name":"Updated OpenWeatherMap PHP API Fixture","longitude":-9.14,"latitude":38.72,"altitude":110,"rank":0} diff --git a/tests/Fixtures/stations/update.meta.json b/tests/Fixtures/stations/update.meta.json new file mode 100644 index 0000000..57055bb --- /dev/null +++ b/tests/Fixtures/stations/update.meta.json @@ -0,0 +1,23 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Update station", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T20:33:09Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "PUT", + "path": "/data/3.0/stations/6a779284adde3b0001343e02", + "query": {}, + "body": { + "external_id": "openweathermap-php-api-fixture-updated", + "name": "Updated OpenWeatherMap PHP API Fixture", + "latitude": 38.72, + "longitude": -9.14, + "altitude": 110 + } + }, + "sanitization": [], + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The live response changed rank from 10 at registration to 0 after update, and the station was then deleted." +} diff --git a/tests/Unit/OpenWeatherMapTest.php b/tests/Unit/OpenWeatherMapTest.php index 1b9c72a..4cee864 100644 --- a/tests/Unit/OpenWeatherMapTest.php +++ b/tests/Unit/OpenWeatherMapTest.php @@ -88,7 +88,7 @@ public function testConfiguresBaseUrlQueryAuthenticationAndJsonPayloadDecoding() public function testReturnsMapImagesAsRawResponseData(): void { - $contents = Fixture::contents('weather-maps/tile/clouds-new.png'); + $contents = Fixture::contents('maps/tile/clouds-new.png'); $client = new Client(); $client->addResponse(new Response( headers: ['Content-Type' => 'image/png'], @@ -108,12 +108,12 @@ public function testReturnsMapImagesAsRawResponseData(): void public function testDecodesMislabeledJsonBeforeMappingTheHttpError(): void { - $data = Fixture::json('weather-maps/tile/missing-key.json'); + $data = Fixture::json('maps/tile/missing-key.json'); $client = new Client(); $client->addResponse(new Response( status: 401, headers: ['Content-Type' => 'image/png'], - body: Fixture::contents('weather-maps/tile/missing-key.json'), + body: Fixture::contents('maps/tile/missing-key.json'), )); $api = new OpenWeatherMap('api-key'); diff --git a/tests/Unit/Resource/MapsTest.php b/tests/Unit/Resource/MapsTest.php index 38f9a59..76d5a20 100644 --- a/tests/Unit/Resource/MapsTest.php +++ b/tests/Unit/Resource/MapsTest.php @@ -100,27 +100,27 @@ public static function layers(): iterable yield 'clouds' => [ MapLayer::CLOUDS, '/map/clouds_new/1/1/1.png', - 'weather-maps/tile/clouds-new.png', + 'maps/tile/clouds-new.png', ]; yield 'precipitation' => [ MapLayer::PRECIPITATION, '/map/precipitation_new/1/1/1.png', - 'weather-maps/tile/precipitation-new.png', + 'maps/tile/precipitation-new.png', ]; yield 'pressure' => [ MapLayer::PRESSURE, '/map/pressure_new/1/1/1.png', - 'weather-maps/tile/pressure-new.png', + 'maps/tile/pressure-new.png', ]; yield 'wind' => [ MapLayer::WIND, '/map/wind_new/1/1/1.png', - 'weather-maps/tile/wind-new.png', + 'maps/tile/wind-new.png', ]; yield 'temperature' => [ MapLayer::TEMPERATURE, '/map/temp_new/1/1/1.png', - 'weather-maps/tile/temp-new.png', + 'maps/tile/temp-new.png', ]; } diff --git a/tests/Unit/Response/PayloadDecoderTest.php b/tests/Unit/Response/PayloadDecoderTest.php index fddc556..4d42e6c 100644 --- a/tests/Unit/Response/PayloadDecoderTest.php +++ b/tests/Unit/Response/PayloadDecoderTest.php @@ -23,18 +23,18 @@ public function testItDecodesJsonRegardlessOfTheContentType(): void $response = new Response( status: 401, headers: ['Content-Type' => 'image/png'], - body: Fixture::contents('weather-maps/tile/missing-key.json'), + body: Fixture::contents('maps/tile/missing-key.json'), ); self::assertSame( - Fixture::json('weather-maps/tile/missing-key.json'), + Fixture::json('maps/tile/missing-key.json'), ($this->decoder)($response), ); } public function testItReturnsBinaryBodiesUnchanged(): void { - $contents = Fixture::contents('weather-maps/tile/clouds-new.png'); + $contents = Fixture::contents('maps/tile/clouds-new.png'); $response = new Response( headers: ['Content-Type' => 'image/png'], body: $contents, From 90743d82a91d7573b1e029bfe85cc95e0b0f0edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 10:22:20 +0100 Subject: [PATCH 082/113] test(fixtures): add station measurement captures --- .../measurements/aggregate-day-empty.json | 1 + .../aggregate-day-empty.meta.json | 22 ++++++++ .../measurements/aggregate-hour-empty.json | 1 + .../aggregate-hour-empty.meta.json | 22 ++++++++ .../measurements/aggregate-minute-empty.json | 1 + .../aggregate-minute-empty.meta.json | 22 ++++++++ .../stations/measurements/submit.empty | 0 .../stations/measurements/submit.meta.json | 52 +++++++++++++++++++ 8 files changed, 121 insertions(+) create mode 100644 tests/Fixtures/stations/measurements/aggregate-day-empty.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-hour-empty.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-minute-empty.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json create mode 100644 tests/Fixtures/stations/measurements/submit.empty create mode 100644 tests/Fixtures/stations/measurements/submit.meta.json diff --git a/tests/Fixtures/stations/measurements/aggregate-day-empty.json b/tests/Fixtures/stations/measurements/aggregate-day-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json new file mode 100644 index 0000000..8bdd456 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "d", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting measurements in completed time buckets. The temporary station and its measurements were deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-empty.json b/tests/Fixtures/stations/measurements/aggregate-hour-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json new file mode 100644 index 0000000..be8b416 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "h", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting measurements in completed time buckets. The temporary station and its measurements were deleted before the batch completed." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-empty.json b/tests/Fixtures/stations/measurements/aggregate-minute-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json new file mode 100644 index 0000000..1a7d164 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-empty.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77b80aadde3b0001343e08", + "type": "m", + "limit": 10, + "from": 1786143599, + "to": 1786230780 + } + }, + "sanitization": [], + "notes": "The API returned an empty array after accepting three measurements with HTTP 204. Five polls covered completed minute, hour, and day buckets before the temporary station was deleted." +} diff --git a/tests/Fixtures/stations/measurements/submit.empty b/tests/Fixtures/stations/measurements/submit.empty new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/stations/measurements/submit.meta.json b/tests/Fixtures/stations/measurements/submit.meta.json new file mode 100644 index 0000000..03e6961 --- /dev/null +++ b/tests/Fixtures/stations/measurements/submit.meta.json @@ -0,0 +1,52 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Submit station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-08T23:13:27Z", + "httpStatus": 204, + "bodyFormat": "empty", + "bodyBytes": 0, + "request": { + "method": "POST", + "path": "/data/3.0/measurements", + "query": {}, + "body": [ + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786143600, + "temperature": 19.5, + "wind_speed": 2.4, + "wind_gust": 4.1, + "wind_deg": 180, + "pressure": 1012, + "humidity": 68, + "rain_1h": 0.2 + }, + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786228200, + "temperature": 20.5, + "wind_speed": 3.2, + "wind_gust": 5.3, + "wind_deg": 200, + "pressure": 1013, + "humidity": 64, + "rain_1h": 0.4 + }, + { + "station_id": "6a77b80aadde3b0001343e08", + "dt": 1786230720, + "temperature": 21.5, + "wind_speed": 4.0, + "wind_gust": 6.5, + "wind_deg": 220, + "pressure": 1014, + "humidity": 60, + "rain_1h": 0.6 + } + ] + }, + "sanitization": [], + "notes": "The generated identifier belongs to a deliberately public temporary fixture station. Submission returned no body or Content-Type header. The station and its measurements were deleted before the batch completed." +} From 24cb75253fbdb208d572be083d1cfebdd841d41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 10:41:00 +0100 Subject: [PATCH 083/113] feat(stations): add read endpoints --- README.md | 1 + docs/stations.md | 51 ++++++++++ src/Entity/Stations/Station.php | 107 ++++++++++++++++++++ src/Hydration/PayloadReader.php | 49 +++++++++ src/OpenWeatherMap.php | 6 ++ src/Resource/Stations.php | 38 +++++++ tests/Unit/Entity/Stations/StationTest.php | 111 +++++++++++++++++++++ tests/Unit/Hydration/PayloadReaderTest.php | 27 +++++ tests/Unit/Resource/StationsTest.php | 51 ++++++++++ 9 files changed, 441 insertions(+) create mode 100644 docs/stations.md create mode 100644 src/Entity/Stations/Station.php create mode 100644 src/Resource/Stations.php create mode 100644 tests/Unit/Entity/Stations/StationTest.php create mode 100644 tests/Unit/Resource/StationsTest.php diff --git a/README.md b/README.md index 085b198..fd8a494 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ use yet. - [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) - [Weather Maps](docs/maps.md) +- [Weather Stations](docs/stations.md) - [Geocoding](docs/geocoding.md) ## License diff --git a/docs/stations.md b/docs/stations.md new file mode 100644 index 0000000..2a84cbb --- /dev/null +++ b/docs/stations.md @@ -0,0 +1,51 @@ +# Weather Stations + +Weather Stations API 3.0 manages personal weather stations associated with an +OpenWeather account and is available on OpenWeather's standard free and paid +subscriptions. See the +[official Weather Stations documentation](https://openweathermap.org/api/stations) +for API details. + +## List Stations + +Use `all()` to retrieve every station associated with the authenticated API +key. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$stations = $api->stations()->all(); +``` + +The method returns an array of `Station` entities and returns an empty array +when the account has no registered stations. + +```php +foreach ($stations as $station) { + echo $station->id(); + echo $station->name(); + echo $station->externalId(); + echo $station->coordinates()?->latitude(); + echo $station->coordinates()?->longitude(); + echo $station->altitude(); + echo $station->rank(); + echo $station->createdAt()?->format(DATE_ATOM); + echo $station->updatedAt()?->format(DATE_ATOM); +} +``` + +Every response property may be absent or `null`. Creation and update times are +returned as UTC `DateTimeImmutable` values. Registration responses may also +populate `userId()` and `sourceType()`; other station responses omit them. + +## Find A Station + +Use `find()` with the internal station ID returned by OpenWeather. + +```php +$station = $api->stations()->find('station-id'); + +echo $station->name(); +``` diff --git a/src/Entity/Stations/Station.php b/src/Entity/Stations/Station.php new file mode 100644 index 0000000..de454a8 --- /dev/null +++ b/src/Entity/Stations/Station.php @@ -0,0 +1,107 @@ +nullableFloat('latitude'); + $longitude = $reader->nullableFloat('longitude'); + + // Registration uniquely returns an uppercase ID; list, retrieve, and + // update responses use the conventional lowercase field. + $id = $reader->nullableString('id'); + $registrationId = $reader->nullableString('ID'); + + $hasCoordinates = array_key_exists('latitude', $data) + || array_key_exists('longitude', $data); + + return new self( + id: $id ?? $registrationId, + createdAt: $reader->nullableDateTime('created_at'), + updatedAt: $reader->nullableDateTime('updated_at'), + externalId: $reader->nullableString('external_id'), + name: $reader->nullableString('name'), + coordinates: $hasCoordinates + ? Coordinates::fromArray([ + 'lat' => $latitude, + 'lon' => $longitude, + ], $context) + : null, + altitude: $reader->nullableFloat('altitude'), + rank: $reader->nullableInt('rank'), + userId: $reader->nullableString('user_id'), + sourceType: $reader->nullableInt('source_type'), + ); + } + + public function id(): ?string + { + return $this->id; + } + + public function createdAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function updatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function externalId(): ?string + { + return $this->externalId; + } + + public function name(): ?string + { + return $this->name; + } + + public function coordinates(): ?Coordinates + { + return $this->coordinates; + } + + public function altitude(): ?float + { + return $this->altitude; + } + + public function rank(): ?int + { + return $this->rank; + } + + public function userId(): ?string + { + return $this->userId; + } + + public function sourceType(): ?int + { + return $this->sourceType; + } +} diff --git a/src/Hydration/PayloadReader.php b/src/Hydration/PayloadReader.php index fa61ef3..89fae96 100644 --- a/src/Hydration/PayloadReader.php +++ b/src/Hydration/PayloadReader.php @@ -110,6 +110,55 @@ public function nullableTimestamp(string $path): ?\DateTimeImmutable ->setTimezone(new \DateTimeZone('UTC')); } + public function nullableDateTime(string $path): ?\DateTimeImmutable + { + $value = $this->nullableString($path); + + if ($value === null) { + return null; + } + + // Station responses use UTC ISO 8601 strings with variable + // fractional-second precision, including nanoseconds. + if (preg_match( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/D', + $value, + ) !== 1) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + try { + $dateTime = new \DateTimeImmutable($value); + } catch (\Exception) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + $errors = \DateTimeImmutable::getLastErrors(); + + if ($errors !== false + && ($errors['warning_count'] > 0 || $errors['error_count'] > 0) + ) { + throw HydrationException::invalidValue( + $this->entity, + $path, + 'ISO 8601 date-time string', + $value, + ); + } + + return $dateTime->setTimezone(new \DateTimeZone('UTC')); + } + /** * @param \Closure(mixed): bool $accepts */ diff --git a/src/OpenWeatherMap.php b/src/OpenWeatherMap.php index bc9523a..096a014 100644 --- a/src/OpenWeatherMap.php +++ b/src/OpenWeatherMap.php @@ -16,6 +16,7 @@ use ProgrammatorDev\OpenWeatherMap\Resource\Geocoding; use ProgrammatorDev\OpenWeatherMap\Resource\Maps; use ProgrammatorDev\OpenWeatherMap\Resource\OneCall; +use ProgrammatorDev\OpenWeatherMap\Resource\Stations; use ProgrammatorDev\OpenWeatherMap\Resource\Weather; use ProgrammatorDev\OpenWeatherMap\Response\PayloadDecoder; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -84,6 +85,11 @@ public function oneCall(): OneCall return $this->resource(OneCall::class); } + public function stations(): Stations + { + return $this->resource(Stations::class); + } + public function weather(): Weather { return $this->resource(Weather::class); diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php new file mode 100644 index 0000000..54c2952 --- /dev/null +++ b/src/Resource/Stations.php @@ -0,0 +1,38 @@ + + */ + public function all(): array + { + // https://openweathermap.org/api/stations + return $this + ->endpoint() + ->get('/data/3.0/stations') + ->collection(Station::class); + } + + public function find(string $id): Station + { + $id = Assert::notBlank($id, 'station ID'); + + // https://openweathermap.org/api/stations + /** @var Station $station */ + $station = $this + ->endpoint() + ->get('/data/3.0/stations/{id}', [ + 'id' => $id, + ]) + ->entity(Station::class); + + return $station; + } +} diff --git a/tests/Unit/Entity/Stations/StationTest.php b/tests/Unit/Entity/Stations/StationTest.php new file mode 100644 index 0000000..eaad8ea --- /dev/null +++ b/tests/Unit/Entity/Stations/StationTest.php @@ -0,0 +1,111 @@ +id()); + self::assertSame('2026-08-08T20:33:08+00:00', $station->createdAt()?->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->createdAt()?->format('u')); + self::assertSame('UTC', $station->createdAt()?->getTimezone()->getName()); + self::assertSame('2026-08-08T20:33:08+00:00', $station->updatedAt()?->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->updatedAt()?->format('u')); + self::assertSame('openweathermap-php-api-fixture', $station->externalId()); + self::assertSame('OpenWeatherMap PHP API Fixture', $station->name()); + self::assertSame(38.7223, $station->coordinates()?->latitude()); + self::assertSame(-9.1393, $station->coordinates()?->longitude()); + self::assertSame(100.0, $station->altitude()); + self::assertSame(10, $station->rank()); + self::assertNull($station->userId()); + self::assertNull($station->sourceType()); + } + + public function testHydratesCapturedRegistrationFields(): void + { + $station = Station::fromArray(Fixture::json('stations/register.json')); + + self::assertSame('6a779284adde3b0001343e02', $station->id()); + self::assertSame('user-fixture', $station->userId()); + self::assertSame(5, $station->sourceType()); + self::assertSame('107598', $station->createdAt()?->format('u')); + self::assertSame('107598', $station->updatedAt()?->format('u')); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = Station::fromArray([]); + + self::assertNull($missing->id()); + self::assertNull($missing->createdAt()); + self::assertNull($missing->updatedAt()); + self::assertNull($missing->externalId()); + self::assertNull($missing->name()); + self::assertNull($missing->coordinates()); + self::assertNull($missing->altitude()); + self::assertNull($missing->rank()); + self::assertNull($missing->userId()); + self::assertNull($missing->sourceType()); + + $station = Station::fromArray([ + 'id' => null, + 'created_at' => null, + 'updated_at' => null, + 'external_id' => null, + 'name' => null, + 'latitude' => null, + 'altitude' => null, + 'rank' => null, + 'user_id' => null, + 'source_type' => null, + 'unknown' => new \stdClass(), + ]); + + self::assertNull($station->id()); + self::assertNull($station->createdAt()); + self::assertNull($station->updatedAt()); + self::assertNull($station->externalId()); + self::assertNull($station->name()); + self::assertNull($station->coordinates()?->latitude()); + self::assertNull($station->coordinates()?->longitude()); + self::assertNull($station->altitude()); + self::assertNull($station->rank()); + self::assertNull($station->userId()); + self::assertNull($station->sourceType()); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields(array $data, string $message): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage($message); + + Station::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'identifier' => [['id' => 1], '"id" expected string, int received.']; + yield 'registration identifier' => [['ID' => 1], '"ID" expected string, int received.']; + yield 'created date type' => [['created_at' => 1], '"created_at" expected string, int received.']; + yield 'created date value' => [['created_at' => 'tomorrow'], '"created_at" expected ISO 8601 date-time string, "tomorrow" received.']; + yield 'updated date' => [['updated_at' => 1], '"updated_at" expected string, int received.']; + yield 'external identifier' => [['external_id' => 1], '"external_id" expected string, int received.']; + yield 'name' => [['name' => 1], '"name" expected string, int received.']; + yield 'latitude' => [['latitude' => '38.7'], '"latitude" expected int|float, string received.']; + yield 'longitude' => [['longitude' => '-9.1'], '"longitude" expected int|float, string received.']; + yield 'altitude' => [['altitude' => '100'], '"altitude" expected int|float, string received.']; + yield 'rank' => [['rank' => '10'], '"rank" expected int, string received.']; + yield 'user identifier' => [['user_id' => 1], '"user_id" expected string, int received.']; + yield 'source type' => [['source_type' => '5'], '"source_type" expected int, string received.']; + } +} diff --git a/tests/Unit/Hydration/PayloadReaderTest.php b/tests/Unit/Hydration/PayloadReaderTest.php index 074f5f4..01225b1 100644 --- a/tests/Unit/Hydration/PayloadReaderTest.php +++ b/tests/Unit/Hydration/PayloadReaderTest.php @@ -19,6 +19,7 @@ public function testItReadsSupportedNullableValues(): void 'daylight' => true, 'rain' => ['1h' => 0.4], 'alerts' => ['alert-1', 'alert-2'], + 'created_at' => '2026-08-08T20:33:08.107Z', ], 'Weather'); self::assertSame('Lisbon', $reader->nullableString('name')); @@ -31,6 +32,10 @@ public function testItReadsSupportedNullableValues(): void ['alert-1', 'alert-2'], $reader->nullableStringList('alerts') ); + self::assertSame( + '2026-08-08T20:33:08+00:00', + $reader->nullableDateTime('created_at')?->format(\DateTimeInterface::ATOM), + ); } public function testMissingAndNullValuesAreTolerated(): void @@ -41,6 +46,7 @@ public function testMissingAndNullValuesAreTolerated(): void self::assertNull($reader->nullableString('name')); self::assertNull($reader->nullableStringList('alerts')); self::assertNull($reader->nullableTimestamp('observed_at')); + self::assertNull($reader->nullableDateTime('created_at')); } public function testUnknownFieldsAreIgnored(): void @@ -87,6 +93,26 @@ public function testItHydratesTimestampsAsImmutableUtcValues(): void self::assertSame('2023-11-14T22:13:20+00:00', $timestamp->format(\DateTimeInterface::ATOM)); } + #[DataProvider('invalidDateTimeProvider')] + public function testItRejectsMalformedDateTimeStrings(string $value): void + { + $reader = PayloadReader::from(['created_at' => $value], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "created_at" expected ISO 8601 date-time string, "%s" received.', + $value, + )); + + $reader->nullableDateTime('created_at'); + } + + public static function invalidDateTimeProvider(): iterable + { + yield 'relative value' => ['tomorrow']; + yield 'invalid calendar date' => ['2026-02-31T12:00:00Z']; + } + #[DataProvider('invalidValueProvider')] public function testItRejectsKnownFieldsWithInvalidTypes( string $method, @@ -121,5 +147,6 @@ public static function invalidValueProvider(): iterable yield 'boolean' => ['nullableBool', 1, 'bool', 'int']; yield 'array' => ['nullableArray', new \stdClass(), 'array', 'stdClass']; yield 'timestamp' => ['nullableTimestamp', '1700000000', 'int', 'string']; + yield 'date and time' => ['nullableDateTime', 1700000000, 'string', 'int']; } } diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php new file mode 100644 index 0000000..223e493 --- /dev/null +++ b/tests/Unit/Resource/StationsTest.php @@ -0,0 +1,51 @@ +respondWithFixture('stations/list.json'); + + $stations = $this->api->stations()->all(); + $request = $this->client->getLastRequest(); + + self::assertCount(1, $stations); + self::assertContainsOnlyInstancesOf(Station::class, $stations); + self::assertSame('6a779284adde3b0001343e02', $stations[0]->id()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/3.0/stations', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testFindsAStation(): void + { + $this->respondWithFixture('stations/retrieve.json'); + + $station = $this->api->stations()->find(' 6a779284adde3b0001343e02 '); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('OpenWeatherMap PHP API Fixture', $station->name()); + self::assertSame('GET', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + } + + public function testRejectsABlankStationIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->find(' '); + } +} From 7db3a3b500d7b9ea1d842b82e5942ff4dcfe3907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 10:53:49 +0100 Subject: [PATCH 084/113] refactor(stations): require core metadata --- docs/stations.md | 15 +-- src/Entity/Stations/Station.php | 74 +++++++------- src/Hydration/PayloadReader.php | 53 ++++++++++ tests/Unit/Entity/Stations/StationTest.php | 110 +++++++++++++-------- tests/Unit/Hydration/PayloadReaderTest.php | 31 ++++++ 5 files changed, 200 insertions(+), 83 deletions(-) diff --git a/docs/stations.md b/docs/stations.md index 2a84cbb..ebc05ac 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -27,18 +27,19 @@ foreach ($stations as $station) { echo $station->id(); echo $station->name(); echo $station->externalId(); - echo $station->coordinates()?->latitude(); - echo $station->coordinates()?->longitude(); + echo $station->latitude(); + echo $station->longitude(); echo $station->altitude(); echo $station->rank(); - echo $station->createdAt()?->format(DATE_ATOM); - echo $station->updatedAt()?->format(DATE_ATOM); + echo $station->createdAt()->format(DATE_ATOM); + echo $station->updatedAt()->format(DATE_ATOM); } ``` -Every response property may be absent or `null`. Creation and update times are -returned as UTC `DateTimeImmutable` values. Registration responses may also -populate `userId()` and `sourceType()`; other station responses omit them. +Core station properties are required because they describe station metadata +registered with OpenWeather. Creation and update times are returned as UTC +`DateTimeImmutable` values. Registration responses may also populate +`userId()` and `sourceType()`; other station responses omit them. ## Find A Station diff --git a/src/Entity/Stations/Station.php b/src/Entity/Stations/Station.php index de454a8..d751c4e 100644 --- a/src/Entity/Stations/Station.php +++ b/src/Entity/Stations/Station.php @@ -4,20 +4,21 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; -use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; +use ProgrammatorDev\OpenWeatherMap\Exception\HydrationException; use ProgrammatorDev\OpenWeatherMap\Hydration\PayloadReader; final class Station implements EntityInterface { private function __construct( - private readonly ?string $id, - private readonly ?\DateTimeImmutable $createdAt, - private readonly ?\DateTimeImmutable $updatedAt, - private readonly ?string $externalId, - private readonly ?string $name, - private readonly ?Coordinates $coordinates, - private readonly ?float $altitude, - private readonly ?int $rank, + private readonly string $id, + private readonly \DateTimeImmutable $createdAt, + private readonly \DateTimeImmutable $updatedAt, + private readonly string $externalId, + private readonly string $name, + private readonly float $latitude, + private readonly float $longitude, + private readonly float $altitude, + private readonly int $rank, private readonly ?string $userId, private readonly ?int $sourceType, ) {} @@ -25,72 +26,77 @@ private function __construct( public static function fromArray(array $data, ?Context $context = null): static { $reader = PayloadReader::from($data, self::class); - $latitude = $reader->nullableFloat('latitude'); - $longitude = $reader->nullableFloat('longitude'); // Registration uniquely returns an uppercase ID; list, retrieve, and // update responses use the conventional lowercase field. $id = $reader->nullableString('id'); $registrationId = $reader->nullableString('ID'); - $hasCoordinates = array_key_exists('latitude', $data) - || array_key_exists('longitude', $data); + if ($id === null && $registrationId === null) { + throw HydrationException::invalidType( + self::class, + 'id', + 'string', + null, + ); + } return new self( id: $id ?? $registrationId, - createdAt: $reader->nullableDateTime('created_at'), - updatedAt: $reader->nullableDateTime('updated_at'), - externalId: $reader->nullableString('external_id'), - name: $reader->nullableString('name'), - coordinates: $hasCoordinates - ? Coordinates::fromArray([ - 'lat' => $latitude, - 'lon' => $longitude, - ], $context) - : null, - altitude: $reader->nullableFloat('altitude'), - rank: $reader->nullableInt('rank'), + createdAt: $reader->requiredDateTime('created_at'), + updatedAt: $reader->requiredDateTime('updated_at'), + externalId: $reader->requiredString('external_id'), + name: $reader->requiredString('name'), + latitude: $reader->requiredFloat('latitude'), + longitude: $reader->requiredFloat('longitude'), + altitude: $reader->requiredFloat('altitude'), + rank: $reader->requiredInt('rank'), userId: $reader->nullableString('user_id'), sourceType: $reader->nullableInt('source_type'), ); } - public function id(): ?string + public function id(): string { return $this->id; } - public function createdAt(): ?\DateTimeImmutable + public function createdAt(): \DateTimeImmutable { return $this->createdAt; } - public function updatedAt(): ?\DateTimeImmutable + public function updatedAt(): \DateTimeImmutable { return $this->updatedAt; } - public function externalId(): ?string + public function externalId(): string { return $this->externalId; } - public function name(): ?string + public function name(): string { return $this->name; } - public function coordinates(): ?Coordinates + public function latitude(): float { - return $this->coordinates; + return $this->latitude; } - public function altitude(): ?float + public function longitude(): float + { + return $this->longitude; + } + + public function altitude(): float { return $this->altitude; } - public function rank(): ?int + public function rank(): int { return $this->rank; } diff --git a/src/Hydration/PayloadReader.php b/src/Hydration/PayloadReader.php index 89fae96..77b5bbb 100644 --- a/src/Hydration/PayloadReader.php +++ b/src/Hydration/PayloadReader.php @@ -159,6 +159,59 @@ public function nullableDateTime(string $path): ?\DateTimeImmutable return $dateTime->setTimezone(new \DateTimeZone('UTC')); } + public function requiredString(string $path): string + { + return $this->requiredValue( + $path, + 'string', + $this->nullableString($path), + ); + } + + public function requiredInt(string $path): int + { + return $this->requiredValue( + $path, + 'int', + $this->nullableInt($path), + ); + } + + public function requiredFloat(string $path): float + { + return $this->requiredValue( + $path, + 'int|float', + $this->nullableFloat($path), + ); + } + + public function requiredDateTime(string $path): \DateTimeImmutable + { + return $this->requiredValue( + $path, + 'ISO 8601 date-time string', + $this->nullableDateTime($path), + ); + } + + private function requiredValue( + string $path, + string $expectedType, + mixed $value, + ): mixed { + if ($value === null) { + throw HydrationException::invalidType( + $this->entity, + $path, + $expectedType, + $value, + ); + } + + return $value; + } + /** * @param \Closure(mixed): bool $accepts */ diff --git a/tests/Unit/Entity/Stations/StationTest.php b/tests/Unit/Entity/Stations/StationTest.php index eaad8ea..1cce26c 100644 --- a/tests/Unit/Entity/Stations/StationTest.php +++ b/tests/Unit/Entity/Stations/StationTest.php @@ -15,15 +15,15 @@ public function testHydratesCapturedStation(): void $station = Station::fromArray(Fixture::json('stations/retrieve.json')); self::assertSame('6a779284adde3b0001343e02', $station->id()); - self::assertSame('2026-08-08T20:33:08+00:00', $station->createdAt()?->format(\DateTimeInterface::ATOM)); - self::assertSame('107000', $station->createdAt()?->format('u')); - self::assertSame('UTC', $station->createdAt()?->getTimezone()->getName()); - self::assertSame('2026-08-08T20:33:08+00:00', $station->updatedAt()?->format(\DateTimeInterface::ATOM)); - self::assertSame('107000', $station->updatedAt()?->format('u')); + self::assertSame('2026-08-08T20:33:08+00:00', $station->createdAt()->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->createdAt()->format('u')); + self::assertSame('UTC', $station->createdAt()->getTimezone()->getName()); + self::assertSame('2026-08-08T20:33:08+00:00', $station->updatedAt()->format(\DateTimeInterface::ATOM)); + self::assertSame('107000', $station->updatedAt()->format('u')); self::assertSame('openweathermap-php-api-fixture', $station->externalId()); self::assertSame('OpenWeatherMap PHP API Fixture', $station->name()); - self::assertSame(38.7223, $station->coordinates()?->latitude()); - self::assertSame(-9.1393, $station->coordinates()?->longitude()); + self::assertSame(38.7223, $station->latitude()); + self::assertSame(-9.1393, $station->longitude()); self::assertSame(100.0, $station->altitude()); self::assertSame(10, $station->rank()); self::assertNull($station->userId()); @@ -37,59 +37,85 @@ public function testHydratesCapturedRegistrationFields(): void self::assertSame('6a779284adde3b0001343e02', $station->id()); self::assertSame('user-fixture', $station->userId()); self::assertSame(5, $station->sourceType()); - self::assertSame('107598', $station->createdAt()?->format('u')); - self::assertSame('107598', $station->updatedAt()?->format('u')); + self::assertSame('107598', $station->createdAt()->format('u')); + self::assertSame('107598', $station->updatedAt()->format('u')); } - public function testToleratesMissingNullUnknownAndPartialFields(): void + public function testToleratesNullAndUnknownOptionalFields(): void { - $missing = Station::fromArray([]); - - self::assertNull($missing->id()); - self::assertNull($missing->createdAt()); - self::assertNull($missing->updatedAt()); - self::assertNull($missing->externalId()); - self::assertNull($missing->name()); - self::assertNull($missing->coordinates()); - self::assertNull($missing->altitude()); - self::assertNull($missing->rank()); - self::assertNull($missing->userId()); - self::assertNull($missing->sourceType()); - - $station = Station::fromArray([ - 'id' => null, - 'created_at' => null, - 'updated_at' => null, - 'external_id' => null, - 'name' => null, - 'latitude' => null, - 'altitude' => null, - 'rank' => null, + $data = Fixture::json('stations/retrieve.json'); + $data = array_replace($data, [ 'user_id' => null, 'source_type' => null, 'unknown' => new \stdClass(), ]); + $station = Station::fromArray($data); - self::assertNull($station->id()); - self::assertNull($station->createdAt()); - self::assertNull($station->updatedAt()); - self::assertNull($station->externalId()); - self::assertNull($station->name()); - self::assertNull($station->coordinates()?->latitude()); - self::assertNull($station->coordinates()?->longitude()); - self::assertNull($station->altitude()); - self::assertNull($station->rank()); self::assertNull($station->userId()); self::assertNull($station->sourceType()); } + #[DataProvider('requiredFields')] + public function testRejectsMissingRequiredFields( + string $field, + string $path, + string $expectedType, + ): void { + $data = Fixture::json('stations/retrieve.json'); + unset($data[$field]); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, null received.', + $path, + $expectedType, + )); + + Station::fromArray($data); + } + + #[DataProvider('requiredFields')] + public function testRejectsNullRequiredFields( + string $field, + string $path, + string $expectedType, + ): void { + $data = Fixture::json('stations/retrieve.json'); + $data[$field] = null; + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, null received.', + $path, + $expectedType, + )); + + Station::fromArray($data); + } + + public static function requiredFields(): iterable + { + yield 'identifier' => ['id', 'id', 'string']; + yield 'created date' => ['created_at', 'created_at', 'ISO 8601 date-time string']; + yield 'updated date' => ['updated_at', 'updated_at', 'ISO 8601 date-time string']; + yield 'external identifier' => ['external_id', 'external_id', 'string']; + yield 'name' => ['name', 'name', 'string']; + yield 'latitude' => ['latitude', 'latitude', 'int|float']; + yield 'longitude' => ['longitude', 'longitude', 'int|float']; + yield 'altitude' => ['altitude', 'altitude', 'int|float']; + yield 'rank' => ['rank', 'rank', 'int']; + } + #[DataProvider('invalidFields')] public function testRejectsInvalidKnownFields(array $data, string $message): void { $this->expectException(HydrationException::class); $this->expectExceptionMessage($message); - Station::fromArray($data); + Station::fromArray(array_replace( + Fixture::json('stations/retrieve.json'), + $data, + )); } public static function invalidFields(): iterable diff --git a/tests/Unit/Hydration/PayloadReaderTest.php b/tests/Unit/Hydration/PayloadReaderTest.php index 01225b1..b10b795 100644 --- a/tests/Unit/Hydration/PayloadReaderTest.php +++ b/tests/Unit/Hydration/PayloadReaderTest.php @@ -23,8 +23,11 @@ public function testItReadsSupportedNullableValues(): void ], 'Weather'); self::assertSame('Lisbon', $reader->nullableString('name')); + self::assertSame('Lisbon', $reader->requiredString('name')); self::assertSame(3600, $reader->nullableInt('timezone')); + self::assertSame(3600, $reader->requiredInt('timezone')); self::assertSame(20.0, $reader->nullableFloat('temperature')); + self::assertSame(20.0, $reader->requiredFloat('temperature')); self::assertSame(12.5, $reader->nullableFloat('cloudiness')); self::assertTrue($reader->nullableBool('daylight')); self::assertSame(['1h' => 0.4], $reader->nullableArray('rain')); @@ -36,6 +39,10 @@ public function testItReadsSupportedNullableValues(): void '2026-08-08T20:33:08+00:00', $reader->nullableDateTime('created_at')?->format(\DateTimeInterface::ATOM), ); + self::assertSame( + '2026-08-08T20:33:08+00:00', + $reader->requiredDateTime('created_at')->format(\DateTimeInterface::ATOM), + ); } public function testMissingAndNullValuesAreTolerated(): void @@ -113,6 +120,30 @@ public static function invalidDateTimeProvider(): iterable yield 'invalid calendar date' => ['2026-02-31T12:00:00Z']; } + #[DataProvider('missingRequiredValueProvider')] + public function testItRejectsMissingRequiredValues( + string $method, + string $expectedType, + ): void { + $reader = PayloadReader::from([], 'Weather'); + + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + 'Cannot hydrate Weather: "value" expected %s, null received.', + $expectedType, + )); + + $reader->{$method}('value'); + } + + public static function missingRequiredValueProvider(): iterable + { + yield 'string' => ['requiredString', 'string']; + yield 'integer' => ['requiredInt', 'int']; + yield 'float' => ['requiredFloat', 'int|float']; + yield 'date and time' => ['requiredDateTime', 'ISO 8601 date-time string']; + } + #[DataProvider('invalidValueProvider')] public function testItRejectsKnownFieldsWithInvalidTypes( string $method, From 4fdc8be9539cebfc71fedc7453dce2a3433db3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 11:36:16 +0100 Subject: [PATCH 085/113] feat(stations): add station creation --- docs/stations.md | 31 +++++++-- src/Resource/Stations.php | 30 +++++++++ src/Validation/Assert.php | 12 ++++ tests/Unit/Resource/StationsTest.php | 96 ++++++++++++++++++++++++++++ tests/Unit/Validation/AssertTest.php | 29 +++++++++ 5 files changed, 192 insertions(+), 6 deletions(-) diff --git a/docs/stations.md b/docs/stations.md index ebc05ac..fc79763 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -1,21 +1,40 @@ # Weather Stations -Weather Stations API 3.0 manages personal weather stations associated with an -OpenWeather account and is available on OpenWeather's standard free and paid -subscriptions. See the +The Weather Stations API lets you register and manage personal weather stations +associated with your OpenWeather account. + +It is available on OpenWeather's standard free and paid subscriptions. See the [official Weather Stations documentation](https://openweathermap.org/api/stations) for API details. -## List Stations +## Create A Station -Use `all()` to retrieve every station associated with the authenticated API -key. +Use `create()` to register a station with its external ID, name, coordinates, +and altitude. ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; $api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); +$station = $api->stations()->create( + externalId: 'home-station', + name: 'Home Weather Station', + latitude: 38.7223, + longitude: -9.1393, + altitude: 100, +); +``` + +The method returns the created `Station`. OpenWeather assigns its internal ID, +rank, user ID, source type, and creation and update times. + +## List Stations + +Use `all()` to retrieve every station associated with the authenticated API +key. + +```php $stations = $api->stations()->all(); ``` diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index 54c2952..d1301ec 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -8,6 +8,36 @@ final class Stations extends Resource { + public function create( + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + ): Station { + $externalId = Assert::notBlank($externalId, 'external station ID'); + $name = Assert::notBlank($name, 'station name'); + $latitude = Assert::latitude($latitude); + $longitude = Assert::longitude($longitude); + $altitude = Assert::finiteNumber($altitude, 'station altitude'); + + // https://openweathermap.org/api/stations + /** @var Station $station */ + $station = $this + ->endpoint() + ->json([ + 'external_id' => $externalId, + 'name' => $name, + 'latitude' => $latitude, + 'longitude' => $longitude, + 'altitude' => $altitude, + ]) + ->post('/data/3.0/stations') + ->entity(Station::class); + + return $station; + } + /** * @return list */ diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index 38ff559..f95d4ec 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -42,6 +42,18 @@ public static function longitude(float $longitude): float return $longitude; } + public static function finiteNumber(float $value, string $name): float + { + if (!is_finite($value)) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be a finite number.', + $name, + )); + } + + return $value; + } + public static function countryCode(string $countryCode): string { $countryCode = strtoupper(trim($countryCode)); diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 223e493..c4a9ac5 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -2,11 +2,42 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Resource; +use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; final class StationsTest extends ApiTestCase { + public function testCreatesAStation(): void + { + $this->respondWithFixture('stations/register.json'); + + $station = $this->api->stations()->create( + externalId: ' openweathermap-php-api-fixture ', + name: ' OpenWeatherMap PHP API Fixture ', + latitude: 38.7223, + longitude: -9.1393, + altitude: 100, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('6a779284adde3b0001343e02', $station->id()); + self::assertSame('user-fixture', $station->userId()); + self::assertSame(5, $station->sourceType()); + self::assertSame('POST', $request->getMethod()); + self::assertSame('/data/3.0/stations', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([ + 'external_id' => 'openweathermap-php-api-fixture', + 'name' => 'OpenWeatherMap PHP API Fixture', + 'latitude' => 38.7223, + 'longitude' => -9.1393, + 'altitude' => 100, + ], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + public function testListsStations(): void { $this->respondWithFixture('stations/list.json'); @@ -48,4 +79,69 @@ public function testRejectsABlankStationIdentifier(): void $this->api->stations()->find(' '); } + + #[DataProvider('invalidCreationArguments')] + public function testRejectsInvalidCreationArguments( + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->stations()->create( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + ); + } + + public static function invalidCreationArguments(): iterable + { + yield 'blank external identifier' => [ + ' ', + 'Station', + 0, + 0, + 0, + 'The external station ID must be a non-empty string.', + ]; + yield 'blank name' => [ + 'station', + ' ', + 0, + 0, + 0, + 'The station name must be a non-empty string.', + ]; + yield 'invalid latitude' => [ + 'station', + 'Station', + 90.0001, + 0, + 0, + 'Latitude must be a finite number between -90 and 90.', + ]; + yield 'invalid longitude' => [ + 'station', + 'Station', + 0, + 180.0001, + 0, + 'Longitude must be a finite number between -180 and 180.', + ]; + yield 'non-finite altitude' => [ + 'station', + 'Station', + 0, + 0, + INF, + 'The station altitude must be a finite number.', + ]; + } } diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php index 94cfa99..dd46b66 100644 --- a/tests/Unit/Validation/AssertTest.php +++ b/tests/Unit/Validation/AssertTest.php @@ -8,6 +8,35 @@ final class AssertTest extends TestCase { + #[DataProvider('finiteNumbers')] + public function testItAcceptsFiniteNumbers(float $value): void + { + self::assertSame($value, Assert::finiteNumber($value, 'value')); + } + + public static function finiteNumbers(): iterable + { + yield 'negative' => [-10.5]; + yield 'zero' => [0.0]; + yield 'positive' => [10.5]; + } + + #[DataProvider('nonFiniteNumbers')] + public function testItRejectsNonFiniteNumbers(float $value): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The value must be a finite number.'); + + Assert::finiteNumber($value, 'value'); + } + + public static function nonFiniteNumbers(): iterable + { + yield 'negative infinity' => [-INF]; + yield 'positive infinity' => [INF]; + yield 'not a number' => [NAN]; + } + #[DataProvider('nonNegativeIntegers')] public function testItAcceptsNonNegativeIntegers(int $value): void { From 5322dbc199977f260f8ffcb8a922f617e31eead5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 11:48:20 +0100 Subject: [PATCH 086/113] feat(stations): add station updates --- docs/stations.md | 19 ++++++++ src/Resource/Stations.php | 72 +++++++++++++++++++++++----- tests/Unit/Resource/StationsTest.php | 49 +++++++++++++++++++ 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/docs/stations.md b/docs/stations.md index fc79763..9852e43 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -29,6 +29,25 @@ $station = $api->stations()->create( The method returns the created `Station`. OpenWeather assigns its internal ID, rank, user ID, source type, and creation and update times. +## Update A Station + +Use `update()` with the internal station ID and the complete editable station +information. + +```php +$station = $api->stations()->update( + id: 'station-id', + externalId: 'home-station', + name: 'Home Weather Station', + latitude: 38.7223, + longitude: -9.1393, + altitude: 110, +); +``` + +OpenWeather does not support partial station updates, so all five station values +are required. + ## List Stations Use `all()` to retrieve every station associated with the authenticated API diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index d1301ec..6748bf8 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -15,24 +15,47 @@ public function create( float $longitude, float $altitude, ): Station { - $externalId = Assert::notBlank($externalId, 'external station ID'); - $name = Assert::notBlank($name, 'station name'); - $latitude = Assert::latitude($latitude); - $longitude = Assert::longitude($longitude); - $altitude = Assert::finiteNumber($altitude, 'station altitude'); + // https://openweathermap.org/api/stations + /** @var Station $station */ + $station = $this + ->endpoint() + ->json(self::stationPayload( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + )) + ->post('/data/3.0/stations') + ->entity(Station::class); + + return $station; + } + + public function update( + string $id, + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + ): Station { + $id = Assert::notBlank($id, 'station ID'); // https://openweathermap.org/api/stations /** @var Station $station */ $station = $this ->endpoint() - ->json([ - 'external_id' => $externalId, - 'name' => $name, - 'latitude' => $latitude, - 'longitude' => $longitude, - 'altitude' => $altitude, + ->json(self::stationPayload( + $externalId, + $name, + $latitude, + $longitude, + $altitude, + )) + ->put('/data/3.0/stations/{id}', [ + 'id' => $id, ]) - ->post('/data/3.0/stations') ->entity(Station::class); return $station; @@ -65,4 +88,29 @@ public function find(string $id): Station return $station; } + + /** + * @return array{ + * external_id: string, + * name: string, + * latitude: float, + * longitude: float, + * altitude: float + * } + */ + private static function stationPayload( + string $externalId, + string $name, + float $latitude, + float $longitude, + float $altitude, + ): array { + return [ + 'external_id' => Assert::notBlank($externalId, 'external station ID'), + 'name' => Assert::notBlank($name, 'station name'), + 'latitude' => Assert::latitude($latitude), + 'longitude' => Assert::longitude($longitude), + 'altitude' => Assert::finiteNumber($altitude, 'station altitude'), + ]; + } } diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index c4a9ac5..2f203e1 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -53,6 +53,38 @@ public function testListsStations(): void self::assertSame(['appid' => 'api-key'], $this->query($request)); } + public function testUpdatesAStation(): void + { + $this->respondWithFixture('stations/update.json'); + + $station = $this->api->stations()->update( + id: ' 6a779284adde3b0001343e02 ', + externalId: ' openweathermap-php-api-fixture-updated ', + name: ' Updated OpenWeatherMap PHP API Fixture ', + latitude: 38.72, + longitude: -9.14, + altitude: 110, + ); + $request = $this->client->getLastRequest(); + + self::assertInstanceOf(Station::class, $station); + self::assertSame('openweathermap-php-api-fixture-updated', $station->externalId()); + self::assertSame('PUT', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([ + 'external_id' => 'openweathermap-php-api-fixture-updated', + 'name' => 'Updated OpenWeatherMap PHP API Fixture', + 'latitude' => 38.72, + 'longitude' => -9.14, + 'altitude' => 110, + ], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + public function testFindsAStation(): void { $this->respondWithFixture('stations/retrieve.json'); @@ -80,6 +112,23 @@ public function testRejectsABlankStationIdentifier(): void $this->api->stations()->find(' '); } + public function testRejectsABlankStationIdentifierWhenUpdating(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->update( + id: ' ', + externalId: 'station', + name: 'Station', + latitude: 0, + longitude: 0, + altitude: 0, + ); + } + #[DataProvider('invalidCreationArguments')] public function testRejectsInvalidCreationArguments( string $externalId, From dfddcd4589a8f2db5b0bacaa00b7f47cfd992c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 11:54:10 +0100 Subject: [PATCH 087/113] feat(stations): add station deletion --- docs/stations.md | 11 +++++++++++ src/Resource/Stations.php | 12 ++++++++++++ tests/Support/ApiTestCase.php | 10 ++++++++-- tests/Unit/Resource/StationsTest.php | 28 +++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/stations.md b/docs/stations.md index 9852e43..3ecd67d 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -88,3 +88,14 @@ $station = $api->stations()->find('station-id'); echo $station->name(); ``` + +## Delete A Station + +> **Warning:** Deleting a station also permanently deletes its associated +> measurements. + +Use `delete()` with the internal station ID returned by OpenWeather. + +```php +$api->stations()->delete('station-id'); +``` diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index 6748bf8..e82074e 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -89,6 +89,18 @@ public function find(string $id): Station return $station; } + public function delete(string $id): void + { + $id = Assert::notBlank($id, 'station ID'); + + // https://openweathermap.org/api/stations + $this + ->endpoint() + ->delete('/data/3.0/stations/{id}', [ + 'id' => $id, + ]); + } + /** * @return array{ * external_id: string, diff --git a/tests/Support/ApiTestCase.php b/tests/Support/ApiTestCase.php index 43b2c15..84180de 100644 --- a/tests/Support/ApiTestCase.php +++ b/tests/Support/ApiTestCase.php @@ -23,9 +23,15 @@ protected function setUp(): void $this->api->setup()->client($this->client); } - protected function respondWithFixture(string $path): void + protected function respondWithFixture( + string $path, + int $status = 200, + ): void { - $this->client->addResponse(new Response(body: Fixture::contents($path))); + $this->client->addResponse(new Response( + status: $status, + body: Fixture::contents($path), + )); } protected function query(RequestInterface $request): array diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 2f203e1..2fd91ff 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -10,7 +10,7 @@ final class StationsTest extends ApiTestCase { public function testCreatesAStation(): void { - $this->respondWithFixture('stations/register.json'); + $this->respondWithFixture('stations/register.json', status: 201); $station = $this->api->stations()->create( externalId: ' openweathermap-php-api-fixture ', @@ -129,6 +129,32 @@ public function testRejectsABlankStationIdentifierWhenUpdating(): void ); } + public function testDeletesAStation(): void + { + $this->respondWithFixture('stations/delete.empty', status: 204); + + $this->api->stations()->delete(' 6a779284adde3b0001343e02 '); + $request = $this->client->getLastRequest(); + + self::assertSame('DELETE', $request->getMethod()); + self::assertSame( + '/data/3.0/stations/6a779284adde3b0001343e02', + $request->getUri()->getPath(), + ); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('', (string) $request->getBody()); + } + + public function testRejectsABlankStationIdentifierWhenDeleting(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->delete(' '); + } + #[DataProvider('invalidCreationArguments')] public function testRejectsInvalidCreationArguments( string $externalId, From ad9da64136b8a5bc1c45b62de654956d1c0558f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 13:07:21 +0100 Subject: [PATCH 088/113] feat(stations): add measurement submission --- docs/stations.md | 36 +++ src/Request/Stations/Measurement.php | 237 ++++++++++++++++++ src/Resource/Stations.php | 30 +++ src/Validation/Assert.php | 77 ++++++ .../Unit/Request/Stations/MeasurementTest.php | 148 +++++++++++ tests/Unit/Resource/StationsTest.php | 129 ++++++++++ tests/Unit/Validation/AssertTest.php | 65 +++++ 7 files changed, 722 insertions(+) create mode 100644 src/Request/Stations/Measurement.php create mode 100644 tests/Unit/Request/Stations/MeasurementTest.php diff --git a/docs/stations.md b/docs/stations.md index 3ecd67d..f70a989 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -99,3 +99,39 @@ Use `delete()` with the internal station ID returned by OpenWeather. ```php $api->stations()->delete('station-id'); ``` + +## Submit Measurements + +Create a `Measurement` with the station ID, observation time, and available +readings, then submit it to OpenWeather. + +```php +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; + +$measurement = new Measurement( + stationId: $station->id(), + dateTime: new DateTimeImmutable('now'), + temperature: 19.5, + windSpeed: 2.4, + windDirection: 180, + pressure: 1012, + humidity: 68, +); + +$api->stations()->submitMeasurement($measurement); +``` + +Use `submitMeasurements()` to send several observations in one request. + +```php +$api->stations()->submitMeasurements([ + $firstMeasurement, + $secondMeasurement, +]); +``` + +Measurement units are fixed by the Weather Stations API: Celsius for +temperatures, metres per second for wind, degrees for wind direction, +hectopascals for pressure, percent for humidity, millimetres for rain and snow, +and kilometres for visibility. The API-wide units configuration does not alter +submitted values. diff --git a/src/Request/Stations/Measurement.php b/src/Request/Stations/Measurement.php new file mode 100644 index 0000000..0316105 --- /dev/null +++ b/src/Request/Stations/Measurement.php @@ -0,0 +1,237 @@ +stationId = Assert::notBlank($stationId, 'station ID'); + $this->dateTime = \DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new \DateTimeZone('UTC')); + $this->temperature = Assert::nullableFiniteNumber($temperature, 'temperature'); + $this->windSpeed = Assert::nullableFiniteNumber($windSpeed, 'wind speed'); + $this->windGust = Assert::nullableFiniteNumber($windGust, 'wind gust'); + $this->windDirection = $windDirection === null + ? null + : Assert::integerBetween($windDirection, 0, 360, 'wind direction'); + $this->pressure = Assert::nullableFiniteNumber($pressure, 'pressure'); + $this->humidity = Assert::nullableFiniteNumber($humidity, 'humidity'); + $this->rainLastHour = Assert::nullableFiniteNumber( + $rainLastHour, + 'one-hour rainfall', + ); + $this->rainLastSixHours = Assert::nullableFiniteNumber( + $rainLastSixHours, + 'six-hour rainfall', + ); + $this->rainLastTwentyFourHours = Assert::nullableFiniteNumber( + $rainLastTwentyFourHours, + '24-hour rainfall', + ); + $this->snowLastHour = Assert::nullableFiniteNumber( + $snowLastHour, + 'one-hour snowfall', + ); + $this->snowLastSixHours = Assert::nullableFiniteNumber( + $snowLastSixHours, + 'six-hour snowfall', + ); + $this->snowLastTwentyFourHours = Assert::nullableFiniteNumber( + $snowLastTwentyFourHours, + '24-hour snowfall', + ); + $this->dewPoint = Assert::nullableFiniteNumber($dewPoint, 'dew point'); + $this->humidex = Assert::nullableFiniteNumber($humidex, 'humidex'); + $this->heatIndex = Assert::nullableFiniteNumber($heatIndex, 'heat index'); + $this->visibilityDistance = Assert::nullableFiniteNumber( + $visibilityDistance, + 'visibility distance', + ); + $this->visibilityPrefix = $visibilityPrefix === null + ? null + : Assert::notBlank($visibilityPrefix, 'visibility prefix'); + } + + public function stationId(): string + { + return $this->stationId; + } + + public function dateTime(): \DateTimeImmutable + { + return $this->dateTime; + } + + public function temperature(): ?float + { + return $this->temperature; + } + + public function windSpeed(): ?float + { + return $this->windSpeed; + } + + public function windGust(): ?float + { + return $this->windGust; + } + + public function windDirection(): ?int + { + return $this->windDirection; + } + + public function pressure(): ?float + { + return $this->pressure; + } + + public function humidity(): ?float + { + return $this->humidity; + } + + public function rainLastHour(): ?float + { + return $this->rainLastHour; + } + + public function rainLastSixHours(): ?float + { + return $this->rainLastSixHours; + } + + public function rainLastTwentyFourHours(): ?float + { + return $this->rainLastTwentyFourHours; + } + + public function snowLastHour(): ?float + { + return $this->snowLastHour; + } + + public function snowLastSixHours(): ?float + { + return $this->snowLastSixHours; + } + + public function snowLastTwentyFourHours(): ?float + { + return $this->snowLastTwentyFourHours; + } + + public function dewPoint(): ?float + { + return $this->dewPoint; + } + + public function humidex(): ?float + { + return $this->humidex; + } + + public function heatIndex(): ?float + { + return $this->heatIndex; + } + + public function visibilityDistance(): ?float + { + return $this->visibilityDistance; + } + + public function visibilityPrefix(): ?string + { + return $this->visibilityPrefix; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'station_id' => $this->stationId, + 'dt' => $this->dateTime->getTimestamp(), + 'temperature' => $this->temperature, + 'wind_speed' => $this->windSpeed, + 'wind_gust' => $this->windGust, + 'wind_deg' => $this->windDirection, + 'pressure' => $this->pressure, + 'humidity' => $this->humidity, + 'rain_1h' => $this->rainLastHour, + 'rain_6h' => $this->rainLastSixHours, + 'rain_24h' => $this->rainLastTwentyFourHours, + 'snow_1h' => $this->snowLastHour, + 'snow_6h' => $this->snowLastSixHours, + 'snow_24h' => $this->snowLastTwentyFourHours, + 'dew_point' => $this->dewPoint, + 'humidex' => $this->humidex, + 'heat_index' => $this->heatIndex, + 'visibility_distance' => $this->visibilityDistance, + 'visibility_prefix' => $this->visibilityPrefix, + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index e82074e..edf60f2 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -4,6 +4,7 @@ use ProgrammatorDev\Api\Resource; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; final class Stations extends Resource @@ -101,6 +102,35 @@ public function delete(string $id): void ]); } + public function submitMeasurement(Measurement $measurement): void + { + $this->submitMeasurements([$measurement]); + } + + /** + * @param list $measurements + */ + public function submitMeasurements(array $measurements): void + { + $measurements = Assert::notEmpty($measurements, 'station measurements'); + $measurements = Assert::allInstancesOf( + $measurements, + Measurement::class, + 'station measurement', + ); + $payload = []; + + foreach ($measurements as $measurement) { + $payload[] = $measurement->toArray(); + } + + // https://openweathermap.org/api/stations#measurement + $this + ->endpoint() + ->json($payload) + ->post('/data/3.0/measurements'); + } + /** * @return array{ * external_id: string, diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index f95d4ec..519fd37 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -6,6 +6,74 @@ final class Assert { private function __construct() {} + /** + * @template T + * + * @param array $values + * + * @return non-empty-array + */ + public static function notEmpty(array $values, string $name): array + { + if ($values === []) { + throw new \InvalidArgumentException(sprintf( + 'The %s must not be empty.', + $name, + )); + } + + return $values; + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return T + */ + public static function isInstanceOf( + mixed $value, + string $class, + string $name, + ): object { + if (!$value instanceof $class) { + throw new \InvalidArgumentException(sprintf( + 'The %s must be an instance of %s.', + $name, + $class, + )); + } + + return $value; + } + + /** + * @template T of object + * + * @param array $values + * @param class-string $class + * + * @return array + */ + public static function allInstancesOf( + array $values, + string $class, + string $name, + ): array { + $instances = []; + + foreach ($values as $index => $value) { + $instances[$index] = self::isInstanceOf( + $value, + $class, + sprintf('%s at index %s', $name, $index), + ); + } + + return $instances; + } + public static function notBlank(string $value, string $name): string { $value = trim($value); @@ -54,6 +122,15 @@ public static function finiteNumber(float $value, string $name): float return $value; } + public static function nullableFiniteNumber( + ?float $value, + string $name, + ): ?float { + return $value === null + ? null + : self::finiteNumber($value, $name); + } + public static function countryCode(string $countryCode): string { $countryCode = strtoupper(trim($countryCode)); diff --git a/tests/Unit/Request/Stations/MeasurementTest.php b/tests/Unit/Request/Stations/MeasurementTest.php new file mode 100644 index 0000000..83cdd4f --- /dev/null +++ b/tests/Unit/Request/Stations/MeasurementTest.php @@ -0,0 +1,148 @@ +stationId()); + self::assertSame('UTC', $measurement->dateTime()->getTimezone()->getName()); + self::assertSame(19.5, $measurement->temperature()); + self::assertSame(2.4, $measurement->windSpeed()); + self::assertSame(4.1, $measurement->windGust()); + self::assertSame(180, $measurement->windDirection()); + self::assertSame(1012.0, $measurement->pressure()); + self::assertSame(68.0, $measurement->humidity()); + self::assertSame(0.2, $measurement->rainLastHour()); + self::assertSame(0.4, $measurement->rainLastSixHours()); + self::assertSame(0.6, $measurement->rainLastTwentyFourHours()); + self::assertSame(0.0, $measurement->snowLastHour()); + self::assertSame(0.1, $measurement->snowLastSixHours()); + self::assertSame(0.3, $measurement->snowLastTwentyFourHours()); + self::assertSame(12.5, $measurement->dewPoint()); + self::assertSame(20.1, $measurement->humidex()); + self::assertSame(19.8, $measurement->heatIndex()); + self::assertSame(10.0, $measurement->visibilityDistance()); + self::assertSame('N', $measurement->visibilityPrefix()); + self::assertSame([ + 'station_id' => 'station-id', + 'dt' => 1786224150, + 'temperature' => 19.5, + 'wind_speed' => 2.4, + 'wind_gust' => 4.1, + 'wind_deg' => 180, + 'pressure' => 1012.0, + 'humidity' => 68.0, + 'rain_1h' => 0.2, + 'rain_6h' => 0.4, + 'rain_24h' => 0.6, + 'snow_1h' => 0.0, + 'snow_6h' => 0.1, + 'snow_24h' => 0.3, + 'dew_point' => 12.5, + 'humidex' => 20.1, + 'heat_index' => 19.8, + 'visibility_distance' => 10.0, + 'visibility_prefix' => 'N', + ], $measurement->toArray()); + } + + public function testOmitsUnavailableScalarMeasurements(): void + { + $measurement = new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable('@1786231350'), + ); + + self::assertSame([ + 'station_id' => 'station-id', + 'dt' => 1786231350, + ], $measurement->toArray()); + } + + public function testRejectsABlankStationIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + new Measurement(' ', new \DateTimeImmutable()); + } + + #[DataProvider('invalidWindDirections')] + public function testRejectsAnInvalidWindDirection(int $windDirection): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The wind direction must be between 0 and 360.', + ); + + new Measurement( + 'station-id', + new \DateTimeImmutable(), + windDirection: $windDirection, + ); + } + + public static function invalidWindDirections(): iterable + { + yield 'below zero' => [-1]; + yield 'above 360' => [361]; + } + + public function testRejectsANonFiniteScalarMeasurement(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The temperature must be a finite number.', + ); + + new Measurement( + 'station-id', + new \DateTimeImmutable(), + temperature: INF, + ); + } + + public function testRejectsABlankVisibilityPrefix(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The visibility prefix must be a non-empty string.', + ); + + new Measurement( + 'station-id', + new \DateTimeImmutable(), + visibilityPrefix: ' ', + ); + } +} diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 2fd91ff..1d5f743 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; final class StationsTest extends ApiTestCase @@ -155,6 +156,134 @@ public function testRejectsABlankStationIdentifierWhenDeleting(): void $this->api->stations()->delete(' '); } + public function testSubmitsAMeasurement(): void + { + $this->respondWithFixture('stations/measurements/submit.empty', status: 204); + + $this->api->stations()->submitMeasurement(new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable('@1786231350'), + temperature: 19.5, + )); + $request = $this->client->getLastRequest(); + + self::assertSame('POST', $request->getMethod()); + self::assertSame('/data/3.0/measurements', $request->getUri()->getPath()); + self::assertSame(['appid' => 'api-key'], $this->query($request)); + self::assertSame('application/json', $request->getHeaderLine('Content-Type')); + self::assertSame([[ + 'station_id' => 'station-id', + 'dt' => 1786231350, + 'temperature' => 19.5, + ]], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testSubmitsMultipleMeasurements(): void + { + $this->respondWithFixture('stations/measurements/submit.empty', status: 204); + + $measurements = [ + new Measurement( + stationId: '6a77b80aadde3b0001343e08', + dateTime: new \DateTimeImmutable('@1786143600'), + temperature: 19.5, + windSpeed: 2.4, + windGust: 4.1, + windDirection: 180, + pressure: 1012, + humidity: 68, + rainLastHour: 0.2, + ), + new Measurement( + stationId: '6a77b80aadde3b0001343e08', + dateTime: new \DateTimeImmutable('@1786228200'), + temperature: 20.5, + windSpeed: 3.2, + windGust: 5.3, + windDirection: 200, + pressure: 1013, + humidity: 64, + rainLastHour: 0.4, + ), + new Measurement( + stationId: '6a77b80aadde3b0001343e08', + dateTime: new \DateTimeImmutable('@1786230720'), + temperature: 21.5, + windSpeed: 4, + windGust: 6.5, + windDirection: 220, + pressure: 1014, + humidity: 60, + rainLastHour: 0.6, + ), + ]; + + $this->api->stations()->submitMeasurements($measurements); + $request = $this->client->getLastRequest(); + + self::assertSame( + [ + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786143600, + 'temperature' => 19.5, + 'wind_speed' => 2.4, + 'wind_gust' => 4.1, + 'wind_deg' => 180, + 'pressure' => 1012, + 'humidity' => 68, + 'rain_1h' => 0.2, + ], + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786228200, + 'temperature' => 20.5, + 'wind_speed' => 3.2, + 'wind_gust' => 5.3, + 'wind_deg' => 200, + 'pressure' => 1013, + 'humidity' => 64, + 'rain_1h' => 0.4, + ], + [ + 'station_id' => '6a77b80aadde3b0001343e08', + 'dt' => 1786230720, + 'temperature' => 21.5, + 'wind_speed' => 4, + 'wind_gust' => 6.5, + 'wind_deg' => 220, + 'pressure' => 1014, + 'humidity' => 60, + 'rain_1h' => 0.6, + ], + ], + json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR), + ); + } + + public function testRejectsAnEmptyMeasurementBatch(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station measurements must not be empty.', + ); + + $this->api->stations()->submitMeasurements([]); + } + + public function testRejectsAnInvalidMeasurementBatchItem(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station measurement at index 1 must be an instance of', + ); + + $this->api->stations()->submitMeasurements([ + new Measurement('station-id', new \DateTimeImmutable()), + 'invalid', + ]); + } + #[DataProvider('invalidCreationArguments')] public function testRejectsInvalidCreationArguments( string $externalId, diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php index dd46b66..a98bc98 100644 --- a/tests/Unit/Validation/AssertTest.php +++ b/tests/Unit/Validation/AssertTest.php @@ -8,6 +8,65 @@ final class AssertTest extends TestCase { + public function testItAcceptsANonEmptyArray(): void + { + $values = ['value']; + + self::assertSame($values, Assert::notEmpty($values, 'values')); + } + + public function testItRejectsAnEmptyArray(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The values must not be empty.'); + + Assert::notEmpty([], 'values'); + } + + public function testItAcceptsAnInstanceOfAClass(): void + { + $value = new \stdClass(); + + self::assertSame( + $value, + Assert::isInstanceOf($value, \stdClass::class, 'value'), + ); + } + + public function testItRejectsAValueThatIsNotAnInstanceOfAClass(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The value must be an instance of stdClass.', + ); + + Assert::isInstanceOf('value', \stdClass::class, 'value'); + } + + public function testItAcceptsAnArrayContainingOnlyInstancesOfAClass(): void + { + $values = [new \stdClass(), new \stdClass()]; + + self::assertSame( + $values, + Assert::allInstancesOf($values, \stdClass::class, 'value'), + ); + } + + public function testItRejectsAnArrayContainingAnotherType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The value at index 1 must be an instance of stdClass.', + ); + + Assert::allInstancesOf( + [new \stdClass(), 'value'], + \stdClass::class, + 'value', + ); + } + #[DataProvider('finiteNumbers')] public function testItAcceptsFiniteNumbers(float $value): void { @@ -37,6 +96,12 @@ public static function nonFiniteNumbers(): iterable yield 'not a number' => [NAN]; } + public function testItPreservesANullableFiniteNumber(): void + { + self::assertNull(Assert::nullableFiniteNumber(null, 'value')); + self::assertSame(10.5, Assert::nullableFiniteNumber(10.5, 'value')); + } + #[DataProvider('nonNegativeIntegers')] public function testItAcceptsNonNegativeIntegers(int $value): void { From 35e35f5d15302afebb6be777990f00340566d9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 13:22:36 +0100 Subject: [PATCH 089/113] feat(stations): add cloud layer measurements --- docs/stations.md | 16 +++- src/Request/Stations/CloudLayer.php | 62 ++++++++++++++ src/Request/Stations/Measurement.php | 37 +++++++- src/Validation/Assert.php | 9 ++ .../Unit/Request/Stations/CloudLayerTest.php | 84 +++++++++++++++++++ .../Unit/Request/Stations/MeasurementTest.php | 42 ++++++++++ tests/Unit/Resource/StationsTest.php | 3 + tests/Unit/Validation/AssertTest.php | 6 ++ 8 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 src/Request/Stations/CloudLayer.php create mode 100644 tests/Unit/Request/Stations/CloudLayerTest.php diff --git a/docs/stations.md b/docs/stations.md index f70a989..c18ecbe 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -106,6 +106,7 @@ Create a `Measurement` with the station ID, observation time, and available readings, then submit it to OpenWeather. ```php +use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; $measurement = new Measurement( @@ -116,6 +117,13 @@ $measurement = new Measurement( windDirection: 180, pressure: 1012, humidity: 68, + clouds: [ + new CloudLayer( + distance: 1200, + condition: 'BKN', + cumulus: 'CB', + ), + ], ); $api->stations()->submitMeasurement($measurement); @@ -133,5 +141,9 @@ $api->stations()->submitMeasurements([ Measurement units are fixed by the Weather Stations API: Celsius for temperatures, metres per second for wind, degrees for wind direction, hectopascals for pressure, percent for humidity, millimetres for rain and snow, -and kilometres for visibility. The API-wide units configuration does not alter -submitted values. +kilometres for visibility, and metres for cloud-layer distance. The API-wide +units configuration does not alter submitted values. + +Each `CloudLayer` represents one entry in OpenWeather's `clouds` array. Its +distance, METAR cloud condition, and cumulus type are optional, but at least one +value must be provided. diff --git a/src/Request/Stations/CloudLayer.php b/src/Request/Stations/CloudLayer.php new file mode 100644 index 0000000..3445a22 --- /dev/null +++ b/src/Request/Stations/CloudLayer.php @@ -0,0 +1,62 @@ +distance = Assert::nullableFiniteNumber( + $distance, + 'cloud layer distance', + ); + $this->condition = Assert::nullableNotBlank( + $condition, + 'cloud layer condition', + ); + $this->cumulus = Assert::nullableNotBlank( + $cumulus, + 'cloud layer cumulus type', + ); + + Assert::notEmpty($this->toArray(), 'cloud layer values'); + } + + public function distance(): ?float + { + return $this->distance; + } + + public function condition(): ?string + { + return $this->condition; + } + + public function cumulus(): ?string + { + return $this->cumulus; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'distance' => $this->distance, + 'condition' => $this->condition, + 'cumulus' => $this->cumulus, + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Request/Stations/Measurement.php b/src/Request/Stations/Measurement.php index 0316105..5b4ac72 100644 --- a/src/Request/Stations/Measurement.php +++ b/src/Request/Stations/Measurement.php @@ -44,6 +44,14 @@ final class Measurement private readonly ?string $visibilityPrefix; + /** + * @var list + */ + private readonly array $clouds; + + /** + * @param list $clouds + */ public function __construct( string $stationId, \DateTimeInterface $dateTime, @@ -64,6 +72,7 @@ public function __construct( ?float $heatIndex = null, ?float $visibilityDistance = null, ?string $visibilityPrefix = null, + array $clouds = [], ) { $this->stationId = Assert::notBlank($stationId, 'station ID'); $this->dateTime = \DateTimeImmutable::createFromInterface($dateTime) @@ -107,9 +116,15 @@ public function __construct( $visibilityDistance, 'visibility distance', ); - $this->visibilityPrefix = $visibilityPrefix === null - ? null - : Assert::notBlank($visibilityPrefix, 'visibility prefix'); + $this->visibilityPrefix = Assert::nullableNotBlank( + $visibilityPrefix, + 'visibility prefix', + ); + $this->clouds = array_values(Assert::allInstancesOf( + $clouds, + CloudLayer::class, + 'cloud layer', + )); } public function stationId(): string @@ -208,7 +223,15 @@ public function visibilityPrefix(): ?string } /** - * @return array + * @return list + */ + public function clouds(): array + { + return $this->clouds; + } + + /** + * @return array>> */ public function toArray(): array { @@ -232,6 +255,12 @@ public function toArray(): array 'heat_index' => $this->heatIndex, 'visibility_distance' => $this->visibilityDistance, 'visibility_prefix' => $this->visibilityPrefix, + 'clouds' => $this->clouds === [] + ? null + : array_map( + static fn(CloudLayer $cloud): array => $cloud->toArray(), + $this->clouds, + ), ], static fn(mixed $value): bool => $value !== null); } } diff --git a/src/Validation/Assert.php b/src/Validation/Assert.php index 519fd37..a6dce70 100644 --- a/src/Validation/Assert.php +++ b/src/Validation/Assert.php @@ -88,6 +88,15 @@ public static function notBlank(string $value, string $name): string return $value; } + public static function nullableNotBlank( + ?string $value, + string $name, + ): ?string { + return $value === null + ? null + : self::notBlank($value, $name); + } + public static function latitude(float $latitude): float { if (!is_finite($latitude) || $latitude < -90 || $latitude > 90) { diff --git a/tests/Unit/Request/Stations/CloudLayerTest.php b/tests/Unit/Request/Stations/CloudLayerTest.php new file mode 100644 index 0000000..540d235 --- /dev/null +++ b/tests/Unit/Request/Stations/CloudLayerTest.php @@ -0,0 +1,84 @@ +distance()); + self::assertSame('BKN', $cloud->condition()); + self::assertSame('CB', $cloud->cumulus()); + self::assertSame([ + 'distance' => 1200.0, + 'condition' => 'BKN', + 'cumulus' => 'CB', + ], $cloud->toArray()); + } + + public function testOmitsUnavailableCloudLayerValues(): void + { + $cloud = new CloudLayer(condition: 'NSC'); + + self::assertNull($cloud->distance()); + self::assertSame('NSC', $cloud->condition()); + self::assertNull($cloud->cumulus()); + self::assertSame(['condition' => 'NSC'], $cloud->toArray()); + } + + public function testRejectsACloudLayerWithoutValues(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer values must not be empty.', + ); + + new CloudLayer(); + } + + public function testRejectsANonFiniteDistance(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer distance must be a finite number.', + ); + + new CloudLayer(distance: INF); + } + + #[DataProvider('blankStringValues')] + public function testRejectsABlankStringValue( + ?string $condition, + ?string $cumulus, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + new CloudLayer(condition: $condition, cumulus: $cumulus); + } + + public static function blankStringValues(): iterable + { + yield 'condition' => [ + ' ', + null, + 'The cloud layer condition must be a non-empty string.', + ]; + yield 'cumulus' => [ + null, + ' ', + 'The cloud layer cumulus type must be a non-empty string.', + ]; + } +} diff --git a/tests/Unit/Request/Stations/MeasurementTest.php b/tests/Unit/Request/Stations/MeasurementTest.php index 83cdd4f..e8978d7 100644 --- a/tests/Unit/Request/Stations/MeasurementTest.php +++ b/tests/Unit/Request/Stations/MeasurementTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; final class MeasurementTest extends TestCase @@ -87,6 +88,47 @@ public function testOmitsUnavailableScalarMeasurements(): void ], $measurement->toArray()); } + public function testMapsCloudLayers(): void + { + $clouds = [ + new CloudLayer(condition: 'SCT', distance: 800), + new CloudLayer(condition: 'BKN', distance: 1200, cumulus: 'CB'), + ]; + $measurement = new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable('@1786231350'), + clouds: $clouds, + ); + + self::assertSame($clouds, $measurement->clouds()); + self::assertSame([ + 'station_id' => 'station-id', + 'dt' => 1786231350, + 'clouds' => [ + ['distance' => 800.0, 'condition' => 'SCT'], + [ + 'distance' => 1200.0, + 'condition' => 'BKN', + 'cumulus' => 'CB', + ], + ], + ], $measurement->toArray()); + } + + public function testRejectsAnInvalidCloudLayer(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The cloud layer at index 0 must be an instance of', + ); + + new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable(), + clouds: ['invalid'], + ); + } + public function testRejectsABlankStationIdentifier(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 1d5f743..a2e91f5 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -164,6 +165,7 @@ public function testSubmitsAMeasurement(): void stationId: 'station-id', dateTime: new \DateTimeImmutable('@1786231350'), temperature: 19.5, + clouds: [new CloudLayer(condition: 'NSC')], )); $request = $this->client->getLastRequest(); @@ -175,6 +177,7 @@ public function testSubmitsAMeasurement(): void 'station_id' => 'station-id', 'dt' => 1786231350, 'temperature' => 19.5, + 'clouds' => [['condition' => 'NSC']], ]], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); } diff --git a/tests/Unit/Validation/AssertTest.php b/tests/Unit/Validation/AssertTest.php index a98bc98..f95e235 100644 --- a/tests/Unit/Validation/AssertTest.php +++ b/tests/Unit/Validation/AssertTest.php @@ -43,6 +43,12 @@ public function testItRejectsAValueThatIsNotAnInstanceOfAClass(): void Assert::isInstanceOf('value', \stdClass::class, 'value'); } + public function testItPreservesANullableNonBlankString(): void + { + self::assertNull(Assert::nullableNotBlank(null, 'value')); + self::assertSame('value', Assert::nullableNotBlank(' value ', 'value')); + } + public function testItAcceptsAnArrayContainingOnlyInstancesOfAClass(): void { $values = [new \stdClass(), new \stdClass()]; From 4a7e8845540ceb699c873944658d932612c9afae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 13:30:01 +0100 Subject: [PATCH 090/113] feat(stations): add METAR weather inputs --- docs/stations.md | 17 ++++ src/Request/Stations/Measurement.php | 26 +++++ src/Request/Stations/Weather.php | 98 +++++++++++++++++++ .../Unit/Request/Stations/MeasurementTest.php | 38 +++++++ tests/Unit/Request/Stations/WeatherTest.php | 64 ++++++++++++ tests/Unit/Resource/StationsTest.php | 3 + 6 files changed, 246 insertions(+) create mode 100644 src/Request/Stations/Weather.php create mode 100644 tests/Unit/Request/Stations/WeatherTest.php diff --git a/docs/stations.md b/docs/stations.md index c18ecbe..b832e89 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -108,6 +108,7 @@ readings, then submit it to OpenWeather. ```php use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; $measurement = new Measurement( stationId: $station->id(), @@ -124,6 +125,12 @@ $measurement = new Measurement( cumulus: 'CB', ), ], + weather: [ + new Weather( + precipitation: 'RA', + intensity: '-', + ), + ], ); $api->stations()->submitMeasurement($measurement); @@ -144,6 +151,16 @@ hectopascals for pressure, percent for humidity, millimetres for rain and snow, kilometres for visibility, and metres for cloud-layer distance. The API-wide units configuration does not alter submitted values. +Visibility prefixes, cloud conditions, cumulus types, and weather values use +standard METAR codes. See the +[NOAA METAR reference](https://aviationweather.gov/help/data/#metar) for their +meanings. + Each `CloudLayer` represents one entry in OpenWeather's `clouds` array. Its distance, METAR cloud condition, and cumulus type are optional, but at least one value must be provided. + +Each `Weather` represents one entry in the `weather` array. It accepts the +available METAR precipitation, descriptor, intensity, proximity, obscuration, +and other codes. At least one value must be provided, and codes are kept as +strings so additional values accepted by OpenWeather are not restricted. diff --git a/src/Request/Stations/Measurement.php b/src/Request/Stations/Measurement.php index 5b4ac72..cc3a3b7 100644 --- a/src/Request/Stations/Measurement.php +++ b/src/Request/Stations/Measurement.php @@ -49,8 +49,14 @@ final class Measurement */ private readonly array $clouds; + /** + * @var list + */ + private readonly array $weather; + /** * @param list $clouds + * @param list $weather */ public function __construct( string $stationId, @@ -73,6 +79,7 @@ public function __construct( ?float $visibilityDistance = null, ?string $visibilityPrefix = null, array $clouds = [], + array $weather = [], ) { $this->stationId = Assert::notBlank($stationId, 'station ID'); $this->dateTime = \DateTimeImmutable::createFromInterface($dateTime) @@ -125,6 +132,11 @@ public function __construct( CloudLayer::class, 'cloud layer', )); + $this->weather = array_values(Assert::allInstancesOf( + $weather, + Weather::class, + 'weather', + )); } public function stationId(): string @@ -230,6 +242,14 @@ public function clouds(): array return $this->clouds; } + /** + * @return list + */ + public function weather(): array + { + return $this->weather; + } + /** * @return array>> */ @@ -261,6 +281,12 @@ public function toArray(): array static fn(CloudLayer $cloud): array => $cloud->toArray(), $this->clouds, ), + 'weather' => $this->weather === [] + ? null + : array_map( + static fn(Weather $weather): array => $weather->toArray(), + $this->weather, + ), ], static fn(mixed $value): bool => $value !== null); } } diff --git a/src/Request/Stations/Weather.php b/src/Request/Stations/Weather.php new file mode 100644 index 0000000..f273498 --- /dev/null +++ b/src/Request/Stations/Weather.php @@ -0,0 +1,98 @@ +precipitation = Assert::nullableNotBlank( + $precipitation, + 'weather precipitation', + ); + $this->descriptor = Assert::nullableNotBlank( + $descriptor, + 'weather descriptor', + ); + $this->intensity = Assert::nullableNotBlank( + $intensity, + 'weather intensity', + ); + $this->proximity = Assert::nullableNotBlank( + $proximity, + 'weather proximity', + ); + $this->obscuration = Assert::nullableNotBlank( + $obscuration, + 'weather obscuration', + ); + $this->other = Assert::nullableNotBlank($other, 'other weather value'); + + Assert::notEmpty($this->toArray(), 'weather values'); + } + + public function precipitation(): ?string + { + return $this->precipitation; + } + + public function descriptor(): ?string + { + return $this->descriptor; + } + + public function intensity(): ?string + { + return $this->intensity; + } + + public function proximity(): ?string + { + return $this->proximity; + } + + public function obscuration(): ?string + { + return $this->obscuration; + } + + public function other(): ?string + { + return $this->other; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'precipitation' => $this->precipitation, + 'descriptor' => $this->descriptor, + 'intensity' => $this->intensity, + 'proximity' => $this->proximity, + 'obscuration' => $this->obscuration, + 'other' => $this->other, + ], static fn(mixed $value): bool => $value !== null); + } +} diff --git a/tests/Unit/Request/Stations/MeasurementTest.php b/tests/Unit/Request/Stations/MeasurementTest.php index e8978d7..5ae1107 100644 --- a/tests/Unit/Request/Stations/MeasurementTest.php +++ b/tests/Unit/Request/Stations/MeasurementTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; final class MeasurementTest extends TestCase { @@ -129,6 +130,43 @@ public function testRejectsAnInvalidCloudLayer(): void ); } + public function testMapsWeather(): void + { + $weather = [ + new Weather(precipitation: 'RA', intensity: '-'), + new Weather(obscuration: 'FG'), + ]; + $measurement = new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable('@1786231350'), + weather: $weather, + ); + + self::assertSame($weather, $measurement->weather()); + self::assertSame([ + 'station_id' => 'station-id', + 'dt' => 1786231350, + 'weather' => [ + ['precipitation' => 'RA', 'intensity' => '-'], + ['obscuration' => 'FG'], + ], + ], $measurement->toArray()); + } + + public function testRejectsInvalidWeather(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather at index 0 must be an instance of', + ); + + new Measurement( + stationId: 'station-id', + dateTime: new \DateTimeImmutable(), + weather: ['invalid'], + ); + } + public function testRejectsABlankStationIdentifier(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Unit/Request/Stations/WeatherTest.php b/tests/Unit/Request/Stations/WeatherTest.php new file mode 100644 index 0000000..32080c6 --- /dev/null +++ b/tests/Unit/Request/Stations/WeatherTest.php @@ -0,0 +1,64 @@ +precipitation()); + self::assertSame('SH', $weather->descriptor()); + self::assertSame('-', $weather->intensity()); + self::assertSame('VC', $weather->proximity()); + self::assertSame('BR', $weather->obscuration()); + self::assertSame('SQ', $weather->other()); + self::assertSame([ + 'precipitation' => 'RA', + 'descriptor' => 'SH', + 'intensity' => '-', + 'proximity' => 'VC', + 'obscuration' => 'BR', + 'other' => 'SQ', + ], $weather->toArray()); + } + + public function testOmitsUnavailableWeatherValues(): void + { + $weather = new Weather(precipitation: 'SN'); + + self::assertNull($weather->descriptor()); + self::assertSame(['precipitation' => 'SN'], $weather->toArray()); + } + + public function testRejectsWeatherWithoutValues(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather values must not be empty.', + ); + + new Weather(); + } + + public function testRejectsABlankWeatherValue(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The weather precipitation must be a non-empty string.', + ); + + new Weather(precipitation: ' '); + } +} diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index a2e91f5..0cd39de 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -6,6 +6,7 @@ use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; final class StationsTest extends ApiTestCase @@ -166,6 +167,7 @@ public function testSubmitsAMeasurement(): void dateTime: new \DateTimeImmutable('@1786231350'), temperature: 19.5, clouds: [new CloudLayer(condition: 'NSC')], + weather: [new Weather(precipitation: 'RA', intensity: '-')], )); $request = $this->client->getLastRequest(); @@ -178,6 +180,7 @@ public function testSubmitsAMeasurement(): void 'dt' => 1786231350, 'temperature' => 19.5, 'clouds' => [['condition' => 'NSC']], + 'weather' => [['precipitation' => 'RA', 'intensity' => '-']], ]], json_decode((string) $request->getBody(), true, 512, JSON_THROW_ON_ERROR)); } From e72f0414e24c8aa5444ae9807cced99096dabe2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 9 Aug 2026 15:31:08 +0100 Subject: [PATCH 091/113] refactor(stations): separate station ID from measurements --- docs/stations.md | 21 +++++---- src/Request/Stations/Measurement.php | 10 ----- src/Resource/Stations.php | 18 ++++++-- .../Unit/Request/Stations/MeasurementTest.php | 24 ----------- tests/Unit/Resource/StationsTest.php | 43 +++++++++++++------ 5 files changed, 56 insertions(+), 60 deletions(-) diff --git a/docs/stations.md b/docs/stations.md index b832e89..bf58909 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -102,8 +102,8 @@ $api->stations()->delete('station-id'); ## Submit Measurements -Create a `Measurement` with the station ID, observation time, and available -readings, then submit it to OpenWeather. +Create a `Measurement` with the observation time and available readings, then +submit it for a station. ```php use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; @@ -111,7 +111,6 @@ use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; $measurement = new Measurement( - stationId: $station->id(), dateTime: new DateTimeImmutable('now'), temperature: 19.5, windSpeed: 2.4, @@ -133,16 +132,22 @@ $measurement = new Measurement( ], ); -$api->stations()->submitMeasurement($measurement); +$api->stations()->submitMeasurement( + stationId: $station->id(), + measurement: $measurement, +); ``` Use `submitMeasurements()` to send several observations in one request. ```php -$api->stations()->submitMeasurements([ - $firstMeasurement, - $secondMeasurement, -]); +$api->stations()->submitMeasurements( + stationId: $station->id(), + measurements: [ + $firstMeasurement, + $secondMeasurement, + ], +); ``` Measurement units are fixed by the Weather Stations API: Celsius for diff --git a/src/Request/Stations/Measurement.php b/src/Request/Stations/Measurement.php index cc3a3b7..a92efae 100644 --- a/src/Request/Stations/Measurement.php +++ b/src/Request/Stations/Measurement.php @@ -6,8 +6,6 @@ final class Measurement { - private readonly string $stationId; - private readonly \DateTimeImmutable $dateTime; private readonly ?float $temperature; @@ -59,7 +57,6 @@ final class Measurement * @param list $weather */ public function __construct( - string $stationId, \DateTimeInterface $dateTime, ?float $temperature = null, ?float $windSpeed = null, @@ -81,7 +78,6 @@ public function __construct( array $clouds = [], array $weather = [], ) { - $this->stationId = Assert::notBlank($stationId, 'station ID'); $this->dateTime = \DateTimeImmutable::createFromInterface($dateTime) ->setTimezone(new \DateTimeZone('UTC')); $this->temperature = Assert::nullableFiniteNumber($temperature, 'temperature'); @@ -139,11 +135,6 @@ public function __construct( )); } - public function stationId(): string - { - return $this->stationId; - } - public function dateTime(): \DateTimeImmutable { return $this->dateTime; @@ -256,7 +247,6 @@ public function weather(): array public function toArray(): array { return array_filter([ - 'station_id' => $this->stationId, 'dt' => $this->dateTime->getTimestamp(), 'temperature' => $this->temperature, 'wind_speed' => $this->windSpeed, diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index edf60f2..9432620 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -102,16 +102,23 @@ public function delete(string $id): void ]); } - public function submitMeasurement(Measurement $measurement): void + public function submitMeasurement( + string $stationId, + Measurement $measurement, + ): void { - $this->submitMeasurements([$measurement]); + $this->submitMeasurements($stationId, [$measurement]); } /** * @param list $measurements */ - public function submitMeasurements(array $measurements): void + public function submitMeasurements( + string $stationId, + array $measurements, + ): void { + $stationId = Assert::notBlank($stationId, 'station ID'); $measurements = Assert::notEmpty($measurements, 'station measurements'); $measurements = Assert::allInstancesOf( $measurements, @@ -121,7 +128,10 @@ public function submitMeasurements(array $measurements): void $payload = []; foreach ($measurements as $measurement) { - $payload[] = $measurement->toArray(); + $payload[] = [ + 'station_id' => $stationId, + ...$measurement->toArray(), + ]; } // https://openweathermap.org/api/stations#measurement diff --git a/tests/Unit/Request/Stations/MeasurementTest.php b/tests/Unit/Request/Stations/MeasurementTest.php index 5ae1107..2d8da79 100644 --- a/tests/Unit/Request/Stations/MeasurementTest.php +++ b/tests/Unit/Request/Stations/MeasurementTest.php @@ -13,7 +13,6 @@ final class MeasurementTest extends TestCase public function testMapsDocumentedScalarMeasurements(): void { $measurement = new Measurement( - stationId: ' station-id ', dateTime: new \DateTimeImmutable('2026-08-08T23:22:30+02:00'), temperature: 19.5, windSpeed: 2.4, @@ -34,7 +33,6 @@ public function testMapsDocumentedScalarMeasurements(): void visibilityPrefix: ' N ', ); - self::assertSame('station-id', $measurement->stationId()); self::assertSame('UTC', $measurement->dateTime()->getTimezone()->getName()); self::assertSame(19.5, $measurement->temperature()); self::assertSame(2.4, $measurement->windSpeed()); @@ -54,7 +52,6 @@ public function testMapsDocumentedScalarMeasurements(): void self::assertSame(10.0, $measurement->visibilityDistance()); self::assertSame('N', $measurement->visibilityPrefix()); self::assertSame([ - 'station_id' => 'station-id', 'dt' => 1786224150, 'temperature' => 19.5, 'wind_speed' => 2.4, @@ -79,12 +76,10 @@ public function testMapsDocumentedScalarMeasurements(): void public function testOmitsUnavailableScalarMeasurements(): void { $measurement = new Measurement( - stationId: 'station-id', dateTime: new \DateTimeImmutable('@1786231350'), ); self::assertSame([ - 'station_id' => 'station-id', 'dt' => 1786231350, ], $measurement->toArray()); } @@ -96,14 +91,12 @@ public function testMapsCloudLayers(): void new CloudLayer(condition: 'BKN', distance: 1200, cumulus: 'CB'), ]; $measurement = new Measurement( - stationId: 'station-id', dateTime: new \DateTimeImmutable('@1786231350'), clouds: $clouds, ); self::assertSame($clouds, $measurement->clouds()); self::assertSame([ - 'station_id' => 'station-id', 'dt' => 1786231350, 'clouds' => [ ['distance' => 800.0, 'condition' => 'SCT'], @@ -124,7 +117,6 @@ public function testRejectsAnInvalidCloudLayer(): void ); new Measurement( - stationId: 'station-id', dateTime: new \DateTimeImmutable(), clouds: ['invalid'], ); @@ -137,14 +129,12 @@ public function testMapsWeather(): void new Weather(obscuration: 'FG'), ]; $measurement = new Measurement( - stationId: 'station-id', dateTime: new \DateTimeImmutable('@1786231350'), weather: $weather, ); self::assertSame($weather, $measurement->weather()); self::assertSame([ - 'station_id' => 'station-id', 'dt' => 1786231350, 'weather' => [ ['precipitation' => 'RA', 'intensity' => '-'], @@ -161,22 +151,11 @@ public function testRejectsInvalidWeather(): void ); new Measurement( - stationId: 'station-id', dateTime: new \DateTimeImmutable(), weather: ['invalid'], ); } - public function testRejectsABlankStationIdentifier(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The station ID must be a non-empty string.', - ); - - new Measurement(' ', new \DateTimeImmutable()); - } - #[DataProvider('invalidWindDirections')] public function testRejectsAnInvalidWindDirection(int $windDirection): void { @@ -186,7 +165,6 @@ public function testRejectsAnInvalidWindDirection(int $windDirection): void ); new Measurement( - 'station-id', new \DateTimeImmutable(), windDirection: $windDirection, ); @@ -206,7 +184,6 @@ public function testRejectsANonFiniteScalarMeasurement(): void ); new Measurement( - 'station-id', new \DateTimeImmutable(), temperature: INF, ); @@ -220,7 +197,6 @@ public function testRejectsABlankVisibilityPrefix(): void ); new Measurement( - 'station-id', new \DateTimeImmutable(), visibilityPrefix: ' ', ); diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 0cd39de..363f321 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -162,13 +162,15 @@ public function testSubmitsAMeasurement(): void { $this->respondWithFixture('stations/measurements/submit.empty', status: 204); - $this->api->stations()->submitMeasurement(new Measurement( - stationId: 'station-id', - dateTime: new \DateTimeImmutable('@1786231350'), - temperature: 19.5, - clouds: [new CloudLayer(condition: 'NSC')], - weather: [new Weather(precipitation: 'RA', intensity: '-')], - )); + $this->api->stations()->submitMeasurement( + 'station-id', + new Measurement( + dateTime: new \DateTimeImmutable('@1786231350'), + temperature: 19.5, + clouds: [new CloudLayer(condition: 'NSC')], + weather: [new Weather(precipitation: 'RA', intensity: '-')], + ), + ); $request = $this->client->getLastRequest(); self::assertSame('POST', $request->getMethod()); @@ -190,7 +192,6 @@ public function testSubmitsMultipleMeasurements(): void $measurements = [ new Measurement( - stationId: '6a77b80aadde3b0001343e08', dateTime: new \DateTimeImmutable('@1786143600'), temperature: 19.5, windSpeed: 2.4, @@ -201,7 +202,6 @@ public function testSubmitsMultipleMeasurements(): void rainLastHour: 0.2, ), new Measurement( - stationId: '6a77b80aadde3b0001343e08', dateTime: new \DateTimeImmutable('@1786228200'), temperature: 20.5, windSpeed: 3.2, @@ -212,7 +212,6 @@ public function testSubmitsMultipleMeasurements(): void rainLastHour: 0.4, ), new Measurement( - stationId: '6a77b80aadde3b0001343e08', dateTime: new \DateTimeImmutable('@1786230720'), temperature: 21.5, windSpeed: 4, @@ -224,7 +223,10 @@ public function testSubmitsMultipleMeasurements(): void ), ]; - $this->api->stations()->submitMeasurements($measurements); + $this->api->stations()->submitMeasurements( + '6a77b80aadde3b0001343e08', + $measurements, + ); $request = $this->client->getLastRequest(); self::assertSame( @@ -274,7 +276,20 @@ public function testRejectsAnEmptyMeasurementBatch(): void 'The station measurements must not be empty.', ); - $this->api->stations()->submitMeasurements([]); + $this->api->stations()->submitMeasurements('station-id', []); + } + + public function testRejectsABlankMeasurementStationIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The station ID must be a non-empty string.', + ); + + $this->api->stations()->submitMeasurement( + ' ', + new Measurement(new \DateTimeImmutable()), + ); } public function testRejectsAnInvalidMeasurementBatchItem(): void @@ -284,8 +299,8 @@ public function testRejectsAnInvalidMeasurementBatchItem(): void 'The station measurement at index 1 must be an instance of', ); - $this->api->stations()->submitMeasurements([ - new Measurement('station-id', new \DateTimeImmutable()), + $this->api->stations()->submitMeasurements('station-id', [ + new Measurement(new \DateTimeImmutable()), 'invalid', ]); } From bc2cec235366f2bb753385081517cd9fba49d5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 10 Aug 2026 09:42:02 +0100 Subject: [PATCH 092/113] test(stations): add captured measurement aggregates --- .../measurements/aggregate-day-success.json | 30 +++++++++++++++++++ .../aggregate-day-success.meta.json | 22 ++++++++++++++ .../measurements/aggregate-hour-success.json | 30 +++++++++++++++++++ .../aggregate-hour-success.meta.json | 22 ++++++++++++++ .../aggregate-minute-success.json | 28 +++++++++++++++++ .../aggregate-minute-success.meta.json | 22 ++++++++++++++ 6 files changed, 154 insertions(+) create mode 100644 tests/Fixtures/stations/measurements/aggregate-day-success.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-day-success.meta.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-hour-success.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-minute-success.json create mode 100644 tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.json b/tests/Fixtures/stations/measurements/aggregate-day-success.json new file mode 100644 index 0000000..accca0f --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.json @@ -0,0 +1,30 @@ +[ + { + "type": "d", + "date": 1786233600, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": { + "rain": 0.6 + } + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json new file mode 100644 index 0000000..4e51c50 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-10T07:11:03Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "d", + "limit": 100, + "from": 1786231349, + "to": 1786345863 + } + }, + "sanitization": [], + "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The returned rain total is preserved exactly." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.json new file mode 100644 index 0000000..79aa500 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.json @@ -0,0 +1,30 @@ +[ + { + "type": "h", + "date": 1786233600, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": { + "rain": 0.6 + } + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json new file mode 100644 index 0000000..5f9d2e7 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-10T07:11:03Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "h", + "limit": 100, + "from": 1786231349, + "to": 1786345863 + } + }, + "sanitization": [], + "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The returned rain total is preserved exactly." +} diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.json new file mode 100644 index 0000000..48b564c --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.json @@ -0,0 +1,28 @@ +[ + { + "type": "m", + "date": 1786231380, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 21.5, + "min": 19.5, + "average": 20.5, + "weight": 3 + }, + "humidity": { + "average": 64, + "weight": 3 + }, + "wind": { + "deg": 203.4, + "speed": 3.08 + }, + "pressure": { + "min": 1012, + "max": 1014, + "average": 1013, + "weight": 3 + }, + "precipitation": {} + } +] diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json new file mode 100644 index 0000000..79bef29 --- /dev/null +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Stations API", + "endpoint": "Aggregate station measurements", + "apiVersion": "3.0", + "capturedAt": "2026-08-10T07:11:03Z", + "httpStatus": 200, + "contentType": "application/json; charset=utf-8", + "request": { + "method": "GET", + "path": "/data/3.0/measurements", + "query": { + "station_id": "6a77ba36adde3b0001343e09", + "type": "m", + "limit": 100, + "from": 1786231349, + "to": 1786345863 + } + }, + "sanitization": [], + "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The empty precipitation object is preserved exactly." +} From 72c9c088ebe4edffc5c7ebfbbcfc3bff3f126868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 10 Aug 2026 09:51:50 +0100 Subject: [PATCH 093/113] feat(stations): add measurement aggregate entities --- src/Entity/Stations/MeasurementAggregate.php | 108 ++++++++++++ .../MeasurementAggregate/Humidity.php | 47 ++++++ .../MeasurementAggregate/Precipitation.php | 62 +++++++ .../MeasurementAggregate/Pressure.php | 86 ++++++++++ .../MeasurementAggregate/Temperature.php | 86 ++++++++++ .../Stations/MeasurementAggregate/Wind.php | 60 +++++++ src/Enum/AggregationInterval.php | 15 ++ .../Stations/MeasurementAggregateTest.php | 157 ++++++++++++++++++ 8 files changed, 621 insertions(+) create mode 100644 src/Entity/Stations/MeasurementAggregate.php create mode 100644 src/Entity/Stations/MeasurementAggregate/Humidity.php create mode 100644 src/Entity/Stations/MeasurementAggregate/Precipitation.php create mode 100644 src/Entity/Stations/MeasurementAggregate/Pressure.php create mode 100644 src/Entity/Stations/MeasurementAggregate/Temperature.php create mode 100644 src/Entity/Stations/MeasurementAggregate/Wind.php create mode 100644 src/Enum/AggregationInterval.php create mode 100644 tests/Unit/Entity/Stations/MeasurementAggregateTest.php diff --git a/src/Entity/Stations/MeasurementAggregate.php b/src/Entity/Stations/MeasurementAggregate.php new file mode 100644 index 0000000..d27f4fa --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate.php @@ -0,0 +1,108 @@ +nullableString('type'); + $temperature = $reader->nullableArray('temp'); + $humidity = $reader->nullableArray('humidity'); + $wind = $reader->nullableArray('wind'); + $pressure = $reader->nullableArray('pressure'); + $precipitation = $reader->nullableArray('precipitation'); + + if ($interval !== null) { + $interval = AggregationInterval::tryFrom($interval) + ?? throw HydrationException::invalidValue( + self::class, + 'type', + 'one of m, h, or d', + $interval, + ); + } + + return new self( + interval: $interval, + dateTime: $reader->nullableTimestamp('date'), + stationId: $reader->nullableString('station_id'), + temperature: $temperature === null + ? null + : Temperature::fromArray($temperature, $context), + humidity: $humidity === null + ? null + : Humidity::fromArray($humidity, $context), + wind: $wind === null ? null : Wind::fromArray($wind, $context), + pressure: $pressure === null + ? null + : Pressure::fromArray($pressure, $context), + precipitation: $precipitation === null + ? null + : Precipitation::fromArray($precipitation, $context), + ); + } + + public function interval(): ?AggregationInterval + { + return $this->interval; + } + + public function dateTime(): ?\DateTimeImmutable + { + return $this->dateTime; + } + + public function stationId(): ?string + { + return $this->stationId; + } + + public function temperature(): ?Temperature + { + return $this->temperature; + } + + public function humidity(): ?Humidity + { + return $this->humidity; + } + + public function wind(): ?Wind + { + return $this->wind; + } + + public function pressure(): ?Pressure + { + return $this->pressure; + } + + public function precipitation(): ?Precipitation + { + return $this->precipitation; + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Humidity.php b/src/Entity/Stations/MeasurementAggregate/Humidity.php new file mode 100644 index 0000000..9204c17 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Humidity.php @@ -0,0 +1,47 @@ +nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::PERCENT; + } + + public function averageWithUnit(): ?string + { + return MeasurementFormatter::format($this->average, $this->averageUnit()); + } + + public function weight(): ?int + { + return $this->weight; + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Precipitation.php b/src/Entity/Stations/MeasurementAggregate/Precipitation.php new file mode 100644 index 0000000..1f12539 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Precipitation.php @@ -0,0 +1,62 @@ +nullableFloat('rain'), + snow: $reader->nullableFloat('snow'), + ); + } + + public function rain(): ?float + { + return $this->rain; + } + + public function rainUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function rainWithUnit(): ?string + { + return $this->format($this->rain); + } + + public function snow(): ?float + { + return $this->snow; + } + + public function snowUnit(): Unit + { + return Unit::MILLIMETER; + } + + public function snowWithUnit(): ?string + { + return $this->format($this->snow); + } + + private function format(?float $precipitation): ?string + { + return MeasurementFormatter::format($precipitation, Unit::MILLIMETER); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Pressure.php b/src/Entity/Stations/MeasurementAggregate/Pressure.php new file mode 100644 index 0000000..744eecd --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Pressure.php @@ -0,0 +1,86 @@ +nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + average: $reader->nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::HECTOPASCAL; + } + + public function averageWithUnit(): ?string + { + return $this->format($this->average); + } + + public function weight(): ?int + { + return $this->weight; + } + + private function format(?float $pressure): ?string + { + return MeasurementFormatter::format($pressure, Unit::HECTOPASCAL); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Temperature.php b/src/Entity/Stations/MeasurementAggregate/Temperature.php new file mode 100644 index 0000000..91acd6f --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Temperature.php @@ -0,0 +1,86 @@ +nullableFloat('min'), + maximum: $reader->nullableFloat('max'), + average: $reader->nullableFloat('average'), + weight: $reader->nullableInt('weight'), + ); + } + + public function minimum(): ?float + { + return $this->minimum; + } + + public function minimumUnit(): Unit + { + return Unit::CELSIUS; + } + + public function minimumWithUnit(): ?string + { + return $this->format($this->minimum); + } + + public function maximum(): ?float + { + return $this->maximum; + } + + public function maximumUnit(): Unit + { + return Unit::CELSIUS; + } + + public function maximumWithUnit(): ?string + { + return $this->format($this->maximum); + } + + public function average(): ?float + { + return $this->average; + } + + public function averageUnit(): Unit + { + return Unit::CELSIUS; + } + + public function averageWithUnit(): ?string + { + return $this->format($this->average); + } + + public function weight(): ?int + { + return $this->weight; + } + + private function format(?float $temperature): ?string + { + return MeasurementFormatter::format($temperature, Unit::CELSIUS); + } +} diff --git a/src/Entity/Stations/MeasurementAggregate/Wind.php b/src/Entity/Stations/MeasurementAggregate/Wind.php new file mode 100644 index 0000000..a0d2564 --- /dev/null +++ b/src/Entity/Stations/MeasurementAggregate/Wind.php @@ -0,0 +1,60 @@ +nullableFloat('deg'), + speed: $reader->nullableFloat('speed'), + ); + } + + public function direction(): ?float + { + return $this->direction; + } + + public function directionUnit(): Unit + { + return Unit::DEGREE; + } + + public function directionWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->direction, + $this->directionUnit(), + ); + } + + public function speed(): ?float + { + return $this->speed; + } + + public function speedUnit(): Unit + { + return Unit::METERS_PER_SECOND; + } + + public function speedWithUnit(): ?string + { + return MeasurementFormatter::format($this->speed, $this->speedUnit()); + } +} diff --git a/src/Enum/AggregationInterval.php b/src/Enum/AggregationInterval.php new file mode 100644 index 0000000..d19c965 --- /dev/null +++ b/src/Enum/AggregationInterval.php @@ -0,0 +1,15 @@ +interval()); + self::assertSame(1786231380, $aggregate->dateTime()?->getTimestamp()); + self::assertSame('UTC', $aggregate->dateTime()?->getTimezone()->getName()); + self::assertSame('6a77ba36adde3b0001343e09', $aggregate->stationId()); + + self::assertSame(19.5, $aggregate->temperature()?->minimum()); + self::assertSame(Unit::CELSIUS, $aggregate->temperature()?->minimumUnit()); + self::assertSame('19.5 °C', $aggregate->temperature()?->minimumWithUnit()); + self::assertSame(21.5, $aggregate->temperature()?->maximum()); + self::assertSame('21.5 °C', $aggregate->temperature()?->maximumWithUnit()); + self::assertSame(20.5, $aggregate->temperature()?->average()); + self::assertSame('20.5 °C', $aggregate->temperature()?->averageWithUnit()); + self::assertSame(3, $aggregate->temperature()?->weight()); + + self::assertSame(64.0, $aggregate->humidity()?->average()); + self::assertSame(Unit::PERCENT, $aggregate->humidity()?->averageUnit()); + self::assertSame('64 %', $aggregate->humidity()?->averageWithUnit()); + self::assertSame(3, $aggregate->humidity()?->weight()); + + self::assertSame(203.4, $aggregate->wind()?->direction()); + self::assertSame(Unit::DEGREE, $aggregate->wind()?->directionUnit()); + self::assertSame('203.4 °', $aggregate->wind()?->directionWithUnit()); + self::assertSame(3.08, $aggregate->wind()?->speed()); + self::assertSame(Unit::METERS_PER_SECOND, $aggregate->wind()?->speedUnit()); + self::assertSame('3.08 m/s', $aggregate->wind()?->speedWithUnit()); + + self::assertSame(1012.0, $aggregate->pressure()?->minimum()); + self::assertSame(Unit::HECTOPASCAL, $aggregate->pressure()?->minimumUnit()); + self::assertSame('1012 hPa', $aggregate->pressure()?->minimumWithUnit()); + self::assertSame(1014.0, $aggregate->pressure()?->maximum()); + self::assertSame('1014 hPa', $aggregate->pressure()?->maximumWithUnit()); + self::assertSame(1013.0, $aggregate->pressure()?->average()); + self::assertSame('1013 hPa', $aggregate->pressure()?->averageWithUnit()); + self::assertSame(3, $aggregate->pressure()?->weight()); + + self::assertNotNull($aggregate->precipitation()); + self::assertNull($aggregate->precipitation()?->rain()); + self::assertNull($aggregate->precipitation()?->rainWithUnit()); + self::assertNull($aggregate->precipitation()?->snow()); + } + + public function testHydratesCapturedHourAndDayAggregates(): void + { + $hour = MeasurementAggregate::fromArray( + Fixture::json('stations/measurements/aggregate-hour-success.json')[0], + ); + $day = MeasurementAggregate::fromArray( + Fixture::json('stations/measurements/aggregate-day-success.json')[0], + ); + + self::assertSame(AggregationInterval::HOUR, $hour->interval()); + self::assertSame(AggregationInterval::DAY, $day->interval()); + self::assertSame(1786233600, $hour->dateTime()?->getTimestamp()); + self::assertSame(1786233600, $day->dateTime()?->getTimestamp()); + self::assertSame(0.6, $hour->precipitation()?->rain()); + self::assertSame(Unit::MILLIMETER, $hour->precipitation()?->rainUnit()); + self::assertSame('0.6 mm', $hour->precipitation()?->rainWithUnit()); + self::assertSame(0.6, $day->precipitation()?->rain()); + } + + public function testToleratesMissingNullUnknownAndPartialFields(): void + { + $missing = MeasurementAggregate::fromArray([]); + + self::assertNull($missing->interval()); + self::assertNull($missing->dateTime()); + self::assertNull($missing->stationId()); + self::assertNull($missing->temperature()); + self::assertNull($missing->humidity()); + self::assertNull($missing->wind()); + self::assertNull($missing->pressure()); + self::assertNull($missing->precipitation()); + + $partial = MeasurementAggregate::fromArray([ + 'type' => null, + 'date' => null, + 'station_id' => null, + 'temp' => ['average' => null, 'unknown' => new \stdClass()], + 'humidity' => [], + 'wind' => ['speed' => null], + 'pressure' => [], + 'precipitation' => ['rain' => null, 'snow' => 1.2], + 'unknown' => new \stdClass(), + ]); + + self::assertNull($partial->temperature()?->average()); + self::assertNull($partial->humidity()?->average()); + self::assertNull($partial->wind()?->speed()); + self::assertNull($partial->pressure()?->average()); + self::assertNull($partial->precipitation()?->rain()); + self::assertSame(1.2, $partial->precipitation()?->snow()); + self::assertSame(Unit::MILLIMETER, $partial->precipitation()?->snowUnit()); + self::assertSame('1.2 mm', $partial->precipitation()?->snowWithUnit()); + } + + public function testRejectsAnUnknownAggregationInterval(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage( + '"type" expected one of m, h, or d, "week" received.', + ); + + MeasurementAggregate::fromArray(['type' => 'week']); + } + + #[DataProvider('invalidFields')] + public function testRejectsInvalidKnownFields( + array $data, + string $path, + string $expectedType, + string $receivedType, + ): void { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage(sprintf( + '"%s" expected %s, %s received.', + $path, + $expectedType, + $receivedType, + )); + + MeasurementAggregate::fromArray($data); + } + + public static function invalidFields(): iterable + { + yield 'interval' => [['type' => 1], 'type', 'string', 'int']; + yield 'date' => [['date' => '1786231380'], 'date', 'int', 'string']; + yield 'station identifier' => [['station_id' => 1], 'station_id', 'string', 'int']; + yield 'temperature container' => [['temp' => 'invalid'], 'temp', 'array', 'string']; + yield 'temperature minimum' => [['temp' => ['min' => '19.5']], 'min', 'int|float', 'string']; + yield 'temperature weight' => [['temp' => ['weight' => 3.0]], 'weight', 'int', 'float']; + yield 'humidity average' => [['humidity' => ['average' => '64']], 'average', 'int|float', 'string']; + yield 'wind direction' => [['wind' => ['deg' => '203.4']], 'deg', 'int|float', 'string']; + yield 'pressure average' => [['pressure' => ['average' => '1013']], 'average', 'int|float', 'string']; + yield 'precipitation rain' => [['precipitation' => ['rain' => '0.6']], 'rain', 'int|float', 'string']; + } +} From 12304ba6980ff87d530e404000000a23668dc9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 10 Aug 2026 10:02:53 +0100 Subject: [PATCH 094/113] feat(stations): add measurement aggregate retrieval --- src/Resource/Stations.php | 30 +++++++++ tests/Unit/Resource/StationsTest.php | 96 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/Resource/Stations.php b/src/Resource/Stations.php index 9432620..4298a5b 100644 --- a/src/Resource/Stations.php +++ b/src/Resource/Stations.php @@ -3,7 +3,9 @@ namespace ProgrammatorDev\OpenWeatherMap\Resource; use ProgrammatorDev\Api\Resource; +use ProgrammatorDev\OpenWeatherMap\Entity\Stations\MeasurementAggregate; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; +use ProgrammatorDev\OpenWeatherMap\Enum\AggregationInterval; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Validation\Assert; @@ -141,6 +143,34 @@ public function submitMeasurements( ->post('/data/3.0/measurements'); } + /** + * @return list + */ + public function measurements( + string $stationId, + AggregationInterval $interval, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, + int $limit, + ): array { + $stationId = Assert::notBlank($stationId, 'station ID'); + Assert::chronologicalRange($startAt, $endAt); + $limit = Assert::positiveInteger($limit, 'result limit'); + + // https://openweathermap.org/api/stations#measurement + return $this + ->endpoint() + ->queries([ + 'station_id' => $stationId, + 'type' => $interval, + 'limit' => $limit, + 'from' => $startAt->getTimestamp(), + 'to' => $endAt->getTimestamp(), + ]) + ->get('/data/3.0/measurements') + ->collection(MeasurementAggregate::class); + } + /** * @return array{ * external_id: string, diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 363f321..3beac1e 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -3,7 +3,9 @@ namespace ProgrammatorDev\OpenWeatherMap\Test\Unit\Resource; use PHPUnit\Framework\Attributes\DataProvider; +use ProgrammatorDev\OpenWeatherMap\Entity\Stations\MeasurementAggregate; use ProgrammatorDev\OpenWeatherMap\Entity\Stations\Station; +use ProgrammatorDev\OpenWeatherMap\Enum\AggregationInterval; use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; @@ -305,6 +307,100 @@ public function testRejectsAnInvalidMeasurementBatchItem(): void ]); } + public function testAggregatesMeasurements(): void + { + $this->respondWithFixture( + 'stations/measurements/aggregate-hour-success.json', + ); + + $aggregates = $this->api->stations()->measurements( + stationId: ' 6a77ba36adde3b0001343e09 ', + interval: AggregationInterval::HOUR, + startAt: new \DateTimeImmutable('@1786231349'), + endAt: new \DateTimeImmutable('@1786345863'), + limit: 100, + ); + $request = $this->client->getLastRequest(); + + self::assertCount(1, $aggregates); + self::assertContainsOnlyInstancesOf(MeasurementAggregate::class, $aggregates); + self::assertSame(AggregationInterval::HOUR, $aggregates[0]->interval()); + self::assertSame(20.5, $aggregates[0]->temperature()?->average()); + self::assertSame(0.6, $aggregates[0]->precipitation()?->rain()); + self::assertSame('GET', $request->getMethod()); + self::assertSame('/data/3.0/measurements', $request->getUri()->getPath()); + self::assertSame([ + 'station_id' => '6a77ba36adde3b0001343e09', + 'type' => 'h', + 'limit' => '100', + 'from' => '1786231349', + 'to' => '1786345863', + 'appid' => 'api-key', + ], $this->query($request)); + } + + public function testReturnsAnEmptyMeasurementAggregateCollection(): void + { + $this->respondWithFixture( + 'stations/measurements/aggregate-minute-empty.json', + ); + + $aggregates = $this->api->stations()->measurements( + stationId: 'station-id', + interval: AggregationInterval::MINUTE, + startAt: new \DateTimeImmutable('@1786143599'), + endAt: new \DateTimeImmutable('@1786230780'), + limit: 10, + ); + + self::assertSame([], $aggregates); + } + + #[DataProvider('invalidAggregationArguments')] + public function testRejectsInvalidAggregationArguments( + string $stationId, + \DateTimeInterface $startAt, + \DateTimeInterface $endAt, + int $limit, + string $message, + ): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->api->stations()->measurements( + $stationId, + AggregationInterval::HOUR, + $startAt, + $endAt, + $limit, + ); + } + + public static function invalidAggregationArguments(): iterable + { + yield 'blank station identifier' => [ + ' ', + new \DateTimeImmutable('@100'), + new \DateTimeImmutable('@200'), + 1, + 'The station ID must be a non-empty string.', + ]; + yield 'reversed date range' => [ + 'station-id', + new \DateTimeImmutable('@200'), + new \DateTimeImmutable('@100'), + 1, + 'The end date must be after or equal to the start date.', + ]; + yield 'non-positive result limit' => [ + 'station-id', + new \DateTimeImmutable('@100'), + new \DateTimeImmutable('@200'), + 0, + 'The result limit must be at least 1.', + ]; + } + #[DataProvider('invalidCreationArguments')] public function testRejectsInvalidCreationArguments( string $externalId, From 1797031c0e843bb460a7b1ee2cf6d57e05cf0ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 10 Aug 2026 10:06:31 +0100 Subject: [PATCH 095/113] docs(stations): document measurement retrieval --- docs/stations.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/stations.md b/docs/stations.md index bf58909..d53eb41 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -169,3 +169,53 @@ Each `Weather` represents one entry in the `weather` array. It accepts the available METAR precipitation, descriptor, intensity, proximity, obscuration, and other codes. At least one value must be provided, and codes are kept as strings so additional values accepted by OpenWeather are not restricted. + +## Retrieve Measurements + +Use `measurements()` to retrieve measurements aggregated by minute, hour, or +day for a station and time range. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\AggregationInterval; + +$measurements = $api->stations()->measurements( + stationId: $station->id(), + interval: AggregationInterval::HOUR, + startAt: new DateTimeImmutable('2 days ago'), + endAt: new DateTimeImmutable('now'), + limit: 100, +); +``` + +> **Processing delay:** Submitted measurements are aggregated asynchronously +> and may take more than 24 hours to appear. OpenWeather does not document an +> availability timeframe. + +The method returns an array of `MeasurementAggregate` entities and returns an +empty array when no aggregates are available for the requested interval. Each +entity identifies its aggregation interval, bucket time, and station. + +```php +foreach ($measurements as $measurement) { + echo $measurement->interval()?->value; + echo $measurement->dateTime()?->format(DATE_ATOM); + echo $measurement->stationId(); + + echo $measurement->temperature()?->average(); // 20.5 + echo $measurement->temperature()?->averageWithUnit(); // 20.5 °C + echo $measurement->humidity()?->averageWithUnit(); // 64 % + echo $measurement->wind()?->speedWithUnit(); // 3.08 m/s + echo $measurement->pressure()?->averageWithUnit(); // 1013 hPa + echo $measurement->precipitation()?->rainWithUnit(); // 0.6 mm +} +``` + +Temperature and pressure aggregates expose `minimum()`, `maximum()`, +`average()`, and `weight()`. Humidity exposes `average()` and `weight()`. Wind +exposes `direction()` and `speed()`, while precipitation exposes `rain()` and +`snow()`. Measurement properties and nested structures are nullable because +OpenWeather may omit data that was unavailable for an aggregation bucket. + +The endpoint returns aggregates rather than the original submitted +measurements. Submitted visibility, cloud layers, METAR weather descriptions, +and other raw fields are not included in the documented aggregate response. From 7ff8dda1a8076edf6197a02760c1884076612189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 08:41:56 +0100 Subject: [PATCH 096/113] test(stations): capture delayed aggregate results --- .../measurements/aggregate-day-success.json | 29 +++++++++++++++++++ .../aggregate-day-success.meta.json | 6 ++-- .../measurements/aggregate-hour-success.json | 29 +++++++++++++++++++ .../aggregate-hour-success.meta.json | 6 ++-- .../aggregate-minute-success.json | 26 +++++++++++++++++ .../aggregate-minute-success.meta.json | 6 ++-- .../Stations/MeasurementAggregateTest.php | 17 +++++++---- tests/Unit/Resource/StationsTest.php | 7 +++-- 8 files changed, 108 insertions(+), 18 deletions(-) diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.json b/tests/Fixtures/stations/measurements/aggregate-day-success.json index accca0f..7818f20 100644 --- a/tests/Fixtures/stations/measurements/aggregate-day-success.json +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.json @@ -26,5 +26,34 @@ "precipitation": { "rain": 0.6 } + }, + { + "type": "d", + "date": 1786320000, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": { + "rain": 3.4, + "snow": 0.5 + } } ] diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json index 4e51c50..8ed3fe3 100644 --- a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json @@ -3,7 +3,7 @@ "product": "Weather Stations API", "endpoint": "Aggregate station measurements", "apiVersion": "3.0", - "capturedAt": "2026-08-10T07:11:03Z", + "capturedAt": "2026-08-14T07:30:22Z", "httpStatus": 200, "contentType": "application/json; charset=utf-8", "request": { @@ -14,9 +14,9 @@ "type": "d", "limit": 100, "from": 1786231349, - "to": 1786345863 + "to": 1786692622 } }, "sanitization": [], - "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The returned rain total is preserved exactly." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response." } diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.json index 79aa500..c068e45 100644 --- a/tests/Fixtures/stations/measurements/aggregate-hour-success.json +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.json @@ -26,5 +26,34 @@ "precipitation": { "rain": 0.6 } + }, + { + "type": "h", + "date": 1786291200, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": { + "rain": 0.6, + "snow": 0.1 + } } ] diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json index 5f9d2e7..e0624f3 100644 --- a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json @@ -3,7 +3,7 @@ "product": "Weather Stations API", "endpoint": "Aggregate station measurements", "apiVersion": "3.0", - "capturedAt": "2026-08-10T07:11:03Z", + "capturedAt": "2026-08-14T07:30:22Z", "httpStatus": 200, "contentType": "application/json; charset=utf-8", "request": { @@ -14,9 +14,9 @@ "type": "h", "limit": 100, "from": 1786231349, - "to": 1786345863 + "to": 1786692622 } }, "sanitization": [], - "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The returned rain total is preserved exactly." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response." } diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.json index 48b564c..4852ebf 100644 --- a/tests/Fixtures/stations/measurements/aggregate-minute-success.json +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.json @@ -24,5 +24,31 @@ "weight": 3 }, "precipitation": {} + }, + { + "type": "m", + "date": 1786290900, + "station_id": "6a77ba36adde3b0001343e09", + "temp": { + "max": 16.7, + "min": 16.7, + "average": 16.7, + "weight": 1 + }, + "humidity": { + "average": 72, + "weight": 1 + }, + "wind": { + "deg": 230, + "speed": 4.8 + }, + "pressure": { + "min": 1014.2, + "max": 1014.2, + "average": 1014.2, + "weight": 1 + }, + "precipitation": {} } ] diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json index 79bef29..786c0c5 100644 --- a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json @@ -3,7 +3,7 @@ "product": "Weather Stations API", "endpoint": "Aggregate station measurements", "apiVersion": "3.0", - "capturedAt": "2026-08-10T07:11:03Z", + "capturedAt": "2026-08-14T07:30:22Z", "httpStatus": 200, "contentType": "application/json; charset=utf-8", "request": { @@ -14,9 +14,9 @@ "type": "m", "limit": 100, "from": 1786231349, - "to": 1786345863 + "to": 1786692622 } }, "sanitization": [], - "notes": "The aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after the three source measurements were accepted. The empty precipitation object is preserved exactly." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Both empty precipitation objects are preserved exactly." } diff --git a/tests/Unit/Entity/Stations/MeasurementAggregateTest.php b/tests/Unit/Entity/Stations/MeasurementAggregateTest.php index 54cc7c3..1c1fd9a 100644 --- a/tests/Unit/Entity/Stations/MeasurementAggregateTest.php +++ b/tests/Unit/Entity/Stations/MeasurementAggregateTest.php @@ -61,12 +61,12 @@ public function testHydratesCapturedMinuteAggregate(): void public function testHydratesCapturedHourAndDayAggregates(): void { - $hour = MeasurementAggregate::fromArray( - Fixture::json('stations/measurements/aggregate-hour-success.json')[0], - ); - $day = MeasurementAggregate::fromArray( - Fixture::json('stations/measurements/aggregate-day-success.json')[0], - ); + $hours = Fixture::json('stations/measurements/aggregate-hour-success.json'); + $days = Fixture::json('stations/measurements/aggregate-day-success.json'); + $hour = MeasurementAggregate::fromArray($hours[0]); + $day = MeasurementAggregate::fromArray($days[0]); + $populatedHour = MeasurementAggregate::fromArray($hours[1]); + $populatedDay = MeasurementAggregate::fromArray($days[1]); self::assertSame(AggregationInterval::HOUR, $hour->interval()); self::assertSame(AggregationInterval::DAY, $day->interval()); @@ -76,6 +76,11 @@ public function testHydratesCapturedHourAndDayAggregates(): void self::assertSame(Unit::MILLIMETER, $hour->precipitation()?->rainUnit()); self::assertSame('0.6 mm', $hour->precipitation()?->rainWithUnit()); self::assertSame(0.6, $day->precipitation()?->rain()); + self::assertSame(1014.2, $populatedHour->pressure()?->average()); + self::assertSame(0.1, $populatedHour->precipitation()?->snow()); + self::assertSame('0.1 mm', $populatedHour->precipitation()?->snowWithUnit()); + self::assertSame(3.4, $populatedDay->precipitation()?->rain()); + self::assertSame(0.5, $populatedDay->precipitation()?->snow()); } public function testToleratesMissingNullUnknownAndPartialFields(): void diff --git a/tests/Unit/Resource/StationsTest.php b/tests/Unit/Resource/StationsTest.php index 3beac1e..f3aca54 100644 --- a/tests/Unit/Resource/StationsTest.php +++ b/tests/Unit/Resource/StationsTest.php @@ -317,16 +317,17 @@ public function testAggregatesMeasurements(): void stationId: ' 6a77ba36adde3b0001343e09 ', interval: AggregationInterval::HOUR, startAt: new \DateTimeImmutable('@1786231349'), - endAt: new \DateTimeImmutable('@1786345863'), + endAt: new \DateTimeImmutable('@1786692622'), limit: 100, ); $request = $this->client->getLastRequest(); - self::assertCount(1, $aggregates); + self::assertCount(2, $aggregates); self::assertContainsOnlyInstancesOf(MeasurementAggregate::class, $aggregates); self::assertSame(AggregationInterval::HOUR, $aggregates[0]->interval()); self::assertSame(20.5, $aggregates[0]->temperature()?->average()); self::assertSame(0.6, $aggregates[0]->precipitation()?->rain()); + self::assertSame(0.1, $aggregates[1]->precipitation()?->snow()); self::assertSame('GET', $request->getMethod()); self::assertSame('/data/3.0/measurements', $request->getUri()->getPath()); self::assertSame([ @@ -334,7 +335,7 @@ public function testAggregatesMeasurements(): void 'type' => 'h', 'limit' => '100', 'from' => '1786231349', - 'to' => '1786345863', + 'to' => '1786692622', 'appid' => 'api-key', ], $this->query($request)); } From 8d9726a0e21b134733d4c0f5aff7a7cc6f3f374d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 08:50:57 +0100 Subject: [PATCH 097/113] docs: refine maps and stations guidance --- README.md | 4 ++-- docs/maps.md | 2 +- docs/stations.md | 12 +++++++++++- tests/Fixtures/stations/delete.meta.json | 2 +- .../measurements/aggregate-day-success.meta.json | 2 +- .../measurements/aggregate-hour-success.meta.json | 2 +- .../measurements/aggregate-minute-success.meta.json | 2 +- tests/Fixtures/stations/register.meta.json | 2 +- 8 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fd8a494..db32b82 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ use yet. - [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) -- [Weather Maps](docs/maps.md) -- [Weather Stations](docs/stations.md) +- [Maps](docs/maps.md) +- [Stations](docs/stations.md) - [Geocoding](docs/geocoding.md) ## License diff --git a/docs/maps.md b/docs/maps.md index 71f09ec..60b256f 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -1,4 +1,4 @@ -# Weather Maps +# Maps Weather Maps API 1.0 provides current cloud, precipitation, sea-level pressure, wind-speed, and temperature overlays. It is available on OpenWeather's standard diff --git a/docs/stations.md b/docs/stations.md index d53eb41..96939f2 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -1,4 +1,4 @@ -# Weather Stations +# Stations The Weather Stations API lets you register and manage personal weather stations associated with your OpenWeather account. @@ -161,6 +161,11 @@ standard METAR codes. See the [NOAA METAR reference](https://aviationweather.gov/help/data/#metar) for their meanings. +> **Upstream inconsistency:** OpenWeather documents `visibilityPrefix` as a +> compass-direction string, but its live API rejected a documented string value +> during verification. Omit this value unless OpenWeather clarifies or corrects +> the accepted type. + Each `CloudLayer` represents one entry in OpenWeather's `clouds` array. Its distance, METAR cloud condition, and cumulus type are optional, but at least one value must be provided. @@ -170,6 +175,11 @@ available METAR precipitation, descriptor, intensity, proximity, obscuration, and other codes. At least one value must be provided, and codes are kept as strings so additional values accepted by OpenWeather are not restricted. +METAR visibility, cloud, and weather values appear to be write-only in this API. +A successful submission has no response body, and OpenWeather does not document +a method for retrieving the original measurement payload. These values +therefore could not be read back or verified after submission. + ## Retrieve Measurements Use `measurements()` to retrieve measurements aggregated by minute, hour, or diff --git a/tests/Fixtures/stations/delete.meta.json b/tests/Fixtures/stations/delete.meta.json index bde1901..481bec7 100644 --- a/tests/Fixtures/stations/delete.meta.json +++ b/tests/Fixtures/stations/delete.meta.json @@ -13,5 +13,5 @@ "query": {} }, "sanitization": [], - "notes": "The generated identifier belongs to the deleted temporary fixture station and is unchanged. The successful response had no body or Content-Type header, and a following list request confirmed the account returned from zero stations to zero." + "notes": "The generated identifier belongs to the deleted temporary fixture station and is unchanged. The successful response had no body or Content-Type header, and a following list request confirmed the account returned from one station to zero." } diff --git a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json index 8ed3fe3..26eb74f 100644 --- a/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-day-success.meta.json @@ -18,5 +18,5 @@ } }, "sanitization": [], - "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response. The retained station was deleted after capture, and a following list request confirmed that no stations remained." } diff --git a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json index e0624f3..0c7feb0 100644 --- a/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-hour-success.meta.json @@ -18,5 +18,5 @@ } }, "sanitization": [], - "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Its decimal pressure and rain and snow totals are preserved exactly; submitted METAR structures are absent from the aggregate response. The retained station was deleted after capture, and a following list request confirmed that no stations remained." } diff --git a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json index 786c0c5..2a6c8ea 100644 --- a/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json +++ b/tests/Fixtures/stations/measurements/aggregate-minute-success.meta.json @@ -18,5 +18,5 @@ } }, "sanitization": [], - "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Both empty precipitation objects are preserved exactly." + "notes": "The first aggregate appeared between 23 hours 43 minutes and 31 hours 43 minutes after its three source measurements were accepted. The second aggregate appeared approximately 30 hours after its fully populated source measurement was accepted. Both empty precipitation objects are preserved exactly. The retained station was deleted after capture, and a following list request confirmed that no stations remained." } diff --git a/tests/Fixtures/stations/register.meta.json b/tests/Fixtures/stations/register.meta.json index ec4b8ce..8a6ab77 100644 --- a/tests/Fixtures/stations/register.meta.json +++ b/tests/Fixtures/stations/register.meta.json @@ -24,5 +24,5 @@ "action": "replaced private account identifier with user-fixture" } ], - "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted and the account returned from zero stations to zero." + "notes": "The deliberately public fixture metadata and generated station identifier are unchanged. The temporary station was deleted and the account returned from one station to zero." } From 51327e3bf3c003a584355fabca5728d139a7e643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:01:22 +0100 Subject: [PATCH 098/113] refactor(one-call): standardize dew point getters --- src/Entity/OneCall/Current.php | 16 ++++++++-------- src/Entity/OneCall/OneDayTimeline/Period.php | 16 ++++++++-------- src/Entity/OneCall/Timeline/WeatherPeriod.php | 16 ++++++++-------- tests/Unit/Entity/OneCall/CurrentTest.php | 10 +++++----- .../OneCall/FifteenMinuteTimeline/PeriodTest.php | 8 ++++---- .../Entity/OneCall/OneDayTimeline/PeriodTest.php | 8 ++++---- .../OneCall/OneHourTimeline/PeriodTest.php | 4 ++-- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/Entity/OneCall/Current.php b/src/Entity/OneCall/Current.php index 4864ae9..c11445b 100644 --- a/src/Entity/OneCall/Current.php +++ b/src/Entity/OneCall/Current.php @@ -32,7 +32,7 @@ private function __construct( private readonly ?float $feelsLikeTemperature, private readonly ?int $pressure, private readonly ?int $humidity, - private readonly ?float $dewPointTemperature, + private readonly ?float $dewPoint, private readonly ?float $ultravioletIndex, private readonly ?int $visibility, private readonly ?Wind $wind, @@ -86,7 +86,7 @@ public static function fromArray(array $data, ?Context $context = null): static feelsLikeTemperature: $reader->nullableFloat('data.0.feels_like'), pressure: $reader->nullableInt('data.0.pressure'), humidity: $reader->nullableInt('data.0.humidity'), - dewPointTemperature: $reader->nullableFloat('data.0.dew_point'), + dewPoint: $reader->nullableFloat('data.0.dew_point'), ultravioletIndex: $reader->nullableFloat('data.0.uvi'), visibility: $reader->nullableInt('data.0.visibility'), wind: $hasWind @@ -197,21 +197,21 @@ public function humidityWithUnit(): ?string return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); } - public function dewPointTemperature(): ?float + public function dewPoint(): ?float { - return $this->dewPointTemperature; + return $this->dewPoint; } - public function dewPointTemperatureUnit(): Unit + public function dewPointUnit(): Unit { return $this->units->temperatureUnit(); } - public function dewPointTemperatureWithUnit(): ?string + public function dewPointWithUnit(): ?string { return MeasurementFormatter::format( - $this->dewPointTemperature, - $this->dewPointTemperatureUnit(), + $this->dewPoint, + $this->dewPointUnit(), ); } diff --git a/src/Entity/OneCall/OneDayTimeline/Period.php b/src/Entity/OneCall/OneDayTimeline/Period.php index b15b553..9678bd7 100644 --- a/src/Entity/OneCall/OneDayTimeline/Period.php +++ b/src/Entity/OneCall/OneDayTimeline/Period.php @@ -31,7 +31,7 @@ private function __construct( private readonly ?FeelsLikeTemperature $feelsLikeTemperature, private readonly ?float $pressure, private readonly ?int $humidity, - private readonly ?float $dewPointTemperature, + private readonly ?float $dewPoint, private readonly ?float $ultravioletIndex, private readonly ?int $visibility, private readonly ?Wind $wind, @@ -84,7 +84,7 @@ public static function fromArray(array $data, ?Context $context = null): static : FeelsLikeTemperature::fromArray($feelsLikeTemperature, $context), pressure: $reader->nullableFloat('pressure'), humidity: $reader->nullableInt('humidity'), - dewPointTemperature: $reader->nullableFloat('dew_point'), + dewPoint: $reader->nullableFloat('dew_point'), ultravioletIndex: $reader->nullableFloat('uvi'), visibility: $reader->nullableInt('visibility'), wind: $hasWind @@ -180,21 +180,21 @@ public function humidityWithUnit(): ?string return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); } - public function dewPointTemperature(): ?float + public function dewPoint(): ?float { - return $this->dewPointTemperature; + return $this->dewPoint; } - public function dewPointTemperatureUnit(): Unit + public function dewPointUnit(): Unit { return $this->units->temperatureUnit(); } - public function dewPointTemperatureWithUnit(): ?string + public function dewPointWithUnit(): ?string { return MeasurementFormatter::format( - $this->dewPointTemperature, - $this->dewPointTemperatureUnit(), + $this->dewPoint, + $this->dewPointUnit(), ); } diff --git a/src/Entity/OneCall/Timeline/WeatherPeriod.php b/src/Entity/OneCall/Timeline/WeatherPeriod.php index 78766ec..9e58167 100644 --- a/src/Entity/OneCall/Timeline/WeatherPeriod.php +++ b/src/Entity/OneCall/Timeline/WeatherPeriod.php @@ -30,7 +30,7 @@ protected function __construct( private readonly ?float $feelsLikeTemperature, private readonly ?float $pressure, private readonly ?int $humidity, - private readonly ?float $dewPointTemperature, + private readonly ?float $dewPoint, private readonly ?float $ultravioletIndex, private readonly ?int $visibility, private readonly ?Wind $wind, @@ -74,7 +74,7 @@ public static function fromArray(array $data, ?Context $context = null): static feelsLikeTemperature: $reader->nullableFloat('feels_like'), pressure: $reader->nullableFloat('pressure'), humidity: $reader->nullableInt('humidity'), - dewPointTemperature: $reader->nullableFloat('dew_point'), + dewPoint: $reader->nullableFloat('dew_point'), ultravioletIndex: $reader->nullableFloat('uvi'), visibility: $reader->nullableInt('visibility'), wind: $hasWind @@ -166,21 +166,21 @@ public function humidityWithUnit(): ?string return MeasurementFormatter::format($this->humidity, $this->humidityUnit()); } - public function dewPointTemperature(): ?float + public function dewPoint(): ?float { - return $this->dewPointTemperature; + return $this->dewPoint; } - public function dewPointTemperatureUnit(): Unit + public function dewPointUnit(): Unit { return $this->units->temperatureUnit(); } - public function dewPointTemperatureWithUnit(): ?string + public function dewPointWithUnit(): ?string { return MeasurementFormatter::format( - $this->dewPointTemperature, - $this->dewPointTemperatureUnit(), + $this->dewPoint, + $this->dewPointUnit(), ); } diff --git a/tests/Unit/Entity/OneCall/CurrentTest.php b/tests/Unit/Entity/OneCall/CurrentTest.php index 13e76a4..ce5001a 100644 --- a/tests/Unit/Entity/OneCall/CurrentTest.php +++ b/tests/Unit/Entity/OneCall/CurrentTest.php @@ -41,9 +41,9 @@ public function testHydratesCapturedCurrentWeather(): void self::assertSame(71, $current->humidity()); self::assertSame(Unit::PERCENT, $current->humidityUnit()); self::assertSame('71 %', $current->humidityWithUnit()); - self::assertSame(18.75, $current->dewPointTemperature()); - self::assertSame(Unit::CELSIUS, $current->dewPointTemperatureUnit()); - self::assertSame('18.75 °C', $current->dewPointTemperatureWithUnit()); + self::assertSame(18.75, $current->dewPoint()); + self::assertSame(Unit::CELSIUS, $current->dewPointUnit()); + self::assertSame('18.75 °C', $current->dewPointWithUnit()); self::assertSame(7.53, $current->ultravioletIndex()); self::assertSame(40, $current->clouds()?->coverage()); self::assertSame(Unit::PERCENT, $current->clouds()?->coverageUnit()); @@ -123,7 +123,7 @@ public function testRetainsUnitsFromHydrationContext(): void self::assertSame(Unit::FAHRENHEIT, $current->temperatureUnit()); self::assertSame('72.5 °F', $current->temperatureWithUnit()); self::assertSame('71 °F', $current->feelsLikeTemperatureWithUnit()); - self::assertSame('60 °F', $current->dewPointTemperatureWithUnit()); + self::assertSame('60 °F', $current->dewPointWithUnit()); self::assertSame(Unit::MILES_PER_HOUR, $current->wind()?->speedUnit()); self::assertSame('10 mph', $current->wind()?->speedWithUnit()); self::assertSame('15 mph', $current->wind()?->gustWithUnit()); @@ -138,7 +138,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->dateTime()); self::assertNull($missing->temperature()); self::assertNull($missing->temperatureWithUnit()); - self::assertNull($missing->dewPointTemperature()); + self::assertNull($missing->dewPoint()); self::assertNull($missing->ultravioletIndex()); self::assertNull($missing->wind()); self::assertNull($missing->clouds()); diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php index 9bc0885..503cf1b 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php @@ -32,9 +32,9 @@ public function testHydratesCapturedForecastPeriod(): void self::assertSame(55, $period->humidity()); self::assertSame(Unit::PERCENT, $period->humidityUnit()); self::assertSame('55 %', $period->humidityWithUnit()); - self::assertSame(16.45, $period->dewPointTemperature()); - self::assertSame(Unit::CELSIUS, $period->dewPointTemperatureUnit()); - self::assertSame('16.45 °C', $period->dewPointTemperatureWithUnit()); + self::assertSame(16.45, $period->dewPoint()); + self::assertSame(Unit::CELSIUS, $period->dewPointUnit()); + self::assertSame('16.45 °C', $period->dewPointWithUnit()); self::assertSame(8.06, $period->ultravioletIndex()); self::assertSame(10000, $period->visibility()); self::assertSame(Unit::METER, $period->visibilityUnit()); @@ -105,7 +105,7 @@ public function testRetainsUnitsFromHydrationContext(): void self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); self::assertSame('72.5 °F', $period->temperatureWithUnit()); self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); - self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); self::assertSame('10 mph', $period->wind()?->speedWithUnit()); self::assertSame('15 mph', $period->wind()?->gustWithUnit()); diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php index 6599b37..0adfdac 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php @@ -40,7 +40,7 @@ public function testHydratesCapturedDailyPeriod(): void self::assertSame(Unit::HECTOPASCAL, $period->pressureUnit()); self::assertSame('1015.44 hPa', $period->pressureWithUnit()); self::assertSame(48, $period->humidity()); - self::assertNull($period->dewPointTemperature()); + self::assertNull($period->dewPoint()); self::assertSame(0.0, $period->ultravioletIndex()); self::assertNull($period->visibility()); self::assertSame(6.17, $period->wind()?->speed()); @@ -89,8 +89,8 @@ public function testHydratesDocumentedFieldsAbsentFromCapturedPeriods(): void 'alerts' => ['alert-id'], ]); - self::assertSame(16.5, $period->dewPointTemperature()); - self::assertSame('16.5 °C', $period->dewPointTemperatureWithUnit()); + self::assertSame(16.5, $period->dewPoint()); + self::assertSame('16.5 °C', $period->dewPointWithUnit()); self::assertSame(10000, $period->visibility()); self::assertSame('10000 m', $period->visibilityWithUnit()); self::assertSame(8.2, $period->wind()?->gust()); @@ -113,7 +113,7 @@ public function testRetainsUnitsFromHydrationContext(): void self::assertSame(Unit::FAHRENHEIT, $period->temperature()?->dayUnit()); self::assertSame('72.5 °F', $period->temperature()?->dayWithUnit()); self::assertSame('71 °F', $period->feelsLikeTemperature()?->dayWithUnit()); - self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); self::assertSame('10 mph', $period->wind()?->speedWithUnit()); } diff --git a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php index b41b69b..b8856ba 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php @@ -28,7 +28,7 @@ public function testHydratesCapturedPeriod(): void self::assertSame(1015.0, $period->pressure()); self::assertSame('1015 hPa', $period->pressureWithUnit()); self::assertSame(62, $period->humidity()); - self::assertSame(17.27, $period->dewPointTemperature()); + self::assertSame(17.27, $period->dewPoint()); self::assertSame(7.53, $period->ultravioletIndex()); self::assertSame(10000, $period->visibility()); self::assertSame(3.87, $period->wind()?->speed()); @@ -90,7 +90,7 @@ public function testRetainsUnitsFromHydrationContext(): void self::assertSame(Unit::FAHRENHEIT, $period->temperatureUnit()); self::assertSame('72.5 °F', $period->temperatureWithUnit()); self::assertSame('71 °F', $period->feelsLikeTemperatureWithUnit()); - self::assertSame('60 °F', $period->dewPointTemperatureWithUnit()); + self::assertSame('60 °F', $period->dewPointWithUnit()); self::assertSame(Unit::MILES_PER_HOUR, $period->wind()?->speedUnit()); self::assertSame('10 mph', $period->wind()?->speedWithUnit()); self::assertSame('15 mph', $period->wind()?->gustWithUnit()); From de117e094aa092dc4ba92ef3d534ed9d95164591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:11:48 +0100 Subject: [PATCH 099/113] build: clean up Composer package metadata --- composer.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/composer.json b/composer.json index db4350c..e383134 100644 --- a/composer.json +++ b/composer.json @@ -17,18 +17,12 @@ "programmatordev/php-api-sdk": "^3.3" }, "require-dev": { - "monolog/monolog": "^3.10", "nyholm/psr7": "^1.8", "php-http/mock-client": "^1.6", "phpunit/phpunit": "^10.5", - "symfony/cache": "^6.4|^7.4|^8.0", "symfony/http-client": "^6.4|^7.4|^8.0", "symfony/var-dumper": "^6.4|^7.4|^8.0" }, - "provide": { - "psr/http-client-implementation": "1.0", - "psr/http-factory-implementation": "1.0" - }, "autoload": { "psr-4": { "ProgrammatorDev\\OpenWeatherMap\\": "src/" From 6b25d08266497cb732545f140b2c1771ca62510a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:25:28 +0100 Subject: [PATCH 100/113] docs: expand README getting started guide --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index db32b82..f5d3668 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,95 @@ [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Tests](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml?query=branch%3Amain) -OpenWeather PHP library built on -[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk). +A fluent PHP client for OpenWeather APIs covering current and forecast weather, +air pollution, geocoding, maps, stations, and One Call. Responses are mapped to +typed entities that safely handle conditional, missing, and `null` data while +keeping common requests concise. -The library is currently under development and the public API is not ready for -use yet. +The library is built on +[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk) +and supports client-wide and request-local configuration. ## Requirements -- PHP 8.1 or higher. +- PHP 8.1 or higher +- An OpenWeather API key + +## Installation + +Install the library with Composer: + +```bash +composer require programmatordev/openweathermap-php-api +``` + +## Getting Started + +Create the API client with an OpenWeather API key, then select a resource and +endpoint: + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); + +$current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, +); + +echo $current->temperature(); +echo $current->temperatureWithUnit(); +``` + +Response properties may be missing or explicitly `null`, so entity getters +return nullable values where appropriate. Collection getters return empty +arrays when the corresponding response collection is absent or `null`. + +## Configuration + +The client defaults to metric units and English. The equivalent explicit +configuration is: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap( + apiKey: $_ENV['OPENWEATHERMAP_API_KEY'], + options: [ + 'units' => Units::METRIC, + 'language' => Language::ENGLISH, + ], +); +``` + +Compatible resources can override those values for one fluent request chain. +The original client configuration and other resource instances remain +unchanged: + +```php +$current = $api + ->weather() + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE) + ->current(latitude: 38.7223, longitude: -9.1393); +``` + +`withLanguage()` also accepts a non-empty language-code string, allowing new +OpenWeather languages to be used without waiting for an enum update. + +See OpenWeather's +[units of measurement](https://openweathermap.org/api/current?collection=current_forecast#data) and +[multilingual support](https://openweathermap.org/api/current?collection=current_forecast#multi) +documentation for the currently supported values. ## Documentation +The detailed guides cover each API's endpoints, response entities, and usage +examples: + - [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) - [Weather](docs/weather.md) From ff61950fa26d235ccc79b7016c62acd5bbee8d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:39:09 +0100 Subject: [PATCH 101/113] feat(weather): normalize precipitation probabilities --- docs/one-call.md | 9 +++++ docs/weather.md | 10 ++++++ src/Entity/OneCall/OneDayTimeline/Period.php | 12 +++---- src/Entity/OneCall/Timeline/WeatherPeriod.php | 12 +++---- .../Concern/HasPrecipitationProbability.php | 36 +++++++++++++++++++ src/Entity/Weather/Forecast/Period.php | 11 +++--- .../FifteenMinuteTimeline/PeriodTest.php | 7 ++-- .../OneCall/OneDayTimeline/PeriodTest.php | 3 ++ .../OneCall/OneHourTimeline/PeriodTest.php | 7 ++-- .../Entity/Weather/Forecast/PeriodTest.php | 6 +++- 10 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 src/Entity/Weather/Concern/HasPrecipitationProbability.php diff --git a/docs/one-call.md b/docs/one-call.md index 92d4536..587a48c 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -136,6 +136,15 @@ foreach ($timeline->periods() as $period) { } ``` +The 15-minute, one-hour, and one-day timelines normalize OpenWeather's +fractional precipitation probability to a percentage: + +```php +$period->precipitationProbability(); // 91.0 +$period->precipitationProbabilityUnit(); // Unit::PERCENT +$period->precipitationProbabilityWithUnit(); // '91 %' +``` + ## One-hour Timeline See OpenWeather's diff --git a/docs/weather.md b/docs/weather.md index 689ab3f..23494c6 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -111,6 +111,16 @@ echo $forecast->city()?->coordinates()?->longitude(); echo $forecast->city()?->timezoneOffset(); ``` +OpenWeather returns precipitation probability as a fraction. The library +normalizes it to a percentage so it follows the same getter pattern as other +measurements: + +```php +$period->precipitationProbability(); // 60.0 +$period->precipitationProbabilityUnit(); // Unit::PERCENT +$period->precipitationProbabilityWithUnit(); // '60 %' +``` + Forecast, sunrise, and sunset timestamps are nullable UTC `DateTimeImmutable` values. The city timezone offset remains separate. diff --git a/src/Entity/OneCall/OneDayTimeline/Period.php b/src/Entity/OneCall/OneDayTimeline/Period.php index 9678bd7..a2a6c8f 100644 --- a/src/Entity/OneCall/OneDayTimeline/Period.php +++ b/src/Entity/OneCall/OneDayTimeline/Period.php @@ -6,6 +6,7 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Clouds; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Condition; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasPrecipitationProbability; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Wind; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; @@ -16,6 +17,8 @@ final class Period implements EntityInterface { + use HasPrecipitationProbability; + /** * @param list $conditions * @param list $alertIds @@ -99,7 +102,9 @@ public static function fromArray(array $data, ?Context $context = null): static 'all' => $reader->nullableInt('clouds'), ], $context) : null, - precipitationProbability: $reader->nullableFloat('pop'), + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), conditions: $conditions, // Live daily responses return scalar precipitation, while the field table // still describes hourly objects: https://openweathermap.org/api/one-call-4 @@ -228,11 +233,6 @@ public function clouds(): ?Clouds return $this->clouds; } - public function precipitationProbability(): ?float - { - return $this->precipitationProbability; - } - /** * @return list */ diff --git a/src/Entity/OneCall/Timeline/WeatherPeriod.php b/src/Entity/OneCall/Timeline/WeatherPeriod.php index 9e58167..c4b139e 100644 --- a/src/Entity/OneCall/Timeline/WeatherPeriod.php +++ b/src/Entity/OneCall/Timeline/WeatherPeriod.php @@ -6,6 +6,7 @@ use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Clouds; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Condition; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasPrecipitationProbability; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Current\Precipitation; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Wind; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; @@ -20,6 +21,8 @@ */ abstract class WeatherPeriod implements EntityInterface { + use HasPrecipitationProbability; + /** * @param list $conditions * @param list $alertIds @@ -89,7 +92,9 @@ public static function fromArray(array $data, ?Context $context = null): static 'all' => $reader->nullableInt('clouds'), ], $context) : null, - precipitationProbability: $reader->nullableFloat('pop'), + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), conditions: $conditions, rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), @@ -214,11 +219,6 @@ public function clouds(): ?Clouds return $this->clouds; } - public function precipitationProbability(): ?float - { - return $this->precipitationProbability; - } - /** * @return list */ diff --git a/src/Entity/Weather/Concern/HasPrecipitationProbability.php b/src/Entity/Weather/Concern/HasPrecipitationProbability.php new file mode 100644 index 0000000..6d37d2d --- /dev/null +++ b/src/Entity/Weather/Concern/HasPrecipitationProbability.php @@ -0,0 +1,36 @@ +precipitationProbability; + } + + public function precipitationProbabilityUnit(): Unit + { + return Unit::PERCENT; + } + + public function precipitationProbabilityWithUnit(): ?string + { + return MeasurementFormatter::format( + $this->precipitationProbability, + $this->precipitationProbabilityUnit(), + ); + } + + private static function normalizePrecipitationProbability( + ?float $probability, + ): ?float { + // OpenWeather returns a fraction from zero to one, while the public + // getter presents the probability on the same percentage scale as + // other percentage measurements. + return $probability === null ? null : $probability * 100; + } +} diff --git a/src/Entity/Weather/Forecast/Period.php b/src/Entity/Weather/Forecast/Period.php index fb374af..16a3055 100644 --- a/src/Entity/Weather/Forecast/Period.php +++ b/src/Entity/Weather/Forecast/Period.php @@ -5,6 +5,7 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Clouds; +use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasPrecipitationProbability; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Concern\HasWeatherMeasurements; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Condition; use ProgrammatorDev\OpenWeatherMap\Entity\Weather\Wind; @@ -18,6 +19,7 @@ final class Period implements EntityInterface { + use HasPrecipitationProbability; use HasWeatherMeasurements; /** @@ -98,7 +100,9 @@ public static function fromArray(array $data, ?Context $context = null): static clouds: $clouds === null ? null : Clouds::fromArray($clouds, $context), wind: $wind === null ? null : Wind::fromArray($wind, $context), visibility: $reader->nullableInt('visibility'), - precipitationProbability: $reader->nullableFloat('pop'), + precipitationProbability: self::normalizePrecipitationProbability( + $reader->nullableFloat('pop'), + ), rain: $rain === null ? null : Precipitation::fromArray($rain, $context), snow: $snow === null ? null : Precipitation::fromArray($snow, $context), partOfDay: $partOfDay, @@ -147,11 +151,6 @@ public function wind(): ?Wind return $this->wind; } - public function precipitationProbability(): ?float - { - return $this->precipitationProbability; - } - public function rain(): ?Precipitation { return $this->rain; diff --git a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php index 503cf1b..4b21caf 100644 --- a/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/FifteenMinuteTimeline/PeriodTest.php @@ -45,6 +45,8 @@ public function testHydratesCapturedForecastPeriod(): void self::assertNull($period->wind()?->gust()); self::assertSame(67, $period->clouds()?->coverage()); self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); self::assertSame('Clouds', $period->conditions()[0]->group()); self::assertNull($period->rain()); self::assertNull($period->snow()); @@ -56,7 +58,8 @@ public function testHydratesCapturedRainConditionWithoutAmount(): void $period = self::fromFixture('one-call/fifteen-minute/rain.json', 16); self::assertSame('Rain', $period->conditions()[0]->group()); - self::assertSame(0.91, $period->precipitationProbability()); + self::assertSame(91.0, $period->precipitationProbability()); + self::assertSame('91 %', $period->precipitationProbabilityWithUnit()); self::assertNull($period->rain()); self::assertNull($period->snow()); } @@ -66,7 +69,7 @@ public function testHydratesCapturedSnowConditionAndAlertIdsWithoutAmount(): voi $period = self::fromFixture('one-call/fifteen-minute/snow-alerts.json'); self::assertSame('Snow', $period->conditions()[0]->group()); - self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(100.0, $period->precipitationProbability()); self::assertNull($period->rain()); self::assertNull($period->snow()); self::assertSame([ diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php index 0adfdac..590975a 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/OneDayTimeline/PeriodTest.php @@ -48,6 +48,8 @@ public function testHydratesCapturedDailyPeriod(): void self::assertNull($period->wind()?->gust()); self::assertSame(41, $period->clouds()?->coverage()); self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); self::assertSame('Clouds', $period->conditions()[0]->group()); self::assertNull($period->rain()); self::assertNull($period->snow()); @@ -76,6 +78,7 @@ public function testHydratesHistoricalAndForecastPeriodsWithoutClassification(): self::assertSame(1785456000, $historical->dateTime()?->getTimestamp()); self::assertNull($historical->precipitationProbability()); + self::assertNull($historical->precipitationProbabilityWithUnit()); self::assertSame(1785628800, $forecast->dateTime()?->getTimestamp()); self::assertSame(0.0, $forecast->precipitationProbability()); } diff --git a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php index b8856ba..752f7fe 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php @@ -36,6 +36,8 @@ public function testHydratesCapturedPeriod(): void self::assertSame(4.47, $period->wind()?->gust()); self::assertSame(48, $period->clouds()?->coverage()); self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); self::assertSame('Clouds', $period->conditions()[0]->group()); self::assertNull($period->rain()); self::assertNull($period->snow()); @@ -47,7 +49,8 @@ public function testHydratesCapturedRain(): void $period = self::fromFixture('one-call/one-hour/rain.json'); self::assertSame('Rain', $period->conditions()[0]->group()); - self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertSame('100 %', $period->precipitationProbabilityWithUnit()); self::assertSame(1.72, $period->rain()?->lastHour()); self::assertSame(Unit::MILLIMETERS_PER_HOUR, $period->rain()?->lastHourUnit()); self::assertSame('1.72 mm/h', $period->rain()?->lastHourWithUnit()); @@ -63,7 +66,7 @@ public function testHydratesCapturedSnowAndAlerts(): void ); self::assertSame('Snow', $period->conditions()[0]->group()); - self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(100.0, $period->precipitationProbability()); self::assertSame(2.54, $period->snow()?->lastHour()); self::assertSame('2.54 mm/h', $period->snow()?->lastHourWithUnit()); self::assertNull($period->rain()); diff --git a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php index 6cf02a6..486cbf0 100644 --- a/tests/Unit/Entity/Weather/Forecast/PeriodTest.php +++ b/tests/Unit/Entity/Weather/Forecast/PeriodTest.php @@ -49,6 +49,8 @@ public function testHydratesCapturedForecastPeriod(): void self::assertSame(4.91, $period->wind()?->gust()); self::assertSame(0.0, $period->precipitationProbability()); + self::assertSame(Unit::PERCENT, $period->precipitationProbabilityUnit()); + self::assertSame('0 %', $period->precipitationProbabilityWithUnit()); self::assertNull($period->rain()); self::assertNull($period->snow()); self::assertSame(PartOfDay::DAY, $period->partOfDay()); @@ -59,7 +61,8 @@ public function testHydratesConditionalRain(): void $period = self::fromFixture('weather/forecast/rain.json'); self::assertSame('Rain', $period->conditions()[0]->group()); - self::assertSame(1.0, $period->precipitationProbability()); + self::assertSame(100.0, $period->precipitationProbability()); + self::assertSame('100 %', $period->precipitationProbabilityWithUnit()); self::assertSame(5.49, $period->rain()?->lastThreeHours()); self::assertSame(Unit::MILLIMETER, $period->rain()?->lastThreeHoursUnit()); self::assertSame('5.49 mm', $period->rain()?->lastThreeHoursWithUnit()); @@ -133,6 +136,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($period->wind()); self::assertNull($period->visibility()); self::assertNull($period->precipitationProbability()); + self::assertNull($period->precipitationProbabilityWithUnit()); self::assertNull($period->rain()?->lastThreeHours()); self::assertNull($period->rain()?->lastThreeHoursWithUnit()); self::assertNull($period->snow()); From 9b0bfaf0c6025359e3109f270b9b6163c26c094e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:48:31 +0100 Subject: [PATCH 102/113] docs: clarify API usage guides --- README.md | 9 ++--- docs/air-pollution.md | 4 +- docs/geocoding.md | 4 +- docs/maps.md | 7 ++-- docs/one-call.md | 88 +++++++++++++++++++++++++------------------ docs/stations.md | 49 +++++++++++++++--------- docs/weather.md | 5 +++ 7 files changed, 101 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index f5d3668..1bf4ccb 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ composer require programmatordev/openweathermap-php-api ## Getting Started -Create the API client with an OpenWeather API key, then select a resource and -endpoint: +Create the API client with an OpenWeather API key, then choose an API and call +one of its methods: ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; @@ -68,9 +68,8 @@ $api = new OpenWeatherMap( ); ``` -Compatible resources can override those values for one fluent request chain. -The original client configuration and other resource instances remain -unchanged: +Weather and One Call requests can override those values for one fluent request +chain. The client-wide configuration remains unchanged for later requests: ```php $current = $api diff --git a/docs/air-pollution.md b/docs/air-pollution.md index bf96a5e..6d2a455 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -22,8 +22,8 @@ $current = $api->airPollution()->current( ); ``` -The returned `Current` entity exposes the observation directly. Every property -may be absent or explicitly `null`. +`current()` returns the air-quality observation for the requested coordinates. +Every property may be absent or explicitly `null`. ```php echo $current->coordinates()?->latitude(); diff --git a/docs/geocoding.md b/docs/geocoding.md index 70ecb4c..7133495 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -7,7 +7,9 @@ for API details. ## Lookup By Name -Use `byName()` with OpenWeather's comma-separated location query. The optional +Use `byName()` with a city name, optionally followed by a state code and +two-letter ISO 3166 country code: `city`, `city,country`, or +`city,state,country`. The state code is intended for US locations. The optional result limit must be between one and five; omit it to use the API default. ```php diff --git a/docs/maps.md b/docs/maps.md index 60b256f..19d18be 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -54,7 +54,7 @@ $url = $api->maps()->tileUrl( For example, the URL can be used as an image source: -```php +```html Precipitation map tile ``` @@ -63,8 +63,9 @@ credential and expose it only where direct client loading is intended. ## Generate A Tile URL Template -Use `tileUrlTemplate()` when an XYZ mapping library should replace the zoom, X, -and Y placeholders while loading the visible tiles. +Use `tileUrlTemplate()` with an XYZ mapping library. XYZ is a common map-tile +format in which the library replaces `{z}` with the zoom level, `{x}` with the +horizontal tile index, and `{y}` with the vertical tile index as the map moves. ```php $urlTemplate = $api->maps()->tileUrlTemplate( diff --git a/docs/one-call.md b/docs/one-call.md index 587a48c..de79e86 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -107,8 +107,9 @@ See OpenWeather's [official One Call 4.0 15-minute forecast documentation](https://openweathermap.org/api/one-call-4#15min) for API details. -Use `fifteenMinuteTimeline()` with a latitude and longitude to retrieve the -initial page of 15-minute forecast periods. +Use `fifteenMinuteTimeline()` with a latitude and longitude to retrieve +15-minute forecast periods. When `startAt` is omitted, OpenWeather starts the +timeline at the current UTC time. ```php $timeline = $api->oneCall()->fifteenMinuteTimeline( @@ -119,8 +120,9 @@ $timeline = $api->oneCall()->fifteenMinuteTimeline( ); ``` -Use `startAt` to select a future starting point and `count` to limit the -requested page size. +Use `startAt` to select a future starting point and `count` to limit the number +of periods returned. Both are optional, and `count` must be positive when +provided. The response exposes location metadata, up to 50 periods, and pagination when OpenWeather provides it. @@ -151,8 +153,9 @@ See OpenWeather's [official One Call 4.0 hourly forecast documentation](https://openweathermap.org/api/one-call-4#hourly) for API details. -Use `oneHourTimeline()` with a latitude and longitude to retrieve the default -hourly timeline. +Use `oneHourTimeline()` with a latitude and longitude to retrieve hourly +periods. When `startAt` is omitted, OpenWeather starts the timeline at the +current UTC time. ```php $timeline = $api->oneCall()->oneHourTimeline( @@ -161,8 +164,9 @@ $timeline = $api->oneCall()->oneHourTimeline( ); ``` -Pass an optional `DateTimeInterface` value to select a historical or future -starting point. Availability depends on OpenWeather. +Use `startAt` to select a historical or future starting point and `count` to +limit the number of periods returned. `count` must be positive when provided, +and timeline availability depends on OpenWeather. ```php $timeline = $api->oneCall()->oneHourTimeline( @@ -173,8 +177,6 @@ $timeline = $api->oneCall()->oneHourTimeline( ); ``` -The optional positive `count` limits the requested page size. - The response contains up to 20 periods. Historical and forecast periods expose their UTC date and time through `dateTime()`. @@ -194,8 +196,9 @@ See OpenWeather's [official One Call 4.0 daily forecast documentation](https://openweathermap.org/api/one-call-4#daily) for API details. -Use `oneDayTimeline()` with a latitude and longitude to retrieve the default -daily timeline. +Use `oneDayTimeline()` with a latitude and longitude to retrieve daily periods. +When `startAt` is omitted, OpenWeather starts the timeline at the current UTC +time. ```php $timeline = $api->oneCall()->oneDayTimeline( @@ -205,7 +208,8 @@ $timeline = $api->oneCall()->oneDayTimeline( ``` Use `startAt` to select a historical or future starting point and `count` to -limit the requested page size. +limit the number of periods returned. Both are optional, and `count` must be +positive when provided. ```php $timeline = $api->oneCall()->oneDayTimeline( @@ -216,9 +220,9 @@ $timeline = $api->oneCall()->oneDayTimeline( ); ``` -Daily periods provide UTC dates, astronomy, daily temperatures, weather -measurements, conditions, precipitation probability, rain, snow, and alert -references. +The response contains up to 10 periods. Daily periods provide UTC dates, +astronomy, daily temperatures, weather measurements, conditions, precipitation +probability, rain, snow, and alert references. ```php foreach ($timeline->periods() as $period) { @@ -241,6 +245,11 @@ values, so these getters return raw nullable floats. The 15-minute, one-hour, and one-day timelines provide explicit pagination. ```php +$timeline = $api->oneCall()->oneHourTimeline( + latitude: 38.7223, + longitude: -9.1393, +); + $pagination = $timeline->pagination(); if ($pagination->hasPreviousPage()) { @@ -269,32 +278,39 @@ See OpenWeather's [official One Call 4.0 weather alert documentation](https://openweathermap.org/api/one-call-4#alerts) for API details. -Current weather and timeline periods may provide alert IDs. Use `alert()` to -retrieve the corresponding alert. +Current weather and timeline periods may provide alert IDs. Use `alert()` with +one of those IDs to retrieve the corresponding alert. ```php -$alert = $api->oneCall()->alert($id); -``` +$current = $api->oneCall()->current( + latitude: 38.7223, + longitude: -9.1393, +); -Alerts provide sender and event information, validity dates, localized -descriptions, and tags. +$alertIds = $current->alertIds(); -```php -echo $alert->id(); -echo $alert->senderName(); -echo $alert->event(); -echo $alert->startsAt()?->format(DATE_ATOM); -echo $alert->endsAt()?->format(DATE_ATOM); -echo $alert->description('en-US'); - -foreach ($alert->descriptions() as $description) { - echo $description->languageCode(); - echo $description->text(); -} +if ($alertIds !== []) { + $alert = $api->oneCall()->alert($alertIds[0]); + + echo $alert->id(); + echo $alert->senderName(); + echo $alert->event(); + echo $alert->startsAt()?->format(DATE_ATOM); + echo $alert->endsAt()?->format(DATE_ATOM); + echo $alert->description('en-US'); -foreach ($alert->tags() as $tag) { - echo $tag; + foreach ($alert->descriptions() as $description) { + echo $description->languageCode(); + echo $description->text(); + } + + foreach ($alert->tags() as $tag) { + echo $tag; + } } ``` +Alerts provide sender and event information, validity dates, localized +descriptions, and tags. + `description()` returns the first exact language-code match or `null`. diff --git a/docs/stations.md b/docs/stations.md index 96939f2..5be2385 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -106,9 +106,7 @@ Create a `Measurement` with the observation time and available readings, then submit it for a station. ```php -use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; -use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; $measurement = new Measurement( dateTime: new DateTimeImmutable('now'), @@ -117,6 +115,24 @@ $measurement = new Measurement( windDirection: 180, pressure: 1012, humidity: 68, +); + +$api->stations()->submitMeasurement( + stationId: $station->id(), + measurement: $measurement, +); +``` + +Optional METAR cloud and weather observations can be included in a +measurement: + +```php +use ProgrammatorDev\OpenWeatherMap\Request\Stations\CloudLayer; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Measurement; +use ProgrammatorDev\OpenWeatherMap\Request\Stations\Weather; + +$measurement = new Measurement( + dateTime: new DateTimeImmutable('now'), clouds: [ new CloudLayer( distance: 1200, @@ -131,11 +147,6 @@ $measurement = new Measurement( ), ], ); - -$api->stations()->submitMeasurement( - stationId: $station->id(), - measurement: $measurement, -); ``` Use `submitMeasurements()` to send several observations in one request. @@ -161,10 +172,9 @@ standard METAR codes. See the [NOAA METAR reference](https://aviationweather.gov/help/data/#metar) for their meanings. -> **Upstream inconsistency:** OpenWeather documents `visibilityPrefix` as a -> compass-direction string, but its live API rejected a documented string value -> during verification. Omit this value unless OpenWeather clarifies or corrects -> the accepted type. +OpenWeather documents `visibilityPrefix` as a compass-direction string, but its +live API rejected a documented string value during verification. Leaving it +`null` avoids this upstream mismatch. Each `CloudLayer` represents one entry in OpenWeather's `clouds` array. Its distance, METAR cloud condition, and cumulus type are optional, but at least one @@ -175,10 +185,10 @@ available METAR precipitation, descriptor, intensity, proximity, obscuration, and other codes. At least one value must be provided, and codes are kept as strings so additional values accepted by OpenWeather are not restricted. -METAR visibility, cloud, and weather values appear to be write-only in this API. -A successful submission has no response body, and OpenWeather does not document -a method for retrieving the original measurement payload. These values -therefore could not be read back or verified after submission. +METAR visibility, cloud, and weather values appear to be submission-only in +this API. OpenWeather's aggregate response does not include them, and the +documentation does not provide an endpoint for retrieving the original +measurement payload. ## Retrieve Measurements @@ -203,7 +213,8 @@ $measurements = $api->stations()->measurements( The method returns an array of `MeasurementAggregate` entities and returns an empty array when no aggregates are available for the requested interval. Each -entity identifies its aggregation interval, bucket time, and station. +entity summarizes readings for one requested minute, hour, or day interval and +identifies the timestamp and station returned by OpenWeather. ```php foreach ($measurements as $measurement) { @@ -223,8 +234,10 @@ foreach ($measurements as $measurement) { Temperature and pressure aggregates expose `minimum()`, `maximum()`, `average()`, and `weight()`. Humidity exposes `average()` and `weight()`. Wind exposes `direction()` and `speed()`, while precipitation exposes `rain()` and -`snow()`. Measurement properties and nested structures are nullable because -OpenWeather may omit data that was unavailable for an aggregation bucket. +`snow()`. OpenWeather returns `weight` without documenting its meaning, so +`weight()` exposes the nullable integer unchanged. Measurement properties and +nested structures are nullable because OpenWeather may omit data that was +unavailable for an aggregation interval. The endpoint returns aggregates rather than the original submitted measurements. Submitted visibility, cloud layers, METAR weather descriptions, diff --git a/docs/weather.md b/docs/weather.md index 23494c6..2772323 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -37,6 +37,11 @@ echo $current->humidity(); echo $current->visibility(); ``` +`minimumTemperature()` and `maximumTemperature()` are the lowest and highest +temperatures currently observed within the requested location. OpenWeather +notes that they are mainly useful for geographically large cities; they are not +the day's forecast low and high. + Conditions, wind, and clouds are exposed as nested entities. A condition keeps the raw OpenWeather icon code and provides its absolute image URL. A response can contain multiple conditions; OpenWeather defines the first as the primary From b9d76ce40177091885278838e6f0592d53260351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 09:52:38 +0100 Subject: [PATCH 103/113] docs: refine alert and METAR examples --- docs/one-call.md | 6 ++---- docs/stations.md | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/one-call.md b/docs/one-call.md index de79e86..50c50a7 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -287,10 +287,8 @@ $current = $api->oneCall()->current( longitude: -9.1393, ); -$alertIds = $current->alertIds(); - -if ($alertIds !== []) { - $alert = $api->oneCall()->alert($alertIds[0]); +foreach ($current->alertIds() as $id) { + $alert = $api->oneCall()->alert($id); echo $alert->id(); echo $alert->senderName(); diff --git a/docs/stations.md b/docs/stations.md index 5be2385..bbddb8c 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -149,6 +149,11 @@ $measurement = new Measurement( ); ``` +METAR visibility, cloud, and weather values appear to be submission-only in +this API. OpenWeather's aggregate response does not include them, and the +documentation does not provide an endpoint for retrieving the original +measurement payload. + Use `submitMeasurements()` to send several observations in one request. ```php @@ -185,11 +190,6 @@ available METAR precipitation, descriptor, intensity, proximity, obscuration, and other codes. At least one value must be provided, and codes are kept as strings so additional values accepted by OpenWeather are not restricted. -METAR visibility, cloud, and weather values appear to be submission-only in -this API. OpenWeather's aggregate response does not include them, and the -documentation does not provide an endpoint for retrieving the original -measurement payload. - ## Retrieve Measurements Use `measurements()` to retrieve measurements aggregated by minute, hour, or From f65e91b747936ce39e5a1a3a607c402801194936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 10:13:04 +0100 Subject: [PATCH 104/113] docs: add setup configuration guide --- README.md | 5 +- docs/setup.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 docs/setup.md diff --git a/README.md b/README.md index 1bf4ccb..ee97adb 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,9 @@ documentation for the currently supported values. ## Documentation -The detailed guides cover each API's endpoints, response entities, and usage -examples: +See [Setup](docs/setup.md) to configure a custom HTTP client, cache, logger, +plugins, or request hooks. The API guides cover endpoints, response entities, +and usage examples: - [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..b4e7004 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,143 @@ +# Setup + +`OpenWeatherMap` uses PHP API SDK's `setup()` method for client-wide HTTP +configuration. Most applications can rely on PHP-HTTP discovery and use the +client without additional setup. Configure the following extension points when +the application needs to provide its own infrastructure or request behavior. + +```php +use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; + +$api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); +``` + +The examples below use infrastructure supplied by the application: +`$httpClient` implements PSR-18, `$cachePool` implements PSR-6, and `$logger` +implements PSR-3. + +Setup changes apply to subsequent requests made by the client. They do not +affect methods such as `maps()->tileUrl()` and `maps()->tileUrlTemplate()`, +which generate URLs without making an HTTP request. + +## HTTP Client + +PHP-HTTP discovery selects a compatible PSR-18 client and PSR-17 factories by +default. Packagist lists available +[PSR-18 client implementations](https://packagist.org/providers/psr/http-client-implementation) +and [PSR-17 factory implementations](https://packagist.org/providers/psr/http-factory-implementation). +Use `client()` to provide a specific PSR-18 client instead. + +```php +$api->setup()->client($httpClient); +``` + +The returned client builder can also receive custom request and stream +factories. + +See the official PHP API SDK +[HTTP client documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/09-http-client.md) +for all client and factory options. + +## Cache + +Use `cache()` with a PSR-6 cache pool to cache eligible HTTP responses. The +fallback TTL is used when a response does not provide a supported cache +directive. GET and HEAD requests are cacheable by default. Packagist lists +available [PSR-6 cache implementations](https://packagist.org/providers/psr/cache-implementation). + +```php +$api + ->setup() + ->cache($cachePool) + ->defaultTtl(300); +``` + +Cache configuration is client-wide. After configuring a pool, `withCache()` can +override cache behavior for one immutable request chain. + +```php +use ProgrammatorDev\Api\Builder\CacheBuilder; + +$current = $api + ->weather() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->current(latitude: 38.7223, longitude: -9.1393); +``` + +See the official PHP API SDK +[cache documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/10-cache.md) +for all global and request-local options. + +## Logging + +Use `logger()` with a PSR-3 logger. Packagist lists available +[PSR-3 logger implementations](https://packagist.org/providers/psr/log-implementation). + +```php +$api->setup()->logger($logger); +``` + +As with other HTTP logging, ensure the application's logging configuration does +not persist credentials or other sensitive request data. + +See the official PHP API SDK +[logging documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/11-logging.md) +for formatter and cache-logging details. + +## Plugins + +Use `plugins()` to add HTTPlug middleware. For example, a retry plugin can +retry failed requests or retryable responses. + +```php +use Http\Client\Common\Plugin\RetryPlugin; + +$retryPlugin = new RetryPlugin([ + 'retries' => 2, +]); + +$api->setup()->plugins()->add($retryPlugin, priority: 25); +``` + +Retries make additional OpenWeather requests and can affect quotas or billing. +Plugin priority controls middleware order. Priority `25` places this retry +plugin after authentication and before cache; consult the linked documentation +when choosing priorities for other plugins. + +See the official PHP API SDK +[plugin documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/12-plugins.md) +for middleware ordering and priority guidance. + +## Hooks + +Hooks run immediately before a request is sent or after its response is +received. They can inspect the API context and optionally return a modified +PSR-7 request or response. + +```php +use ProgrammatorDev\Api\Context\RequestContext; + +$api->setup()->hooks()->beforeRequest( + function (RequestContext $context) { + $request = $context->request(); + + // Modify the request here. + + return $request; + }, +); +``` + +Return a PSR-7 request to replace it for the remainder of the request pipeline, +or return `null` when no replacement is required. Response hooks follow the +same pattern with a PSR-7 response. + +See the official PHP API SDK +[hook documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/13-hooks.md) +for hook return values, context, ordering, and priorities. + +## Complete Setup Reference + +See the complete +[PHP API SDK documentation](https://github.com/programmatordev/php-api-sdk/tree/main/docs) +for every setup method, builder, and extension point. From 81276e4c54cd95ce37b6e5cf7549f7d4e0884013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 10:25:56 +0100 Subject: [PATCH 105/113] docs: add error handling guide --- README.md | 13 +++++++-- docs/errors.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 docs/errors.md diff --git a/README.md b/README.md index ee97adb..3a7cbb8 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,9 @@ documentation for the currently supported values. ## Documentation -See [Setup](docs/setup.md) to configure a custom HTTP client, cache, logger, -plugins, or request hooks. The API guides cover endpoints, response entities, -and usage examples: +### APIs + +These guides cover each API's endpoints, response entities, and usage examples: - [One Call 4.0](docs/one-call.md) - [Air Pollution](docs/air-pollution.md) @@ -100,6 +100,13 @@ and usage examples: - [Stations](docs/stations.md) - [Geocoding](docs/geocoding.md) +### Client Guides + +These guides cover configuration and behavior shared across the APIs: + +- [Setup](docs/setup.md) +- [Error Handling](docs/errors.md) + ## License This project is licensed under the [MIT License](LICENSE). diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..9c0dd18 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,78 @@ +# Error Handling + +The library distinguishes unsuccessful OpenWeather responses from local input, +transport, decoding, and response-mapping failures. + +## OpenWeather API Errors + +Every mapped HTTP error extends `ApiException`, so applications can catch one +type for all unsuccessful OpenWeather responses. + +```php +use ProgrammatorDev\OpenWeatherMap\Exception\ApiException; + +try { + $current = $api->weather()->current( + latitude: 38.7223, + longitude: -9.1393, + ); +} catch (ApiException $exception) { + echo $exception->getMessage(); + echo $exception->statusCode(); + echo $exception->apiCode(); + + $responseData = $exception->responseData(); +} +``` + +`getMessage()` returns OpenWeather's non-empty error message when one is +available. Otherwise, it describes the HTTP status. `statusCode()` always +returns the HTTP response status, while `apiCode()` returns OpenWeather's +numeric `cod` or `code` value when the response provides one. `responseData()` +exposes the decoded error payload. + +Known HTTP statuses use dedicated exceptions: + +| HTTP status | Exception | +| ---: | --- | +| `400` | `BadRequestException` | +| `401` | `UnauthorizedException` | +| `404` | `NotFoundException` | +| `429` | `TooManyRequestsException` | +| Other `4xx` or `5xx` | `UnexpectedErrorException` | + +Catch a specific exception before `ApiException` when the application needs +different behavior for that failure. + +```php +use ProgrammatorDev\OpenWeatherMap\Exception\ApiException; +use ProgrammatorDev\OpenWeatherMap\Exception\TooManyRequestsException; +use ProgrammatorDev\OpenWeatherMap\Exception\UnauthorizedException; + +try { + $current = $api->weather()->current(38.7223, -9.1393); +} catch (TooManyRequestsException $exception) { + // Defer or slow down further requests. +} catch (UnauthorizedException $exception) { + // Check the API key and its access to the requested product. +} catch (ApiException $exception) { + // Handle any other unsuccessful OpenWeather response. +} +``` + +## Other Failures + +Failures that occur before an OpenWeather error response is mapped do not +extend `ApiException`: + +| Type | Meaning | +| --- | --- | +| `InvalidArgumentException` | A method argument or client option is invalid, so no request is sent. | +| `Psr\Http\Client\ClientExceptionInterface` | The PSR-18 client could not complete the HTTP request. | +| `JsonException` | A successful response expected to contain JSON could not be decoded. | +| `HydrationException` | A known response property contains an invalid type or value. | +| `UnexpectedValueException` | A non-JSON response, such as a map tile, has an unexpected payload or media type. | + +Missing, explicitly `null`, conditional, and unknown response properties are +tolerated. `HydrationException` is reserved for known non-null properties whose +types or values do not match the response contract. From dd64ac1dbb9fcf081bd2f8c6645eb4640b5e171a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 10:29:18 +0100 Subject: [PATCH 106/113] docs: clarify client guide links --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a7cbb8..ad3293c 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,12 @@ These guides cover each API's endpoints, response entities, and usage examples: ### Client Guides -These guides cover configuration and behavior shared across the APIs: +These guides cover client configuration and failures shared across the APIs: -- [Setup](docs/setup.md) -- [Error Handling](docs/errors.md) +- [Setup](docs/setup.md) — Configure caching, logging, HTTP clients, plugins, + and request hooks. +- [Error Handling](docs/errors.md) — Handle OpenWeather API errors and client + failures. ## License From 3485d8c59b658e4ca80d781248af4257b23d8e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 10:34:00 +0100 Subject: [PATCH 107/113] docs: add version 4 upgrade notice --- README.md | 7 +++++++ UPGRADE-4.0.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 UPGRADE-4.0.md diff --git a/README.md b/README.md index ad3293c..5764c92 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,13 @@ These guides cover client configuration and failures shared across the APIs: - [Error Handling](docs/errors.md) — Handle OpenWeather API errors and client failures. +## Upgrading + +Version 4 is a complete rewrite without backward compatibility. Existing +integrations should treat it as a new implementation. See +[Upgrading To 4.0](UPGRADE-4.0.md) for the release expectations and current +baseline. + ## License This project is licensed under the [MIT License](LICENSE). diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md new file mode 100644 index 0000000..39151b0 --- /dev/null +++ b/UPGRADE-4.0.md @@ -0,0 +1,36 @@ +# Upgrading To 4.0 + +Version 4 is a complete rewrite of the library. Backward compatibility with +earlier releases is intentionally not preserved, and existing integrations +should treat this release as a new implementation even where the usage remains +familiar. + +## Upgrade Expectations + +- Previous resources, entities, methods, namespaces, and configuration are not + part of the current public contract. +- Compatibility aliases, deprecated transitional APIs, and an old-to-new API + mapping are not provided. +- Integrations should be rebuilt against the current [README](README.md) and + [API guides](README.md#apis). +- Application tests should be reviewed and updated before adopting the new + release. + +## Current Baseline + +- PHP 8.1 or later is required. +- The client is built on + [`programmatordev/php-api-sdk` 3](https://github.com/programmatordev/php-api-sdk). +- Current weather, forecasts, air pollution, geocoding, maps, stations, and One + Call 4.0 are supported. Access depends on the OpenWeather products enabled for + the API key. +- Metric units and English are the defaults, with client-wide configuration and + immutable request-local overrides where supported. +- Response entities tolerate missing, explicitly `null`, conditional, and + unknown fields. Known non-null fields with invalid types produce hydration + errors. +- OpenWeather API failures use a documented exception hierarchy; transport, + decoding, and hydration failures remain distinguishable. See + [Error Handling](docs/errors.md). + +The current documentation defines the supported behavior for this release. From 0ce9801e637e83e9cda23c8d149eae7bd5678e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 10:45:43 +0100 Subject: [PATCH 108/113] chore: complete release readiness cleanup --- .github/workflows/ci.yml | 6 +++++- .gitignore | 18 +++++++++++++++++- docs/stations.md | 6 +++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03e950..6a286be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,17 +10,21 @@ on: - main - "*.x" +permissions: + contents: read + jobs: tests: name: PHP ${{ matrix.php }} Test runs-on: ubuntu-latest strategy: + fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.gitignore b/.gitignore index f88b923..1e99abc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,25 @@ /composer.lock /composer.phar +/auth.json +/.env +/.env.* +!/.env.example /phpunit.xml /.phpunit.result.cache +/.phpunit.cache/ /vendor/ /logs/ -/.idea +/coverage/ +/clover.xml +/coverage.xml +/.idea/ +/.vscode/ +/.fleet/ /index.php /plans/ + +.DS_Store +Thumbs.db +*.swp +*.swo +*~ diff --git a/docs/stations.md b/docs/stations.md index bbddb8c..1ebe27c 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -3,9 +3,9 @@ The Weather Stations API lets you register and manage personal weather stations associated with your OpenWeather account. -It is available on OpenWeather's standard free and paid subscriptions. See the -[official Weather Stations documentation](https://openweathermap.org/api/stations) -for API details. +Availability depends on the products enabled for the OpenWeather account. See +the [official Weather Stations documentation](https://openweathermap.org/api/stations) +and current account configuration for access details. ## Create A Station From 057356a5c3437e12d6e91fb5791e2e7595368c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 11:04:54 +0100 Subject: [PATCH 109/113] ci: add composer validation and job timeout --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a286be..00460f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: tests: name: PHP ${{ matrix.php }} Test runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -33,6 +34,10 @@ jobs: tools: composer:v2 coverage: none + - name: Validate Composer configuration + if: matrix.php == '8.1' + run: composer validate --strict --no-check-lock + - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress From 0bcdaecfa4b3737417556f447f881808294a32b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 11:26:07 +0100 Subject: [PATCH 110/113] refactor(one-call): simplify timeline naming --- docs/one-call.md | 18 ++--- .../{OneDayTimeline.php => DayTimeline.php} | 4 +- .../FeelsLikeTemperature.php | 2 +- .../Period.php | 2 +- .../Temperature.php | 2 +- .../{OneHourTimeline.php => HourTimeline.php} | 4 +- .../Period.php | 2 +- src/Resource/OneCall.php | 20 +++--- .../FeelsLikeTemperatureTest.php | 4 +- .../PeriodTest.php | 4 +- .../TemperatureTest.php | 4 +- ...ayTimelineTest.php => DayTimelineTest.php} | 16 ++--- .../PeriodTest.php | 4 +- ...rTimelineTest.php => HourTimelineTest.php} | 18 ++--- tests/Unit/Resource/OneCallTest.php | 72 +++++++++---------- 15 files changed, 88 insertions(+), 88 deletions(-) rename src/Entity/OneCall/{OneDayTimeline.php => DayTimeline.php} (90%) rename src/Entity/OneCall/{OneDayTimeline => DayTimeline}/FeelsLikeTemperature.php (97%) rename src/Entity/OneCall/{OneDayTimeline => DayTimeline}/Period.php (99%) rename src/Entity/OneCall/{OneDayTimeline => DayTimeline}/Temperature.php (97%) rename src/Entity/OneCall/{OneHourTimeline.php => HourTimeline.php} (90%) rename src/Entity/OneCall/{OneHourTimeline => HourTimeline}/Period.php (63%) rename tests/Unit/Entity/OneCall/{OneDayTimeline => DayTimeline}/FeelsLikeTemperatureTest.php (96%) rename tests/Unit/Entity/OneCall/{OneDayTimeline => DayTimeline}/PeriodTest.php (99%) rename tests/Unit/Entity/OneCall/{OneDayTimeline => DayTimeline}/TemperatureTest.php (97%) rename tests/Unit/Entity/OneCall/{OneDayTimelineTest.php => DayTimelineTest.php} (92%) rename tests/Unit/Entity/OneCall/{OneHourTimeline => HourTimeline}/PeriodTest.php (98%) rename tests/Unit/Entity/OneCall/{OneHourTimelineTest.php => HourTimelineTest.php} (92%) diff --git a/docs/one-call.md b/docs/one-call.md index 50c50a7..960c452 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -147,18 +147,18 @@ $period->precipitationProbabilityUnit(); // Unit::PERCENT $period->precipitationProbabilityWithUnit(); // '91 %' ``` -## One-hour Timeline +## Hour Timeline See OpenWeather's [official One Call 4.0 hourly forecast documentation](https://openweathermap.org/api/one-call-4#hourly) for API details. -Use `oneHourTimeline()` with a latitude and longitude to retrieve hourly +Use `hourTimeline()` with a latitude and longitude to retrieve hourly periods. When `startAt` is omitted, OpenWeather starts the timeline at the current UTC time. ```php -$timeline = $api->oneCall()->oneHourTimeline( +$timeline = $api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -169,7 +169,7 @@ limit the number of periods returned. `count` must be positive when provided, and timeline availability depends on OpenWeather. ```php -$timeline = $api->oneCall()->oneHourTimeline( +$timeline = $api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, startAt: new DateTimeImmutable('2 days ago'), @@ -190,18 +190,18 @@ foreach ($timeline->periods() as $period) { } ``` -## One-day Timeline +## Day Timeline See OpenWeather's [official One Call 4.0 daily forecast documentation](https://openweathermap.org/api/one-call-4#daily) for API details. -Use `oneDayTimeline()` with a latitude and longitude to retrieve daily periods. +Use `dayTimeline()` with a latitude and longitude to retrieve daily periods. When `startAt` is omitted, OpenWeather starts the timeline at the current UTC time. ```php -$timeline = $api->oneCall()->oneDayTimeline( +$timeline = $api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -212,7 +212,7 @@ limit the number of periods returned. Both are optional, and `count` must be positive when provided. ```php -$timeline = $api->oneCall()->oneDayTimeline( +$timeline = $api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, startAt: new DateTimeImmutable('2 days from now'), @@ -245,7 +245,7 @@ values, so these getters return raw nullable floats. The 15-minute, one-hour, and one-day timelines provide explicit pagination. ```php -$timeline = $api->oneCall()->oneHourTimeline( +$timeline = $api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, ); diff --git a/src/Entity/OneCall/OneDayTimeline.php b/src/Entity/OneCall/DayTimeline.php similarity index 90% rename from src/Entity/OneCall/OneDayTimeline.php rename to src/Entity/OneCall/DayTimeline.php index 5e68377..f8615ad 100644 --- a/src/Entity/OneCall/OneDayTimeline.php +++ b/src/Entity/OneCall/DayTimeline.php @@ -5,11 +5,11 @@ use ProgrammatorDev\Api\Context\Context; use ProgrammatorDev\Api\Contract\EntityInterface; use ProgrammatorDev\OpenWeatherMap\Entity\Coordinates; -use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline\Period; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\DayTimeline\Period; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\Pagination; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Timeline\TimelinePage; -final class OneDayTimeline implements EntityInterface +final class DayTimeline implements EntityInterface { /** * @param TimelinePage $page diff --git a/src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php b/src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php similarity index 97% rename from src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php rename to src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php index f206717..4ceb62b 100644 --- a/src/Entity/OneCall/OneDayTimeline/FeelsLikeTemperature.php +++ b/src/Entity/OneCall/DayTimeline/FeelsLikeTemperature.php @@ -1,6 +1,6 @@ $page diff --git a/src/Entity/OneCall/OneHourTimeline/Period.php b/src/Entity/OneCall/HourTimeline/Period.php similarity index 63% rename from src/Entity/OneCall/OneHourTimeline/Period.php rename to src/Entity/OneCall/HourTimeline/Period.php index 8a43721..7ef4feb 100644 --- a/src/Entity/OneCall/OneHourTimeline/Period.php +++ b/src/Entity/OneCall/HourTimeline/Period.php @@ -1,6 +1,6 @@ endpoint() ->queries([ @@ -117,17 +117,17 @@ public function oneHourTimeline( 'lang' => $this->resolvedLanguage(), ]) ->get('/data/4.0/onecall/timeline/1h') - ->entity(OneHourTimeline::class); + ->entity(HourTimeline::class); return $timeline; } - public function oneDayTimeline( + public function dayTimeline( float $latitude, float $longitude, ?\DateTimeInterface $startAt = null, ?int $count = null, - ): OneDayTimeline { + ): DayTimeline { $latitude = Assert::latitude($latitude); $longitude = Assert::longitude($longitude); @@ -136,7 +136,7 @@ public function oneDayTimeline( } // https://openweathermap.org/api/one-call-4#daily - /** @var OneDayTimeline $timeline */ + /** @var DayTimeline $timeline */ $timeline = $this ->endpoint() ->queries([ @@ -148,7 +148,7 @@ public function oneDayTimeline( 'lang' => $this->resolvedLanguage(), ]) ->get('/data/4.0/onecall/timeline/1day') - ->entity(OneDayTimeline::class); + ->entity(DayTimeline::class); return $timeline; } diff --git a/tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php b/tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php similarity index 96% rename from tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php rename to tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php index a46aea9..ed2d034 100644 --- a/tests/Unit/Entity/OneCall/OneDayTimeline/FeelsLikeTemperatureTest.php +++ b/tests/Unit/Entity/OneCall/DayTimeline/FeelsLikeTemperatureTest.php @@ -1,12 +1,12 @@ coordinates()); self::assertNull($missing->timezone()); @@ -66,7 +66,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->pagination()->previousPage()); self::assertNull($missing->pagination()->nextPage()); - $timeline = OneDayTimeline::fromArray([ + $timeline = DayTimeline::fromArray([ 'lat' => null, 'timezone_offset' => null, 'data' => [ @@ -93,7 +93,7 @@ public function testRejectsInvalidKnownFields(array $data, string $message): voi $this->expectException(HydrationException::class); $this->expectExceptionMessage($message); - OneDayTimeline::fromArray($data); + DayTimeline::fromArray($data); } public static function invalidFields(): iterable diff --git a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php b/tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php similarity index 98% rename from tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php rename to tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php index 752f7fe..33813cf 100644 --- a/tests/Unit/Entity/OneCall/OneHourTimeline/PeriodTest.php +++ b/tests/Unit/Entity/OneCall/HourTimeline/PeriodTest.php @@ -1,12 +1,12 @@ Units::IMPERIAL, ])); - $timeline = OneHourTimeline::fromArray([ + $timeline = HourTimeline::fromArray([ 'data' => [['temp' => 72.5]], 'next' => '/data/4.0/onecall/timeline/1h?start=1785740400', ], $context); @@ -80,7 +80,7 @@ public function testHydratesWithConfigurationContextWithoutResolver(): void public function testToleratesMissingNullUnknownAndPartialFields(): void { - $missing = OneHourTimeline::fromArray([]); + $missing = HourTimeline::fromArray([]); self::assertNull($missing->coordinates()); self::assertNull($missing->timezone()); @@ -92,7 +92,7 @@ public function testToleratesMissingNullUnknownAndPartialFields(): void self::assertNull($missing->pagination()->previousPage()); self::assertNull($missing->pagination()->nextPage()); - $timeline = OneHourTimeline::fromArray([ + $timeline = HourTimeline::fromArray([ 'lat' => null, 'timezone_offset' => null, 'data' => [ @@ -119,7 +119,7 @@ public function testRejectsInvalidKnownFields(array $data, string $message): voi $this->expectException(HydrationException::class); $this->expectExceptionMessage($message); - OneHourTimeline::fromArray($data); + HourTimeline::fromArray($data); } public static function invalidFields(): iterable diff --git a/tests/Unit/Resource/OneCallTest.php b/tests/Unit/Resource/OneCallTest.php index 1b738c9..60eb6bd 100644 --- a/tests/Unit/Resource/OneCallTest.php +++ b/tests/Unit/Resource/OneCallTest.php @@ -8,8 +8,8 @@ use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\Current; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\FifteenMinuteTimeline; use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\MinuteTimeline; -use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneDayTimeline; -use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\OneHourTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\DayTimeline; +use ProgrammatorDev\OpenWeatherMap\Entity\OneCall\HourTimeline; use ProgrammatorDev\OpenWeatherMap\Enum\Unit; use ProgrammatorDev\OpenWeatherMap\Enum\Units; use ProgrammatorDev\OpenWeatherMap\Test\Support\ApiTestCase; @@ -298,17 +298,17 @@ public function testFifteenMinuteTimelineAcceptsFluentConfiguration(): void ], $this->query($request)); } - public function testGetsOneHourTimelineByCoordinates(): void + public function testGetsHourTimelineByCoordinates(): void { $this->respondWithFixture('one-call/one-hour/success.json'); - $timeline = $this->api->oneCall()->oneHourTimeline( + $timeline = $this->api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, ); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneHourTimeline::class, $timeline); + self::assertInstanceOf(HourTimeline::class, $timeline); self::assertCount(20, $timeline->periods()); self::assertSame(1785668400, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertStringContainsString( @@ -330,11 +330,11 @@ public function testGetsOneHourTimelineByCoordinates(): void ], $this->query($request)); } - public function testGetsOneHourTimelineFromStart(): void + public function testGetsHourTimelineFromStart(): void { $this->respondWithFixture('one-call/one-hour/history.json'); - $timeline = $this->api->oneCall()->oneHourTimeline( + $timeline = $this->api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, startAt: new \DateTimeImmutable('@1785495600'), @@ -352,14 +352,14 @@ public function testGetsOneHourTimelineFromStart(): void ], $this->query($request)); } - public function testGetsNextOneHourTimelinePage(): void + public function testGetsNextHourTimelinePage(): void { $this->respondWithFixture('one-call/one-hour/success.json'); $this->client->addResponse(new Response( body: '{"data":[{"dt":1785740400}]}', )); - $timeline = $this->api->oneCall()->oneHourTimeline( + $timeline = $this->api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -369,7 +369,7 @@ public function testGetsNextOneHourTimelinePage(): void $nextPage = $timeline->pagination()->nextPage(); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneHourTimeline::class, $nextPage); + self::assertInstanceOf(HourTimeline::class, $nextPage); self::assertSame(1785740400, $nextPage->periods()[0]->dateTime()?->getTimestamp()); self::assertCount(2, $this->client->getRequests()); self::assertSame('GET', $request->getMethod()); @@ -386,14 +386,14 @@ public function testGetsNextOneHourTimelinePage(): void ], $this->query($request)); } - public function testGetsPreviousOneHourTimelinePage(): void + public function testGetsPreviousHourTimelinePage(): void { $this->respondWithFixture('one-call/one-hour/success.json'); $this->client->addResponse(new Response( body: '{"data":[{"dt":1785596400}]}', )); - $timeline = $this->api->oneCall()->oneHourTimeline( + $timeline = $this->api->oneCall()->hourTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -403,7 +403,7 @@ public function testGetsPreviousOneHourTimelinePage(): void $previousPage = $timeline->pagination()->previousPage(); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneHourTimeline::class, $previousPage); + self::assertInstanceOf(HourTimeline::class, $previousPage); self::assertSame(1785596400, $previousPage->periods()[0]->dateTime()?->getTimestamp()); self::assertCount(2, $this->client->getRequests()); self::assertSame('GET', $request->getMethod()); @@ -420,7 +420,7 @@ public function testGetsPreviousOneHourTimelinePage(): void ], $this->query($request)); } - public function testOneHourTimelineAcceptsFluentConfiguration(): void + public function testHourTimelineAcceptsFluentConfiguration(): void { $this->client->addResponse(new Response( body: '{"data":[{"temp":72.5}]}', @@ -430,7 +430,7 @@ public function testOneHourTimelineAcceptsFluentConfiguration(): void ->oneCall() ->withUnits(Units::IMPERIAL) ->withLanguage('pt') - ->oneHourTimeline(38.7223, -9.1393, count: 3); + ->hourTimeline(38.7223, -9.1393, count: 3); $request = $this->client->getLastRequest(); self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperatureUnit()); @@ -440,17 +440,17 @@ public function testOneHourTimelineAcceptsFluentConfiguration(): void self::assertSame('pt', $this->query($request)['lang']); } - public function testGetsOneDayTimelineByCoordinates(): void + public function testGetsDayTimelineByCoordinates(): void { $this->respondWithFixture('one-call/one-day/success.json'); - $timeline = $this->api->oneCall()->oneDayTimeline( + $timeline = $this->api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, ); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneDayTimeline::class, $timeline); + self::assertInstanceOf(DayTimeline::class, $timeline); self::assertCount(10, $timeline->periods()); self::assertSame(1785628800, $timeline->periods()[0]->dateTime()?->getTimestamp()); self::assertStringContainsString( @@ -472,11 +472,11 @@ public function testGetsOneDayTimelineByCoordinates(): void ], $this->query($request)); } - public function testGetsOneDayTimelineFromStart(): void + public function testGetsDayTimelineFromStart(): void { $this->respondWithFixture('one-call/one-day/history.json'); - $timeline = $this->api->oneCall()->oneDayTimeline( + $timeline = $this->api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, startAt: new \DateTimeImmutable('@1785456000'), @@ -494,14 +494,14 @@ public function testGetsOneDayTimelineFromStart(): void ], $this->query($request)); } - public function testGetsNextOneDayTimelinePage(): void + public function testGetsNextDayTimelinePage(): void { $this->respondWithFixture('one-call/one-day/success.json'); $this->client->addResponse(new Response( body: '{"data":[{"dt":1786492800}]}', )); - $timeline = $this->api->oneCall()->oneDayTimeline( + $timeline = $this->api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -511,7 +511,7 @@ public function testGetsNextOneDayTimelinePage(): void $nextPage = $timeline->pagination()->nextPage(); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneDayTimeline::class, $nextPage); + self::assertInstanceOf(DayTimeline::class, $nextPage); self::assertSame(1786492800, $nextPage->periods()[0]->dateTime()?->getTimestamp()); self::assertCount(2, $this->client->getRequests()); self::assertSame('GET', $request->getMethod()); @@ -528,14 +528,14 @@ public function testGetsNextOneDayTimelinePage(): void ], $this->query($request)); } - public function testGetsPreviousOneDayTimelinePage(): void + public function testGetsPreviousDayTimelinePage(): void { $this->respondWithFixture('one-call/one-day/success.json'); $this->client->addResponse(new Response( body: '{"data":[{"dt":1784764800}]}', )); - $timeline = $this->api->oneCall()->oneDayTimeline( + $timeline = $this->api->oneCall()->dayTimeline( latitude: 38.7223, longitude: -9.1393, ); @@ -545,7 +545,7 @@ public function testGetsPreviousOneDayTimelinePage(): void $previousPage = $timeline->pagination()->previousPage(); $request = $this->client->getLastRequest(); - self::assertInstanceOf(OneDayTimeline::class, $previousPage); + self::assertInstanceOf(DayTimeline::class, $previousPage); self::assertSame(1784764800, $previousPage->periods()[0]->dateTime()?->getTimestamp()); self::assertCount(2, $this->client->getRequests()); self::assertSame('GET', $request->getMethod()); @@ -562,7 +562,7 @@ public function testGetsPreviousOneDayTimelinePage(): void ], $this->query($request)); } - public function testOneDayTimelineAcceptsFluentConfigurationAndCount(): void + public function testDayTimelineAcceptsFluentConfigurationAndCount(): void { $this->client->addResponse(new Response( body: '{"data":[{"temp":{"day":72.5}}]}', @@ -572,7 +572,7 @@ public function testOneDayTimelineAcceptsFluentConfigurationAndCount(): void ->oneCall() ->withUnits(Units::IMPERIAL) ->withLanguage('pt') - ->oneDayTimeline(38.7223, -9.1393, count: 3); + ->dayTimeline(38.7223, -9.1393, count: 3); $request = $this->client->getLastRequest(); self::assertSame(Unit::FAHRENHEIT, $timeline->periods()[0]->temperature()?->dayUnit()); @@ -619,7 +619,7 @@ public function testFifteenMinuteTimelineRejectsInvalidCoordinates( } #[DataProvider('invalidCoordinates')] - public function testOneHourTimelineRejectsInvalidCoordinates( + public function testHourTimelineRejectsInvalidCoordinates( float $latitude, float $longitude, string $message, @@ -627,11 +627,11 @@ public function testOneHourTimelineRejectsInvalidCoordinates( $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - $this->api->oneCall()->oneHourTimeline($latitude, $longitude); + $this->api->oneCall()->hourTimeline($latitude, $longitude); } #[DataProvider('invalidCoordinates')] - public function testOneDayTimelineRejectsInvalidCoordinates( + public function testDayTimelineRejectsInvalidCoordinates( float $latitude, float $longitude, string $message, @@ -639,7 +639,7 @@ public function testOneDayTimelineRejectsInvalidCoordinates( $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); - $this->api->oneCall()->oneDayTimeline($latitude, $longitude); + $this->api->oneCall()->dayTimeline($latitude, $longitude); } public function testFifteenMinuteTimelineRejectsInvalidCount(): void @@ -650,20 +650,20 @@ public function testFifteenMinuteTimelineRejectsInvalidCount(): void $this->api->oneCall()->fifteenMinuteTimeline(38.7223, -9.1393, count: 0); } - public function testOneHourTimelineRejectsInvalidCount(): void + public function testHourTimelineRejectsInvalidCount(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The timeline count must be at least 1.'); - $this->api->oneCall()->oneHourTimeline(38.7223, -9.1393, count: 0); + $this->api->oneCall()->hourTimeline(38.7223, -9.1393, count: 0); } - public function testOneDayTimelineRejectsInvalidCount(): void + public function testDayTimelineRejectsInvalidCount(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The timeline count must be at least 1.'); - $this->api->oneCall()->oneDayTimeline(38.7223, -9.1393, count: 0); + $this->api->oneCall()->dayTimeline(38.7223, -9.1393, count: 0); } public static function invalidCoordinates(): iterable From 9e5db00e55fcc60fb6a08b0f12ae3f1fba2970fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 11:48:35 +0100 Subject: [PATCH 111/113] docs: simplify API usage guidance --- AGENTS.md | 13 +++++------ README.md | 11 +++++----- UPGRADE-4.0.md | 23 +++++++++----------- docs/air-pollution.md | 5 ++--- docs/errors.md | 8 +++---- docs/geocoding.md | 3 +-- docs/maps.md | 5 ++--- docs/one-call.md | 50 ++++++++++++++++++++----------------------- docs/setup.md | 25 +++++++++++----------- docs/stations.md | 18 ++++++++-------- docs/weather.md | 9 +++----- 11 files changed, 79 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e983070..be36252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,13 +10,13 @@ Composer with PSR-4 autoloading under the ## Sources Of Truth - Use the official OpenWeather documentation for endpoint paths, parameters, - response fields, availability, and subscription constraints. + response fields, and current access requirements. - Use the installed PHP API SDK documentation and source for its supported authoring patterns. - Read existing resources, entities, tests, and documentation before changing related behavior. -- Do not infer API availability from OpenWeather documentation sidebars; verify - it against the current official API catalog or pricing information. +- Do not infer API access from documentation sidebars; verify it against the + current official API documentation. ## Code Changes @@ -71,6 +71,7 @@ Composer with PSR-4 autoloading under the - Update public documentation alongside implemented API areas. - Keep method signatures, examples, supported endpoints, and response entities aligned with the implementation. -- Clearly distinguish standard free-plan APIs from APIs requiring separate or - paid subscriptions. -- Document potentially billable or destructive behavior prominently. +- Avoid hard-coded claims about plans, prices, quotas, or allowances. Link to + the official OpenWeather documentation for current access requirements. +- Document destructive behavior and actions that send additional requests + prominently. diff --git a/README.md b/README.md index 5764c92..fbb3d21 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,8 @@ [![Tests](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/openweathermap-php-api/actions/workflows/ci.yml?query=branch%3Amain) A fluent PHP client for OpenWeather APIs covering current and forecast weather, -air pollution, geocoding, maps, stations, and One Call. Responses are mapped to -typed entities that safely handle conditional, missing, and `null` data while -keeping common requests concise. +air pollution, geocoding, maps, stations, and One Call. Responses use typed +entities that safely handle conditional, missing, and `null` data. The library is built on [`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk) @@ -45,9 +44,9 @@ echo $current->temperature(); echo $current->temperatureWithUnit(); ``` -Response properties may be missing or explicitly `null`, so entity getters -return nullable values where appropriate. Collection getters return empty -arrays when the corresponding response collection is absent or `null`. +Response properties may be missing or `null`, so getters return nullable values +where appropriate. Collection getters return empty arrays when the response +does not contain that collection. ## Configuration diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md index 39151b0..8381561 100644 --- a/UPGRADE-4.0.md +++ b/UPGRADE-4.0.md @@ -1,16 +1,15 @@ # Upgrading To 4.0 -Version 4 is a complete rewrite of the library. Backward compatibility with -earlier releases is intentionally not preserved, and existing integrations -should treat this release as a new implementation even where the usage remains -familiar. +Version 4 is a complete rewrite. It is not backward compatible with earlier +releases. Existing integrations should adopt it as a new implementation, even +where the usage looks familiar. ## Upgrade Expectations - Previous resources, entities, methods, namespaces, and configuration are not part of the current public contract. -- Compatibility aliases, deprecated transitional APIs, and an old-to-new API - mapping are not provided. +- Compatibility aliases, transitional APIs, and an old-to-new API mapping are + not provided. - Integrations should be rebuilt against the current [README](README.md) and [API guides](README.md#apis). - Application tests should be reviewed and updated before adopting the new @@ -22,13 +21,11 @@ familiar. - The client is built on [`programmatordev/php-api-sdk` 3](https://github.com/programmatordev/php-api-sdk). - Current weather, forecasts, air pollution, geocoding, maps, stations, and One - Call 4.0 are supported. Access depends on the OpenWeather products enabled for - the API key. -- Metric units and English are the defaults, with client-wide configuration and - immutable request-local overrides where supported. -- Response entities tolerate missing, explicitly `null`, conditional, and - unknown fields. Known non-null fields with invalid types produce hydration - errors. + Call 4.0 are supported. +- Metric units and English are the defaults. They can be configured for the + client or changed for one request chain where supported. +- Response entities accept missing, `null`, conditional, and unknown fields. + Known non-null fields with invalid types produce hydration errors. - OpenWeather API failures use a documented exception hierarchy; transport, decoding, and hydration failures remain distinguishable. See [Error Handling](docs/errors.md). diff --git a/docs/air-pollution.md b/docs/air-pollution.md index 6d2a455..180d95a 100644 --- a/docs/air-pollution.md +++ b/docs/air-pollution.md @@ -1,7 +1,6 @@ # Air Pollution -Current, forecast, and historical air pollution are included in OpenWeather's -standard free and paid subscriptions. +This guide covers current, forecast, and historical air pollution. ## Current @@ -103,7 +102,7 @@ Forecast periods use the same OpenWeather Air Quality Index and fixed The Historical Air Pollution API returns hourly observations for a coordinate and date range. See the [official Air Pollution API documentation](https://openweathermap.org/api/air-pollution) -for availability and API details. +for API details. Use `history()` with a latitude, longitude, start date, and end date. The date arguments accept any `DateTimeInterface` implementation. The end must be after diff --git a/docs/errors.md b/docs/errors.md index 9c0dd18..792ccf6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,7 +1,7 @@ # Error Handling -The library distinguishes unsuccessful OpenWeather responses from local input, -transport, decoding, and response-mapping failures. +The library separates OpenWeather API errors from invalid input, HTTP client +failures, decoding errors, and invalid response data. ## OpenWeather API Errors @@ -54,7 +54,7 @@ try { } catch (TooManyRequestsException $exception) { // Defer or slow down further requests. } catch (UnauthorizedException $exception) { - // Check the API key and its access to the requested product. + // Check the API key and whether it can use this endpoint. } catch (ApiException $exception) { // Handle any other unsuccessful OpenWeather response. } @@ -71,7 +71,7 @@ extend `ApiException`: | `Psr\Http\Client\ClientExceptionInterface` | The PSR-18 client could not complete the HTTP request. | | `JsonException` | A successful response expected to contain JSON could not be decoded. | | `HydrationException` | A known response property contains an invalid type or value. | -| `UnexpectedValueException` | A non-JSON response, such as a map tile, has an unexpected payload or media type. | +| `UnexpectedValueException` | A non-JSON response, such as a map tile, has unexpected content or a wrong content type. | Missing, explicitly `null`, conditional, and unknown response properties are tolerated. `HydrationException` is reserved for known non-null properties whose diff --git a/docs/geocoding.md b/docs/geocoding.md index 7133495..e4b7940 100644 --- a/docs/geocoding.md +++ b/docs/geocoding.md @@ -1,7 +1,6 @@ # Geocoding -The Geocoding API is available on OpenWeather's standard free and paid -subscriptions. See the +See the [official Geocoding API documentation](https://openweathermap.org/api/geocoding-api) for API details. diff --git a/docs/maps.md b/docs/maps.md index 19d18be..39893a4 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -1,8 +1,7 @@ # Maps Weather Maps API 1.0 provides current cloud, precipitation, sea-level pressure, -wind-speed, and temperature overlays. It is available on OpenWeather's standard -free and paid subscriptions. See the +wind-speed, and temperature overlays. See the [official Weather Maps documentation](https://openweathermap.org/api/weathermaps) for API details. @@ -85,7 +84,7 @@ The returned format can be passed to [Leaflet](https://leafletjs.com/reference.html#tilelayer), [OpenLayers](https://openlayers.org/en/latest/apidoc/module-ol_source_XYZ-XYZ.html), or a [MapLibre raster source](https://maplibre.org/maplibre-style-spec/sources/). -Like a concrete tile URL, the template contains the API key and does not make an +Like a specific tile URL, the template contains the API key and does not make an HTTP request when generated. ## Tile Coordinates diff --git a/docs/one-call.md b/docs/one-call.md index 960c452..6d63b83 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -1,15 +1,14 @@ # One Call 4.0 -One Call 4.0 requires a separate OpenWeather subscription and includes free -daily API calls. Consult OpenWeather's official documentation for the current -allowance, pricing, usage limits, and account configuration before using these -endpoints in production. +OpenWeather manages access and usage terms for One Call 4.0. Consult the +[official One Call documentation](https://openweathermap.org/api/one-call-4) +for current requirements before using these endpoints. ## Current See OpenWeather's [official One Call API 4.0 documentation](https://openweathermap.org/api/one-call-4#current) -for API details and current subscription terms. +for API details. Use `current()` with a latitude and longitude. @@ -24,9 +23,9 @@ $current = $api->oneCall()->current( ); ``` -Every response property may be absent or explicitly `null`. Coordinates and -timezone metadata describe the requested location, while `dateTime()` and the -astronomical timestamps remain UTC values. +Every response property may be absent or `null`. Coordinates and timezone +details describe the requested location. `dateTime()`, sunrise, and sunset use +UTC. ```php echo $current->coordinates()?->latitude(); @@ -53,7 +52,7 @@ $current = $api ->current(38.7223, -9.1393); ``` -Measurement getters return nullable values. Companion methods provide the unit +Measurement getters return nullable values. Related methods provide the unit and a formatted value. With the default metric configuration, for example: ```php @@ -80,7 +79,7 @@ $timeline = $api->oneCall()->minuteTimeline( ); ``` -The response exposes location metadata and forecast periods. Each period +The response contains location details and forecast periods. Each period provides its UTC date and time, precipitation, and any referenced alert IDs. ```php @@ -124,7 +123,7 @@ Use `startAt` to select a future starting point and `count` to limit the number of periods returned. Both are optional, and `count` must be positive when provided. -The response exposes location metadata, up to 50 periods, and pagination when +The response contains location details, up to 50 periods, and pagination when OpenWeather provides it. ```php @@ -138,8 +137,8 @@ foreach ($timeline->periods() as $period) { } ``` -The 15-minute, one-hour, and one-day timelines normalize OpenWeather's -fractional precipitation probability to a percentage: +Precipitation probability follows the same getter pattern as other +measurements: ```php $period->precipitationProbability(); // 91.0 @@ -177,8 +176,8 @@ $timeline = $api->oneCall()->hourTimeline( ); ``` -The response contains up to 20 periods. Historical and forecast periods expose -their UTC date and time through `dateTime()`. +The response contains up to 20 periods. `dateTime()` returns each period's UTC +date and time. ```php foreach ($timeline->periods() as $period) { @@ -220,9 +219,9 @@ $timeline = $api->oneCall()->dayTimeline( ); ``` -The response contains up to 10 periods. Daily periods provide UTC dates, -astronomy, daily temperatures, weather measurements, conditions, precipitation -probability, rain, snow, and alert references. +The response contains up to 10 periods. Daily periods provide UTC dates, sun +and moon times, daily temperatures, weather measurements, conditions, +precipitation probability, rain, snow, and alert references. ```php foreach ($timeline->periods() as $period) { @@ -237,12 +236,12 @@ foreach ($timeline->periods() as $period) { } ``` -OpenWeather does not currently define units for the daily scalar rain and snow -values, so these getters return raw nullable floats. +OpenWeather does not define units for the daily `rain` and `snow` values, so +these getters return nullable floats without conversion. ## Timeline Pagination -The 15-minute, one-hour, and one-day timelines provide explicit pagination. +The 15-minute, one-hour, and one-day timelines support pagination. ```php $timeline = $api->oneCall()->hourTimeline( @@ -266,11 +265,8 @@ echo $pagination->nextPageUrl(); The availability checks and URL getters do not make another API request. `previousPage()` and `nextPage()` request the corresponding page when its URL -is available and otherwise return `null`. Pagination does not iterate -automatically. Every pagination request counts as a separate One Call API call -under your OpenWeather subscription; consult the -[official documentation](https://openweathermap.org/api/one-call-4#pagination) -for current usage and billing terms. +is available and otherwise return `null`. The library does not fetch every page +automatically. Each page navigation sends a separate API request. ## Alert @@ -308,7 +304,7 @@ foreach ($current->alertIds() as $id) { } ``` -Alerts provide sender and event information, validity dates, localized +Alerts provide sender and event information, start and end times, localized descriptions, and tags. `description()` returns the first exact language-code match or `null`. diff --git a/docs/setup.md b/docs/setup.md index b4e7004..db2afb5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -2,8 +2,8 @@ `OpenWeatherMap` uses PHP API SDK's `setup()` method for client-wide HTTP configuration. Most applications can rely on PHP-HTTP discovery and use the -client without additional setup. Configure the following extension points when -the application needs to provide its own infrastructure or request behavior. +client without additional setup. Use the options below when the application +needs its own HTTP services or request behavior. ```php use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; @@ -11,7 +11,7 @@ use ProgrammatorDev\OpenWeatherMap\OpenWeatherMap; $api = new OpenWeatherMap($_ENV['OPENWEATHERMAP_API_KEY']); ``` -The examples below use infrastructure supplied by the application: +The examples below use objects supplied by the application: `$httpClient` implements PSR-18, `$cachePool` implements PSR-6, and `$logger` implements PSR-3. @@ -31,7 +31,7 @@ Use `client()` to provide a specific PSR-18 client instead. $api->setup()->client($httpClient); ``` -The returned client builder can also receive custom request and stream +`client()` returns a builder that can also receive custom request and stream factories. See the official PHP API SDK @@ -40,9 +40,9 @@ for all client and factory options. ## Cache -Use `cache()` with a PSR-6 cache pool to cache eligible HTTP responses. The -fallback TTL is used when a response does not provide a supported cache -directive. GET and HEAD requests are cacheable by default. Packagist lists +Use `cache()` with a PSR-6 cache pool to cache supported HTTP responses. The +default TTL sets the cache lifetime when a response does not provide one. GET +and HEAD requests are cacheable by default. Packagist lists available [PSR-6 cache implementations](https://packagist.org/providers/psr/cache-implementation). ```php @@ -53,7 +53,8 @@ $api ``` Cache configuration is client-wide. After configuring a pool, `withCache()` can -override cache behavior for one immutable request chain. +change cache behavior for one request chain without changing the client-wide +settings. ```php use ProgrammatorDev\Api\Builder\CacheBuilder; @@ -99,7 +100,7 @@ $retryPlugin = new RetryPlugin([ $api->setup()->plugins()->add($retryPlugin, priority: 25); ``` -Retries make additional OpenWeather requests and can affect quotas or billing. +Retries may send additional OpenWeather requests. Plugin priority controls middleware order. Priority `25` places this retry plugin after authentication and before cache; consult the linked documentation when choosing priorities for other plugins. @@ -128,9 +129,9 @@ $api->setup()->hooks()->beforeRequest( ); ``` -Return a PSR-7 request to replace it for the remainder of the request pipeline, -or return `null` when no replacement is required. Response hooks follow the -same pattern with a PSR-7 response. +Return a PSR-7 request to use it for the current request, or return `null` to +keep the original request. Response hooks follow the same pattern with a PSR-7 +response. See the official PHP API SDK [hook documentation](https://github.com/programmatordev/php-api-sdk/blob/main/docs/13-hooks.md) diff --git a/docs/stations.md b/docs/stations.md index 1ebe27c..a4b76b4 100644 --- a/docs/stations.md +++ b/docs/stations.md @@ -3,9 +3,9 @@ The Weather Stations API lets you register and manage personal weather stations associated with your OpenWeather account. -Availability depends on the products enabled for the OpenWeather account. See -the [official Weather Stations documentation](https://openweathermap.org/api/stations) -and current account configuration for access details. +See the +[official Weather Stations documentation](https://openweathermap.org/api/stations) +for API details. ## Create A Station @@ -74,10 +74,10 @@ foreach ($stations as $station) { } ``` -Core station properties are required because they describe station metadata +The main station properties are required because they describe the station registered with OpenWeather. Creation and update times are returned as UTC -`DateTimeImmutable` values. Registration responses may also populate -`userId()` and `sourceType()`; other station responses omit them. +`DateTimeImmutable` values. The create response may also include `userId()` and +`sourceType()`; other station responses omit them. ## Find A Station @@ -207,9 +207,9 @@ $measurements = $api->stations()->measurements( ); ``` -> **Processing delay:** Submitted measurements are aggregated asynchronously -> and may take more than 24 hours to appear. OpenWeather does not document an -> availability timeframe. +> **Processing delay:** OpenWeather processes submitted measurements in the +> background. They may take more than 24 hours to appear, and OpenWeather does +> not document how long processing should take. The method returns an array of `MeasurementAggregate` entities and returns an empty array when no aggregates are available for the requested interval. Each diff --git a/docs/weather.md b/docs/weather.md index 2772323..cd6e5de 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -2,8 +2,7 @@ ## Current -The Current Weather API is available on OpenWeather's standard free and paid -subscriptions. See the +See the [official Current Weather API documentation](https://openweathermap.org/api/current) for API details. @@ -75,8 +74,7 @@ from UTC in seconds. ## Forecast -The 5 Day / 3 Hour Forecast API is available on OpenWeather's standard free -and paid subscriptions. See the +See the [official forecast documentation](https://openweathermap.org/api/forecast5) for API details. @@ -116,8 +114,7 @@ echo $forecast->city()?->coordinates()?->longitude(); echo $forecast->city()?->timezoneOffset(); ``` -OpenWeather returns precipitation probability as a fraction. The library -normalizes it to a percentage so it follows the same getter pattern as other +Precipitation probability follows the same getter pattern as other measurements: ```php From 7f0039a4bf068732de9e3e2fc837548b16136114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 13:23:54 +0100 Subject: [PATCH 112/113] docs: clarify API usage details --- README.md | 5 +++-- docs/maps.md | 17 +++++++-------- docs/one-call.md | 54 ++++++++++++++++++++++++++---------------------- docs/setup.md | 6 +++--- docs/weather.md | 11 +++++----- 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index fbb3d21..34505f5 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ air pollution, geocoding, maps, stations, and One Call. Responses use typed entities that safely handle conditional, missing, and `null` data. The library is built on -[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk) -and supports client-wide and request-local configuration. +[`programmatordev/php-api-sdk`](https://github.com/programmatordev/php-api-sdk), +which provides HTTP client discovery and optional caching, logging, plugins, +and request hooks. ## Requirements diff --git a/docs/maps.md b/docs/maps.md index 39893a4..13f9b65 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -106,12 +106,11 @@ For example: | 2 | 4 × 4 | 0–3 | | 6 | 64 × 64 | 0–63 | -Zoom must be zero or greater. At any zoom level, the largest valid X or Y value -is `(2 ** $zoom) - 1`. Mapping libraries normally calculate these indexes from -the displayed geographic area; they should not be replaced directly with a -location's longitude and latitude. - -Applications displaying Weather Maps data must provide visible OpenWeather -attribution. Consult the -[official FAQ](https://openweathermap.org/faq) -for the current attribution requirements. +Zoom must be zero or greater. X and Y start at 0, and their highest valid value +is one less than the number of tiles along that axis, as shown in the table. +Mapping libraries normally calculate these indexes from the displayed +geographic area; they should not be replaced directly with a location's +longitude and latitude. + +OpenWeather's attribution requirements depend on the applicable license. See +the [official FAQ](https://openweathermap.org/faq) for current guidance. diff --git a/docs/one-call.md b/docs/one-call.md index 6d63b83..f64e392 100644 --- a/docs/one-call.md +++ b/docs/one-call.md @@ -39,30 +39,6 @@ foreach ($current->conditions() as $condition) { } ``` -Configure units and language for a request: - -```php -use ProgrammatorDev\OpenWeatherMap\Enum\Language; -use ProgrammatorDev\OpenWeatherMap\Enum\Units; - -$current = $api - ->oneCall() - ->withUnits(Units::IMPERIAL) - ->withLanguage(Language::PORTUGUESE) - ->current(38.7223, -9.1393); -``` - -Measurement getters return nullable values. Related methods provide the unit -and a formatted value. With the default metric configuration, for example: - -```php -use ProgrammatorDev\OpenWeatherMap\Enum\Unit; - -$current->temperature(); // 24.34 -$current->temperatureUnit(); // Unit::CELSIUS -$current->temperatureWithUnit(); // '24.34 °C' -``` - ## Minute Timeline See OpenWeather's @@ -241,7 +217,7 @@ these getters return nullable floats without conversion. ## Timeline Pagination -The 15-minute, one-hour, and one-day timelines support pagination. +The 15-minute, hour, and day timelines support pagination. ```php $timeline = $api->oneCall()->hourTimeline( @@ -268,6 +244,34 @@ The availability checks and URL getters do not make another API request. is available and otherwise return `null`. The library does not fetch every page automatically. Each page navigation sends a separate API request. +## Units And Language + +Current and timeline requests use the client configuration by default. Use +`withUnits()` and `withLanguage()` to change those values for one request +chain. + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Language; +use ProgrammatorDev\OpenWeatherMap\Enum\Units; + +$current = $api + ->oneCall() + ->withUnits(Units::IMPERIAL) + ->withLanguage(Language::PORTUGUESE) + ->current(38.7223, -9.1393); +``` + +Measurement getters return nullable values. Related methods provide the unit +and a formatted value. With the default metric configuration, for example: + +```php +use ProgrammatorDev\OpenWeatherMap\Enum\Unit; + +$current->temperature(); // 24.34 +$current->temperatureUnit(); // Unit::CELSIUS +$current->temperatureWithUnit(); // '24.34 °C' +``` + ## Alert See OpenWeather's diff --git a/docs/setup.md b/docs/setup.md index db2afb5..b420233 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -15,9 +15,9 @@ The examples below use objects supplied by the application: `$httpClient` implements PSR-18, `$cachePool` implements PSR-6, and `$logger` implements PSR-3. -Setup changes apply to subsequent requests made by the client. They do not -affect methods such as `maps()->tileUrl()` and `maps()->tileUrlTemplate()`, -which generate URLs without making an HTTP request. +Setup changes apply to later requests made by the client. Some methods do not +send an HTTP request and are therefore unaffected. Their API guides note this +where relevant. ## HTTP Client diff --git a/docs/weather.md b/docs/weather.md index cd6e5de..855d870 100644 --- a/docs/weather.md +++ b/docs/weather.md @@ -36,9 +36,9 @@ echo $current->humidity(); echo $current->visibility(); ``` -`minimumTemperature()` and `maximumTemperature()` are the lowest and highest -temperatures currently observed within the requested location. OpenWeather -notes that they are mainly useful for geographically large cities; they are not +`minimumTemperature()` and `maximumTemperature()` are OpenWeather's optional +minimum and maximum temperatures for the city at the current moment. They are +mainly useful for large cities and often match `temperature()`. They are not the day's forecast low and high. Conditions, wind, and clouds are exposed as nested entities. A condition keeps @@ -78,8 +78,9 @@ See the [official forecast documentation](https://openweathermap.org/api/forecast5) for API details. -Use `forecast()` with a latitude and longitude. The optional `count` limits the -number of three-hour periods returned and must be positive. +Use `forecast()` with a latitude and longitude to retrieve up to five days of +weather forecasts, with one period every three hours. The optional `count` +limits the number of periods returned and must be positive. ```php $forecast = $api->weather()->forecast( From 9ed07dd6de14dd248519b3f350842d6268847a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Fri, 14 Aug 2026 13:28:21 +0100 Subject: [PATCH 113/113] chore: update Composer package metadata --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e383134..f7fe0f5 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "programmatordev/openweathermap-php-api", - "description": "OpenWeatherMap PHP library that provides convenient access to the OpenWeatherMap API", + "description": "A fluent PHP client for OpenWeather APIs, including weather, air pollution, geocoding, maps, stations, and One Call", "type": "library", - "keywords": ["openweathermap", "api", "php", "php8", "sdk", "psr-18", "psr-17", "psr-6", "psr-3"], + "keywords": ["openweather", "openweathermap", "weather", "forecast", "air-quality", "geocoding", "api", "php", "php8", "sdk", "psr-18", "psr-17", "psr-6", "psr-3"], "license": "MIT", "authors": [ {

OYkvxT+v$m*wPhqNFrg1H$EfDiNi-TQ%nfh{ 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 D?{CLT literal 0 HcmV?d00001 diff --git a/tests/Fixtures/weather-maps/tile/pressure-new.meta.json b/tests/Fixtures/weather-maps/tile/pressure-new.meta.json new file mode 100644 index 0000000..adaf02f --- /dev/null +++ b/tests/Fixtures/weather-maps/tile/pressure-new.meta.json @@ -0,0 +1,22 @@ +{ + "provenance": "captured", + "product": "Weather Maps API", + "endpoint": "Map tile", + "apiVersion": "1.0", + "capturedAt": "2026-08-08T10:22:04Z", + "httpStatus": 200, + "contentType": "image/png", + "bodyFormat": "png", + "bodyBytes": 66276, + "sha256": "b5310fd2b200e551ee3d9943e5b45ad6b71907bf8fa5f12de5060b6217ac8b55", + "image": { + "width": 256, + "height": 256 + }, + "request": { + "method": "GET", + "path": "/map/pressure_new/1/1/1.png", + "query": {} + }, + "sanitization": [] +} diff --git a/tests/Fixtures/weather-maps/tile/pressure-new.png b/tests/Fixtures/weather-maps/tile/pressure-new.png new file mode 100644 index 0000000000000000000000000000000000000000..cedf8ae20090ca0d6970a1b3aa4b4d64e4cebf56 GIT binary patch literal 66276 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%0Axi_b)~u${zzT->mE3(60kpKV;)uYvF;7 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 yCp4SI@D?u&XNzx5b9#o`q$U&*5# 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