From 8d1fd5607302f3abae85ff936bfaba8151d35eeb Mon Sep 17 00:00:00 2001 From: Henrik Panhans Date: Tue, 1 Oct 2024 12:12:35 +0200 Subject: [PATCH] One Call API 3.0 (#13) Signed-off-by: Henrik Panhans --- .github/workflows/documentation.yml | 54 +- .github/workflows/swift.yml | 79 +- .gitignore | 4 + .vscode/launch.json | 15 - CODE_OF_CONDUCT.md | 26 +- HPOpenWeather.podspec | 32 - Package.resolved | 114 +- Package.swift | 27 +- README.md | 94 +- Scripts/build-docc-archive | 39 + Scripts/configure-hooks | 10 + Scripts/convert-coverage-report | 54 + Scripts/format-swift-code | 10 + Scripts/lint-swift-code | 10 + .../Documentation.docc/Documentation.md | 93 + .../CLLocationCoordinate+Extensions.swift | 2 +- .../Extensions/NSError+Extensions.swift | 17 - Sources/HPOpenWeather/Models/City.swift | 23 - .../Models/DailyTemperature.swift | 24 +- .../Models/Forecasts/BasicWeather.swift | 30 - .../Models/Forecasts/CurrentWeather.swift | 113 +- .../Models/Forecasts/DailyForecast.swift | 98 +- .../Models/Forecasts/ForecastBase.swift | 38 + .../Models/Forecasts/HourlyForecast.swift | 95 +- .../Models/Forecasts/MinutelyForecast.swift | 17 + Sources/HPOpenWeather/Models/Moon.swift | 11 + .../HPOpenWeather/Models/Precipitation.swift | 18 +- Sources/HPOpenWeather/Models/Sun.swift | 10 +- .../HPOpenWeather/Models/Temperature.swift | 32 +- .../Models/Weather+Language.swift | 52 + .../HPOpenWeather/Models/Weather+Units.swift | 35 + Sources/HPOpenWeather/Models/Weather.swift | 72 + .../HPOpenWeather/Models/WeatherAlert.swift | 28 +- .../Models/WeatherCondition.swift | 10 +- .../HPOpenWeather/Models/WeatherIcon.swift | 23 +- Sources/HPOpenWeather/Models/Wind.swift | 22 +- Sources/HPOpenWeather/OpenWeather.swift | 111 +- .../HPOpenWeather/OpenWeatherAPIError.swift | 23 + Sources/HPOpenWeather/OpenWeatherError.swift | 23 + .../Requests/APINetworkRequest.swift | 30 - .../Requests/ExcludableField.swift | 9 + .../Requests/WeatherRequest+Combine.swift | 25 - .../WeatherRequest+ExcludableField.swift | 13 - .../Requests/WeatherRequest.swift | 69 +- .../Response/WeatherResponse+Language.swift | 54 - .../Response/WeatherResponse+Units.swift | 41 - .../Response/WeatherResponse.swift | 35 - .../HPOpenWeatherTests.swift | 112 -- .../HPOpenWeatherTests/OpenWeatherTests.swift | 100 + .../Resources/3-0-test-response.json | 1713 +++++++++++++++++ Tests/HPOpenWeatherTests/TestSecret.swift | 7 - .../HPOpenWeatherTests/WeatherIconTests.swift | 14 +- Tests/HPOpenWeatherTests/WeatherTests.swift | 33 + config/periphery.yml | 1 + config/swift-format.json | 67 + 55 files changed, 2958 insertions(+), 953 deletions(-) delete mode 100644 .vscode/launch.json delete mode 100644 HPOpenWeather.podspec create mode 100755 Scripts/build-docc-archive create mode 100755 Scripts/configure-hooks create mode 100755 Scripts/convert-coverage-report create mode 100755 Scripts/format-swift-code create mode 100755 Scripts/lint-swift-code create mode 100644 Sources/HPOpenWeather/Documentation.docc/Documentation.md delete mode 100644 Sources/HPOpenWeather/Extensions/NSError+Extensions.swift delete mode 100644 Sources/HPOpenWeather/Models/City.swift delete mode 100644 Sources/HPOpenWeather/Models/Forecasts/BasicWeather.swift create mode 100644 Sources/HPOpenWeather/Models/Forecasts/ForecastBase.swift create mode 100644 Sources/HPOpenWeather/Models/Forecasts/MinutelyForecast.swift create mode 100644 Sources/HPOpenWeather/Models/Moon.swift create mode 100644 Sources/HPOpenWeather/Models/Weather+Language.swift create mode 100644 Sources/HPOpenWeather/Models/Weather+Units.swift create mode 100644 Sources/HPOpenWeather/Models/Weather.swift create mode 100644 Sources/HPOpenWeather/OpenWeatherAPIError.swift create mode 100644 Sources/HPOpenWeather/OpenWeatherError.swift delete mode 100644 Sources/HPOpenWeather/Requests/APINetworkRequest.swift create mode 100644 Sources/HPOpenWeather/Requests/ExcludableField.swift delete mode 100644 Sources/HPOpenWeather/Requests/WeatherRequest+Combine.swift delete mode 100644 Sources/HPOpenWeather/Requests/WeatherRequest+ExcludableField.swift delete mode 100644 Sources/HPOpenWeather/Response/WeatherResponse+Language.swift delete mode 100644 Sources/HPOpenWeather/Response/WeatherResponse+Units.swift delete mode 100644 Sources/HPOpenWeather/Response/WeatherResponse.swift delete mode 100644 Tests/HPOpenWeatherTests/HPOpenWeatherTests.swift create mode 100644 Tests/HPOpenWeatherTests/OpenWeatherTests.swift create mode 100644 Tests/HPOpenWeatherTests/Resources/3-0-test-response.json delete mode 100644 Tests/HPOpenWeatherTests/TestSecret.swift create mode 100644 Tests/HPOpenWeatherTests/WeatherTests.swift create mode 100644 config/periphery.yml create mode 100644 config/swift-format.json diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 2cb9a5c..cfc9bac 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,14 +1,12 @@ -name: Pages Deploy +name: Documentation on: push: - branches: [ main ] - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write + branches: ["main"] + paths: + - "Sources/**/*.swift" + - "Tests/**/*.swift" + - "**/*.md" # Allow one concurrent deployment concurrency: @@ -16,29 +14,35 @@ concurrency: cancel-in-progress: true jobs: - # Single deploy job since we're just deploying - deploy: + deploy-pages: + name: Deploy Documentation to GitHub Pages + runs-on: macos-14 environment: - # Must be set to this for deploying to GitHub Pages name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: macos-12 + permissions: + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source steps: - - name: Checkout 🛎️ - uses: actions/checkout@v3 + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- - name: Build DocC - run: | - swift package --allow-writing-to-directory ./docs \ - generate-documentation --target HPOpenWeather \ - --transform-for-static-hosting \ - --hosting-base-path HPOpenWeather \ - --output-path ./docs - echo "" > docs/index.html + run: Scripts/build-docc-archive HPOpenWeather - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: - # Upload only docs directory - path: 'docs' + path: ${{ runner.temp }}/docs - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 123af42..5df0065 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -2,25 +2,70 @@ name: Swift on: push: - branches: [ main ] + branches: [main] + paths: + - "Sources/**/*.swift" + - "Tests/**/*.swift" pull_request: - branches: [ main ] + branches: [main] + paths: + - "Sources/**/*.swift" + - "Tests/**/*.swift" jobs: - build: - - runs-on: macos-latest - + test-swift: + name: Test Swift Code + runs-on: macos-14 steps: - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: latest-stable - - uses: actions/checkout@v2 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v - env: + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + - name: Build + run: swift build -v + - name: Run tests + run: swift test --enable-code-coverage -v + env: API_KEY: ${{ secrets.API_KEY }} - - name: Codecov - uses: codecov/codecov-action@v2 \ No newline at end of file + - name: Convert coverage report + continue-on-error: true + run: Scripts/convert-coverage-report --target HPOpenWeatherPackageTests + - name: Upload coverage reports to Codecov + continue-on-error: true + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: henrik-dmg/HPOpenWeather + + lint-code: + name: Lint Swift Code + runs-on: macos-14 + steps: + - name: Configure Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout Code + uses: actions/checkout@v4 + - name: Cache SPM dependencies + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + - name: Install SwiftLint + run: brew install swift-format peripheryapp/periphery/periphery + - name: Lint code + run: Scripts/lint-swift-code + - name: Scan for dead code + run: periphery scan --strict --config config/periphery.yml diff --git a/.gitignore b/.gitignore index c4c0743..ed8d367 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ fastlane/test_output iOSInjectionProject/ .swiftpm settings.json +/docs +/coverage + +**/.DS_Store \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index b7e1bb4..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "configurations": [ - { - "type": "lldb", - "request": "launch", - "name": "Test HPOpenWeather", - "program": "/Applications/Xcode.app/Contents/Developer/usr/bin/xctest", - "args": [ - ".build/debug/HPOpenWeatherPackageTests.xctest" - ], - "cwd": "${workspaceFolder:HPOpenWeather}", - "preLaunchTask": "swift: Build All" - } - ] -} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 77b84c7..e24371c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation. Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities diff --git a/HPOpenWeather.podspec b/HPOpenWeather.podspec deleted file mode 100644 index e1044ce..0000000 --- a/HPOpenWeather.podspec +++ /dev/null @@ -1,32 +0,0 @@ -Pod::Spec.new do |s| - - s.name = "HPOpenWeather" - s.version = "5.0.0" - s.summary = "Cross-platform framework to communicate with the OpenWeatherMap JSON API" - - s.license = { :type => "MIT", :file => "LICENSE.md" } - s.homepage = "https://panhans.dev/opensource/hpopenweather" - - s.author = { "henrik-dmg" => "henrik@panhans.dev" } - s.social_media_url = "https://twitter.com/henrik_dmg" - - s.ios.deployment_target = "13.0" - s.watchos.deployment_target = "7.0" - s.tvos.deployment_target = "13.0" - s.osx.deployment_target = "10.15" - - s.source = { :git => 'https://github.com/henrik-dmg/HPOpenWeather.git', :tag => s.version } - - s.source_files = "Sources/**/*.swift" - - s.framework = "Foundation" - s.ios.framework = "UIKit" - s.watchos.framework = "UIKit" - s.tvos.framework = "UIKit" - - s.swift_version = "5.5" - s.requires_arc = true - s.dependency "HPNetwork", "~> 3.1.1" - s.dependency "HPURLBuilder", "~> 1.0.0" - -end diff --git a/Package.resolved b/Package.resolved index 0ca497e..fa54949 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,88 +1,32 @@ { - "object": { - "pins": [ - { - "package": "HPNetwork", - "repositoryURL": "https://github.com/henrik-dmg/HPNetwork", - "state": { - "branch": null, - "revision": "fba08b668713bad984ad4d76bf99b55e9192fcce", - "version": "3.1.2" - } - }, - { - "package": "HPURLBuilder", - "repositoryURL": "https://github.com/henrik-dmg/HPURLBuilder", - "state": { - "branch": null, - "revision": "49ad1fb6f10914e7134dc9f8e5c21f48a52e3d37", - "version": "1.1.0" - } - }, - { - "package": "swift-argument-parser", - "repositoryURL": "https://github.com/apple/swift-argument-parser.git", - "state": { - "branch": null, - "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1", - "version": "1.1.4" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": "main", - "revision": "a3217706ad049ca058743003e065767773cc56cc", - "version": null - } - }, - { - "package": "SymbolKit", - "repositoryURL": "https://github.com/apple/swift-docc-symbolkit", - "state": { - "branch": "main", - "revision": "b45d1f2ed151d057b54504d653e0da5552844e34", - "version": null - } - }, - { - "package": "swift-format", - "repositoryURL": "https://github.com/apple/swift-format", - "state": { - "branch": "main", - "revision": "e5875f32d37d0de760bd4ca3b988f42373866f96", - "version": null - } - }, - { - "package": "SwiftSyntax", - "repositoryURL": "https://github.com/apple/swift-syntax.git", - "state": { - "branch": "main", - "revision": "1e61cc3bd13c0f61d75e509994e00e64fecf8bf3", - "version": null - } - }, - { - "package": "swift-system", - "repositoryURL": "https://github.com/apple/swift-system.git", - "state": { - "branch": null, - "revision": "836bc4557b74fe6d2660218d56e3ce96aff76574", - "version": "1.1.1" - } - }, - { - "package": "swift-tools-support-core", - "repositoryURL": "https://github.com/apple/swift-tools-support-core.git", - "state": { - "branch": null, - "revision": "284a41800b7c5565512ec6ae21ee818aac1f84ac", - "version": "0.4.0" - } + "pins" : [ + { + "identity" : "hpnetwork", + "kind" : "remoteSourceControl", + "location" : "https://github.com/henrik-dmg/HPNetwork", + "state" : { + "revision" : "a4629039573f695f97fc044032c4e654b40671b0", + "version" : "4.0.1" } - ] - }, - "version": 1 + }, + { + "identity" : "hpurlbuilder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/henrik-dmg/HPURLBuilder", + "state" : { + "revision" : "49ad1fb6f10914e7134dc9f8e5c21f48a52e3d37", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd", + "version" : "1.3.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index f223b28..65a130e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,32 +6,37 @@ import PackageDescription let package = Package( name: "HPOpenWeather", platforms: [ - .iOS(.v13), .tvOS(.v13), .watchOS(.v7), .macOS(.v10_15) + .iOS(.v15), .tvOS(.v15), .watchOS(.v6), .macOS(.v12), ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "HPOpenWeather", targets: ["HPOpenWeather"] - ) + ) ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/henrik-dmg/HPNetwork", from: "3.0.0"), - .package(url: "https://github.com/henrik-dmg/HPURLBuilder", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), - .package(url: "https://github.com/apple/swift-format", branch: "main") + .package(url: "https://github.com/henrik-dmg/HPNetwork", from: "4.0.0"), + .package(url: "https://github.com/henrik-dmg/HPURLBuilder", from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "HPOpenWeather", - dependencies: ["HPNetwork", "HPURLBuilder"] - ), + dependencies: [ + .product(name: "HPNetworkMock", package: "HPNetwork"), + "HPURLBuilder", + ] + ), .testTarget( name: "HPOpenWeatherTests", - dependencies: ["HPOpenWeather"] - ) + dependencies: [ + "HPOpenWeather", + .product(name: "HPNetworkMock", package: "HPNetwork"), + ], + resources: [.process("Resources")] + ), ] ) diff --git a/README.md b/README.md index 4dd798e..14d5f69 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,20 @@ # HPOpenWeather -CodeFactor - -Github Actions +[![codecov](https://codecov.io/gh/henrik-dmg/HPOpenWeather/graph/badge.svg?token=mX05cXr144)](https://codecov.io/gh/henrik-dmg/HPOpenWeather) +[![Swift](https://github.com/henrik-dmg/HPOpenWeather/actions/workflows/swift.yml/badge.svg)](https://github.com/henrik-dmg/HPOpenWeather/actions/workflows/swift.yml) [![GitHub license](https://img.shields.io/github/license/henrik-dmg/HPOpenWeather)](https://github.com/henrik-dmg/HPOpenWeather/blob/master/LICENSE.md) HPOpenWeather is a cross-platform Swift framework to communicate with the OpenWeather One-Call API. See their [documentation](https://openweathermap.org/api/one-call-api) for further details. ## Installation -HPOpenWeather supports iOS 13.0+, watchOS 7.0+, tvOS 13.0+ and macOS 10.15+. +`HPOpenWeather` supports iOS 15.0+, watchOS 6.0+, tvOS 15.0+ and macOS 12+. +It can be installed via SPM: -### SPM - -Add `.package(url: "https://github.com/henrik-dmg/HPOpenWeather", from: "5.0.0")` to your `Package.swift` file - -### CocoaPods - -Add `pod 'HPOpenWeather'` to your `Podfile` and run `pod install` - -## Usage - -To get started, you need an API key from [OpenWeather](https://openweathermap.org). Put this API key in the initialiser, additionally you can also specify a custom temperature format and/or language used in the responses (see list for available languages and units below). - -```swift -import HPOpenWeather - -// Assign API key -OpenWeather.shared.apiKey = "--- YOUR API KEY ---" -OpenWeather.shared.language = .german -OpenWeather.shared.units = .metric - -// Or use options -let settings = OpenWeather.Settings(apiKey: "yourAPIKey", language: .german, units: .metric) -OpenWeather.shared.apply(settings) -``` - -You can also customise the response data units and language by accessing the `language` and `units` propertis. - -### Making a request - -To make a request, initialize a new request object like this - -```swift -let request = WeatherRequest(coordinate: .init(latitude: 40, longitude: 30)) ``` - -Or to request weather data from the past: - -```swift -let timemachineRequest = WeatherRequest(coordinate: .init(latitude: 40, longitude: 30), date: someDate) +.package(url: "https://github.com/henrik-dmg/HPOpenWeather", from: "6.0.0") ``` -**Note:** the date has to be at least 6 hours in the past - -To post a request, call `sendWeatherRequest` on `OpenWeather`: - -```swift -// Classic completion handler approach -OpenWeather.shared.schedule(request) { result in - switch result { - case .success(let response): - // do something with weather data here - case .failure(let error): - // handle error - } -} - -// Or using the new concurrency features -let response = try await OpenWeather.shared.weatherResponse(request) -``` - -### Available languages (default in bold) - -- **English** -- Russian -- Italian -- Spanish -- Ukrainian -- German -- Portuguese -- Romanian -- Polish -- Finnish -- Dutch -- French -- Bulgarian -- Swedish -- Chinese Traditional -- Chinese Simplified -- Turkish -- Croatian -- Catalan - -### Available units (default in bold) +## Documentation -- **Metric** (wind speed in m/s, temperature in Celsius) -- Imperial (wind speed in mph, temperature in Fahrenheit) -- Standard (wind speed in m/s, temperature in Kelvin) +The documentation has been moved to https://henrik-dmg.github.io/HPOpenWeather, which is deployed from the DocC catalog. diff --git a/Scripts/build-docc-archive b/Scripts/build-docc-archive new file mode 100755 index 0000000..1ef153a --- /dev/null +++ b/Scripts/build-docc-archive @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +if [[ -z $RUNNER_TEMP ]]; then + echo "RUNNER_TEMP is not set. Setting to root of repository." + RUNNER_TEMP=$(git rev-parse --show-toplevel) +fi + +TARGET=$1 +HOSTING_BASE_PATH=$2 + +if [[ -z $HOSTING_BASE_PATH ]]; then + HOSTING_BASE_PATH=$TARGET +fi + +# First, insert docc-plugin dependency. This is very hacky, but it avoids everyone having to pull in the docc-plugin when they use this library. + +sed '/Dependencies declare/a\ +.package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"),\ +' "Package.swift" > "Package.tmp" + +mv "Package.tmp" "Package.swift" + +swift package resolve + +swift package \ + --allow-writing-to-directory "$RUNNER_TEMP/docs" \ + generate-documentation \ + --target "$TARGET" \ + --transform-for-static-hosting \ + --hosting-base-path "$HOSTING_BASE_PATH" \ + --output-path "$RUNNER_TEMP/docs" + +CUSTOM_PATH=$(echo $HOSTING_BASE_PATH | tr '[:upper:]' '[:lower:]') +echo "" > "$RUNNER_TEMP/docs/index.html" + +if [[ -z $GITHUB_ACTIONS ]]; then + echo "Restoring Package.swift to original state." + sed -i '' '/swift-docc-plugin/d' "Package.swift" +fi \ No newline at end of file diff --git a/Scripts/configure-hooks b/Scripts/configure-hooks new file mode 100755 index 0000000..a83bfc4 --- /dev/null +++ b/Scripts/configure-hooks @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +GIT_ROOT=$(git rev-parse --show-toplevel) + +# Check if the pre-commit hook already exists +if [ -f "$GIT_ROOT/.git/hooks/pre-commit" ]; then + rm "$GIT_ROOT/.git/hooks/pre-commit" +fi + +ln -s "$GIT_ROOT/Scripts/lint-swift-code" "$GIT_ROOT/.git/hooks/pre-commit" \ No newline at end of file diff --git a/Scripts/convert-coverage-report b/Scripts/convert-coverage-report new file mode 100755 index 0000000..676c41c --- /dev/null +++ b/Scripts/convert-coverage-report @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Adapted from https://github.com/michaelhenry/swifty-code-coverage/blob/main/lcov.sh + +OUTPUT_FILE="coverage/lcov.info" +IGNORE_FILENAME_REGEX=".build|Tests|Pods|Carthage|DerivedData" +BUILD_PATH=".build" + +while :; do + case $1 in + --target) TARGET=$2 + shift + ;; + --output) OUTPUT_FILE=$2 + shift + ;; + *) break + esac + shift +done + +if [ -z "$BUILD_PATH" ]; then + echo "Missing --build-path. Either DerivedData or .build (for spm)" + exit 1 +fi + +if [ -z "$TARGET" ]; then + echo "Missing --target. Either an .app or an .xctest (for spm)" + exit 1 +fi + +INSTR_PROFILE=$(find $BUILD_PATH -name "*.profdata") +TARGET_PATH=$(find $BUILD_PATH -name "$TARGET" | head -1) +if [ -f $TARGET_PATH ]; then + OBJECT_FILE="$TARGET_PATH" +else + TARGET=$(echo $TARGET | sed 's/\.[^.]*$//') + OBJECT_FILE=$(find $BUILD_PATH -name "$TARGET" | head -1) +fi + +mkdir -p $(dirname "$OUTPUT_FILE") + +# print to stdout +xcrun llvm-cov report \ + "$OBJECT_FILE" \ + --instr-profile=$INSTR_PROFILE \ + --ignore-filename-regex=$IGNORE_FILENAME_REGEX \ + +# Export to code coverage file +xcrun llvm-cov export \ + "$OBJECT_FILE" \ + --instr-profile=$INSTR_PROFILE \ + --ignore-filename-regex=$IGNORE_FILENAME_REGEX \ + --format="lcov" > $OUTPUT_FILE \ No newline at end of file diff --git a/Scripts/format-swift-code b/Scripts/format-swift-code new file mode 100755 index 0000000..4e66902 --- /dev/null +++ b/Scripts/format-swift-code @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +swift-format format \ + --recursive \ + --parallel \ + --in-place \ + --configuration config/swift-format.json \ + Sources/ \ + Tests/ \ + Package.swift \ No newline at end of file diff --git a/Scripts/lint-swift-code b/Scripts/lint-swift-code new file mode 100755 index 0000000..9af98be --- /dev/null +++ b/Scripts/lint-swift-code @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +swift-format lint \ + --recursive \ + --parallel \ + --strict \ + --configuration config/swift-format.json \ + Sources/ \ + Tests/ \ + Package.swift \ No newline at end of file diff --git a/Sources/HPOpenWeather/Documentation.docc/Documentation.md b/Sources/HPOpenWeather/Documentation.docc/Documentation.md new file mode 100644 index 0000000..891ca04 --- /dev/null +++ b/Sources/HPOpenWeather/Documentation.docc/Documentation.md @@ -0,0 +1,93 @@ +# ``HPOpenWeather`` + +## Overview + +HPOpenWeather is a cross-platform Swift framework to communicate with the OpenWeather One-Call API. +See their [documentation](https://openweathermap.org/api/one-call-api) for further details. + +## Installation + +``HPOpenWeather`` supports iOS 15.0+, watchOS 6.0+, tvOS 15.0+ and macOS 12+. +It can be installed via SPM: + +``` +.package(url: "https://github.com/henrik-dmg/HPOpenWeather", from: "6.0.0") +``` + +## Usage + +### Configuration + +To get started, you need an API key from [OpenWeather](https://openweathermap.org). Then you can create an instance of the ``OpenWeather`` class. + +```swift +import HPOpenWeather + +// Create instance +let openWeatherClient = OpenWeather(apiKey: "") + +// Or use options +let settings = OpenWeather.Settings(apiKey: "", language: .german, units: .metric) +let openWeather = OpenWeather(settings: settings) + +// Change settings at any point +openWeatherClient.apiKey = "" +openWeatherClient.language = .german +openWeatherClient.units = .metric +``` + +### Retrieving Weather Information + +To fetch the weather, there are two options: async/await or callback. Both expect a ``CLLocationCoordinate2D`` for which to fetch the weather. +Additionally, you can specify which fields should be excluded from the response to save bandwidth, or specify a historic date or a date up to 4 days in the future. + +#### Async + +```swift +let weather = try await openWeatherClient.weather(for: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)) +``` + +#### Callback + +```swift +openWeatherClient.requestWeather(for: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)) { result in + switch result { + case .success(let weather): + print(weather) + case .failure(let error): + print(error) + } +} +``` + +### Available languages (default in bold) + +- **English** +- Russian +- Italian +- Spanish +- Ukrainian +- German +- Portuguese +- Romanian +- Polish +- Finnish +- Dutch +- French +- Bulgarian +- Swedish +- Chinese Traditional +- Chinese Simplified +- Turkish +- Croatian +- Catalan + +See ``WeatherLanguage`` for details. + +### Available units (default in bold) + +- **Metric** (wind speed in m/s, temperature in Celsius) +- Imperial (wind speed in mph, temperature in Fahrenheit) +- Standard (wind speed in m/s, temperature in Kelvin) + +See ``WeatherUnits`` for details. \ No newline at end of file diff --git a/Sources/HPOpenWeather/Extensions/CLLocationCoordinate+Extensions.swift b/Sources/HPOpenWeather/Extensions/CLLocationCoordinate+Extensions.swift index 1b4da93..b71f8d2 100644 --- a/Sources/HPOpenWeather/Extensions/CLLocationCoordinate+Extensions.swift +++ b/Sources/HPOpenWeather/Extensions/CLLocationCoordinate+Extensions.swift @@ -1,5 +1,5 @@ -import Foundation import CoreLocation +import Foundation extension CLLocationCoordinate2D: Codable { diff --git a/Sources/HPOpenWeather/Extensions/NSError+Extensions.swift b/Sources/HPOpenWeather/Extensions/NSError+Extensions.swift deleted file mode 100644 index 3252af9..0000000 --- a/Sources/HPOpenWeather/Extensions/NSError+Extensions.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -import HPNetwork - -extension NSError { - - convenience init(domain: String = "com.henrikpanhans.HPOpenWeather", code: Int, description: String) { - self.init( - domain: domain, - code: code, - userInfo: [NSLocalizedDescriptionKey: description] - ) - } - - static let noApiKey = NSError(code: 2, description: "API key was not provided") - static let timeMachineDate = NSError(code: 3, description: "TimeMachineRequest's date has to be at least 6 hours in the past") - -} diff --git a/Sources/HPOpenWeather/Models/City.swift b/Sources/HPOpenWeather/Models/City.swift deleted file mode 100644 index 09c2570..0000000 --- a/Sources/HPOpenWeather/Models/City.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import CoreLocation - -/// Type that holds information about the reqeuest's nearest city -public struct City: Codable, Equatable, Hashable, Identifiable { - - /// The ID assigned to the city - public let id: Int - /// The name of the city - public let name: String - /// The location of the city - public let location: CLLocationCoordinate2D - /// The country code of the city - public let countryCode: String - - enum CodingKeys: String, CodingKey { - case id - case name - case location = "coord" - case countryCode = "country" - } - -} diff --git a/Sources/HPOpenWeather/Models/DailyTemperature.swift b/Sources/HPOpenWeather/Models/DailyTemperature.swift index fa9a51a..893e3e2 100644 --- a/Sources/HPOpenWeather/Models/DailyTemperature.swift +++ b/Sources/HPOpenWeather/Models/DailyTemperature.swift @@ -1,8 +1,21 @@ import Foundation -/// Type that holds information about daily temperature changes +/// Type that holds information about daily temperature changes. public struct DailyTemperature: Codable, Equatable, Hashable { + // MARK: - Nested Types + + enum CodingKeys: String, CodingKey { + case day + case night + case min + case max + case evening = "eve" + case morning = "morn" + } + + // MARK: - Properties + /// Day temperature. public let day: Double /// Night temperature. @@ -16,13 +29,4 @@ public struct DailyTemperature: Codable, Equatable, Hashable { /// Morning temperature. public let morning: Double - enum CodingKeys: String, CodingKey { - case day - case night - case min - case max - case evening = "eve" - case morning = "morn" - } - } diff --git a/Sources/HPOpenWeather/Models/Forecasts/BasicWeather.swift b/Sources/HPOpenWeather/Models/Forecasts/BasicWeather.swift deleted file mode 100644 index c6c5cf3..0000000 --- a/Sources/HPOpenWeather/Models/Forecasts/BasicWeather.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -public protocol BasicWeatherResponse: Codable, Hashable { - - /// The timestamp when the data was collected - var timestamp: Date { get } - /// Atmospheric pressure on the sea level, hPa - var pressure: Double? { get } - /// Humidity in percent - var humidity: Double? { get } - /// Atmospheric temperature (varying according to pressure and humidity) below which - /// water droplets begin to condense and dew can form. Unit Default: Kelvin, Metric: Celsius, Imperial: Fahrenheit. - var dewPoint: Double? { get } - /// UV index - var uvIndex: Double? { get } - /// Average visibility - var visibility: Double? { get } - /// Cloudiness in percent - var cloudCoverage: Double? { get } - /// Basic information about observed wind - var wind: Wind { get } - -} - -public protocol SunResponse: Codable, Hashable { - - /// A container that holds information about sunset and sunrise timestamps - var sun: Sun { get } - -} diff --git a/Sources/HPOpenWeather/Models/Forecasts/CurrentWeather.swift b/Sources/HPOpenWeather/Models/Forecasts/CurrentWeather.swift index 3638d71..5cec335 100644 --- a/Sources/HPOpenWeather/Models/Forecasts/CurrentWeather.swift +++ b/Sources/HPOpenWeather/Models/Forecasts/CurrentWeather.swift @@ -1,9 +1,10 @@ import Foundation -public struct CurrentWeather: BasicWeatherResponse, SunResponse { - +/// A type containing information about the current weather. +public struct CurrentWeather: ForecastBase, SunForecast { + // MARK: - Coding Keys - + enum CodingKeys: String, CodingKey { case feelsLikeTemperature = "feels_like" case snow @@ -19,13 +20,13 @@ public struct CurrentWeather: BasicWeatherResponse, SunResponse { case windSpeed = "wind_speed" case windGust = "wind_gust" case windDirection = "wind_deg" - case weatherArray = "weather" + case weather case sunrise case sunset } - + // MARK: - Properties - + public let timestamp: Date public let pressure: Double? public let humidity: Double? @@ -35,41 +36,71 @@ public struct CurrentWeather: BasicWeatherResponse, SunResponse { public let cloudCoverage: Double? public let rain: Precipitation? public let snow: Precipitation? - - // Temperature - - private let actualTemperature: Double - private let feelsLikeTemperature: Double - - public var temperature: Temperature { - Temperature(actual: actualTemperature, feelsLike: feelsLikeTemperature) - } - - // Weather Conditions - - private let weatherArray: [WeatherCondition] - - public var condition: WeatherCondition? { - weatherArray.first - } - - // Wind - - private let windSpeed: Double? - private let windGust: Double? - private let windDirection: Double? - - public var wind: Wind { - Wind(speed: windSpeed, gust: windGust, degrees: windDirection) + public let wind: Wind + public let sun: Sun + public let currentCondition: WeatherCondition + public let temperature: Temperature + + // MARK: - Init + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let weatherArray = try container.decode([WeatherCondition].self, forKey: .weather) + guard let currentCondition = weatherArray.first else { + throw OpenWeatherError.noCurrentConditionReturned + } + self.currentCondition = currentCondition + + self.snow = try container.decodeIfPresent(Precipitation.self, forKey: .snow) + self.rain = try container.decodeIfPresent(Precipitation.self, forKey: .rain) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + self.pressure = try container.decodeIfPresent(Double.self, forKey: .pressure) + self.humidity = try container.decodeIfPresent(Double.self, forKey: .humidity) + self.dewPoint = try container.decodeIfPresent(Double.self, forKey: .dewPoint) + self.uvIndex = try container.decodeIfPresent(Double.self, forKey: .uvIndex) + self.cloudCoverage = try container.decodeIfPresent(Double.self, forKey: .cloudCoverage) + self.visibility = try container.decodeIfPresent(Double.self, forKey: .visibility) + + let actualTemperature = try container.decode(Double.self, forKey: .actualTemperature) + let feelsLikeTemperature = try container.decode(Double.self, forKey: .feelsLikeTemperature) + self.temperature = Temperature(actual: actualTemperature, feelsLike: feelsLikeTemperature) + + let windSpeed = try container.decodeIfPresent(Double.self, forKey: .windSpeed) + let windGust = try container.decodeIfPresent(Double.self, forKey: .windGust) + let windDirection = try container.decodeIfPresent(Double.self, forKey: .windDirection) + self.wind = Wind(speed: windSpeed, gust: windGust, degrees: windDirection) + + let sunrise = try container.decode(Date.self, forKey: .sunrise) + let sunset = try container.decode(Date.self, forKey: .sunset) + self.sun = Sun(sunset: sunset, sunrise: sunrise) } - - // Sun - - private let sunrise: Date - private let sunset: Date - - public var sun: Sun { - Sun(sunset: sunset, sunrise: sunrise) + + // MARK: - Encoding + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timestamp, forKey: .timestamp) + try container.encodeIfPresent(pressure, forKey: .pressure) + try container.encodeIfPresent(humidity, forKey: .humidity) + try container.encodeIfPresent(dewPoint, forKey: .dewPoint) + try container.encodeIfPresent(uvIndex, forKey: .uvIndex) + try container.encodeIfPresent(visibility, forKey: .visibility) + try container.encodeIfPresent(cloudCoverage, forKey: .cloudCoverage) + try container.encodeIfPresent(rain, forKey: .rain) + try container.encodeIfPresent(snow, forKey: .snow) + try container.encodeIfPresent([currentCondition], forKey: .weather) + + try container.encodeIfPresent(temperature.actual, forKey: .actualTemperature) + try container.encodeIfPresent(temperature.feelsLike, forKey: .feelsLikeTemperature) + + try container.encodeIfPresent(sun.sunrise, forKey: .sunrise) + try container.encodeIfPresent(sun.sunset, forKey: .sunset) + + try container.encodeIfPresent(wind.gust, forKey: .windGust) + try container.encodeIfPresent(wind.speed, forKey: .windSpeed) + try container.encodeIfPresent(wind.degrees, forKey: .windDirection) } - + } diff --git a/Sources/HPOpenWeather/Models/Forecasts/DailyForecast.swift b/Sources/HPOpenWeather/Models/Forecasts/DailyForecast.swift index 1b9f063..7e77290 100644 --- a/Sources/HPOpenWeather/Models/Forecasts/DailyForecast.swift +++ b/Sources/HPOpenWeather/Models/Forecasts/DailyForecast.swift @@ -1,6 +1,6 @@ import Foundation -public struct DailyForecast: BasicWeatherResponse, SunResponse { +public struct DailyForecast: ForecastBase, SunForecast, MoonForecast { // MARK: - Coding Keys @@ -19,9 +19,11 @@ public struct DailyForecast: BasicWeatherResponse, SunResponse { case windSpeed = "wind_speed" case windGust = "wind_gust" case windDirection = "wind_deg" - case weatherArray = "weather" + case weather case sunrise case sunset + case moonrise + case moonset } // MARK: - Properties @@ -38,33 +40,75 @@ public struct DailyForecast: BasicWeatherResponse, SunResponse { public let uvIndex: Double? public let visibility: Double? public let cloudCoverage: Double? - - // Weather Conditions - - private let weatherArray: [WeatherCondition] - - public var condition: WeatherCondition? { - weatherArray.first - } - - // Wind - - private let windSpeed: Double? - private let windGust: Double? - private let windDirection: Double? - - public var wind: Wind { - Wind(speed: windSpeed, gust: windGust, degrees: windDirection) + public let condition: WeatherCondition + public let sun: Sun + public let wind: Wind + public let moon: Moon + + // MARK: - Init + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let weatherArray = try container.decode([WeatherCondition].self, forKey: .weather) + guard let condition = weatherArray.first else { + throw OpenWeatherError.noCurrentConditionReturned + } + self.condition = condition + + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + self.pressure = try container.decodeIfPresent(Double.self, forKey: .pressure) + self.humidity = try container.decodeIfPresent(Double.self, forKey: .humidity) + self.dewPoint = try container.decodeIfPresent(Double.self, forKey: .dewPoint) + self.uvIndex = try container.decodeIfPresent(Double.self, forKey: .uvIndex) + self.cloudCoverage = try container.decodeIfPresent(Double.self, forKey: .cloudCoverage) + self.visibility = try container.decodeIfPresent(Double.self, forKey: .visibility) + self.temperature = try container.decode(DailyTemperature.self, forKey: .temperature) + self.feelsLikeTemperature = try container.decode(DailyTemperature.self, forKey: .feelsLikeTemperature) + self.totalRain = try container.decodeIfPresent(Double.self, forKey: .totalRain) + self.totalSnow = try container.decodeIfPresent(Double.self, forKey: .totalSnow) + + let windSpeed = try container.decodeIfPresent(Double.self, forKey: .windSpeed) + let windGust = try container.decodeIfPresent(Double.self, forKey: .windGust) + let windDirection = try container.decodeIfPresent(Double.self, forKey: .windDirection) + self.wind = Wind(speed: windSpeed, gust: windGust, degrees: windDirection) + + let sunrise = try container.decode(Date.self, forKey: .sunrise) + let sunset = try container.decode(Date.self, forKey: .sunset) + self.sun = Sun(sunset: sunset, sunrise: sunrise) + + let moonrise = try container.decode(Date.self, forKey: .moonrise) + let moonset = try container.decode(Date.self, forKey: .moonset) + self.moon = Moon(moonset: moonset, moonrise: moonrise) } - // Sun - - private let sunrise: Date - private let sunset: Date - - public var sun: Sun { - Sun(sunset: sunset, sunrise: sunrise) + // MARK: - Encoding + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(temperature, forKey: .temperature) + try container.encodeIfPresent(feelsLikeTemperature, forKey: .feelsLikeTemperature) + try container.encodeIfPresent(totalRain, forKey: .totalRain) + try container.encodeIfPresent(totalSnow, forKey: .totalSnow) + try container.encodeIfPresent(timestamp, forKey: .timestamp) + try container.encodeIfPresent(pressure, forKey: .pressure) + try container.encodeIfPresent(humidity, forKey: .humidity) + try container.encodeIfPresent(dewPoint, forKey: .dewPoint) + try container.encodeIfPresent(uvIndex, forKey: .uvIndex) + try container.encodeIfPresent(visibility, forKey: .visibility) + try container.encodeIfPresent(cloudCoverage, forKey: .cloudCoverage) + try container.encodeIfPresent([condition], forKey: .weather) + + try container.encodeIfPresent(sun.sunrise, forKey: .sunrise) + try container.encodeIfPresent(sun.sunset, forKey: .sunset) + + try container.encodeIfPresent(moon.moonrise, forKey: .moonrise) + try container.encodeIfPresent(moon.moonset, forKey: .moonset) + + try container.encodeIfPresent(wind.gust, forKey: .windGust) + try container.encodeIfPresent(wind.speed, forKey: .windSpeed) + try container.encodeIfPresent(wind.degrees, forKey: .windDirection) } } - diff --git a/Sources/HPOpenWeather/Models/Forecasts/ForecastBase.swift b/Sources/HPOpenWeather/Models/Forecasts/ForecastBase.swift new file mode 100644 index 0000000..099b2e2 --- /dev/null +++ b/Sources/HPOpenWeather/Models/Forecasts/ForecastBase.swift @@ -0,0 +1,38 @@ +import Foundation + +public protocol ForecastBase: Codable, Hashable { + + /// The timestamp when the data was collected. + var timestamp: Date { get } + /// Atmospheric pressure on the sea level, hPa. + var pressure: Double? { get } + /// Humidity in percent. + var humidity: Double? { get } + /// Atmospheric temperature (varying according to pressure and humidity) below which water droplets begin to condense and dew can form. + /// + /// Unit Default: Kelvin, Metric: Celsius, Imperial: Fahrenheit. + var dewPoint: Double? { get } + /// UV index. + var uvIndex: Double? { get } + /// Average visibility. + var visibility: Double? { get } + /// Cloudiness in percent. + var cloudCoverage: Double? { get } + /// Basic information about observed wind. + var wind: Wind { get } + +} + +public protocol SunForecast: Codable, Hashable { + + /// A container that holds information about sunset and sunrise timestamps. + var sun: Sun { get } + +} + +public protocol MoonForecast: Codable, Hashable { + + /// A container that holds information about moonrise and moonset timestamps. + var moon: Moon { get } + +} diff --git a/Sources/HPOpenWeather/Models/Forecasts/HourlyForecast.swift b/Sources/HPOpenWeather/Models/Forecasts/HourlyForecast.swift index 8a2ffbb..47b87f1 100644 --- a/Sources/HPOpenWeather/Models/Forecasts/HourlyForecast.swift +++ b/Sources/HPOpenWeather/Models/Forecasts/HourlyForecast.swift @@ -1,9 +1,9 @@ import Foundation -public struct HourlyForecast: BasicWeatherResponse { - +public struct HourlyForecast: ForecastBase { + // MARK: - Coding Keys - + enum CodingKeys: String, CodingKey { case actualTemperature = "temp" case feelsLikeTemperature = "feels_like" @@ -19,11 +19,11 @@ public struct HourlyForecast: BasicWeatherResponse { case windSpeed = "wind_speed" case windGust = "wind_gust" case windDirection = "wind_deg" - case weatherArray = "weather" + case weather } - + // MARK: - Properties - + public let timestamp: Date public let pressure: Double? public let humidity: Double? @@ -33,32 +33,63 @@ public struct HourlyForecast: BasicWeatherResponse { public let cloudCoverage: Double? public let rain: Precipitation? public let snow: Precipitation? - - // Temperature - - private let actualTemperature: Double - private let feelsLikeTemperature: Double - - public var temperature: Temperature { - Temperature(actual: actualTemperature, feelsLike: feelsLikeTemperature) - } - - // Wind - - private let windSpeed: Double? - private let windGust: Double? - private let windDirection: Double? - - public var wind: Wind { - Wind(speed: windSpeed, gust: windGust, degrees: windDirection) + public let temperature: Temperature + public let wind: Wind + public let condition: WeatherCondition + + // MARK: - Init + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let weatherArray = try container.decode([WeatherCondition].self, forKey: .weather) + guard let condition = weatherArray.first else { + throw OpenWeatherError.noCurrentConditionReturned + } + self.condition = condition + + self.snow = try container.decodeIfPresent(Precipitation.self, forKey: .snow) + self.rain = try container.decodeIfPresent(Precipitation.self, forKey: .rain) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + self.pressure = try container.decodeIfPresent(Double.self, forKey: .pressure) + self.humidity = try container.decodeIfPresent(Double.self, forKey: .humidity) + self.dewPoint = try container.decodeIfPresent(Double.self, forKey: .dewPoint) + self.uvIndex = try container.decodeIfPresent(Double.self, forKey: .uvIndex) + self.cloudCoverage = try container.decodeIfPresent(Double.self, forKey: .cloudCoverage) + self.visibility = try container.decodeIfPresent(Double.self, forKey: .visibility) + + let actualTemperature = try container.decode(Double.self, forKey: .actualTemperature) + let feelsLikeTemperature = try container.decode(Double.self, forKey: .feelsLikeTemperature) + self.temperature = Temperature(actual: actualTemperature, feelsLike: feelsLikeTemperature) + + let windSpeed = try container.decodeIfPresent(Double.self, forKey: .windSpeed) + let windGust = try container.decodeIfPresent(Double.self, forKey: .windGust) + let windDirection = try container.decodeIfPresent(Double.self, forKey: .windDirection) + self.wind = Wind(speed: windSpeed, gust: windGust, degrees: windDirection) } - - // Weather - - private let weatherArray: [WeatherCondition] - - public var weather: [WeatherCondition] { - weatherArray + + // MARK: - Encoding + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timestamp, forKey: .timestamp) + try container.encodeIfPresent(pressure, forKey: .pressure) + try container.encodeIfPresent(humidity, forKey: .humidity) + try container.encodeIfPresent(dewPoint, forKey: .dewPoint) + try container.encodeIfPresent(uvIndex, forKey: .uvIndex) + try container.encodeIfPresent(visibility, forKey: .visibility) + try container.encodeIfPresent(cloudCoverage, forKey: .cloudCoverage) + try container.encodeIfPresent(rain, forKey: .rain) + try container.encodeIfPresent(snow, forKey: .snow) + try container.encodeIfPresent([condition], forKey: .weather) + + try container.encodeIfPresent(temperature.actual, forKey: .actualTemperature) + try container.encodeIfPresent(temperature.feelsLike, forKey: .feelsLikeTemperature) + + try container.encodeIfPresent(wind.gust, forKey: .windGust) + try container.encodeIfPresent(wind.speed, forKey: .windSpeed) + try container.encodeIfPresent(wind.degrees, forKey: .windDirection) } - + } diff --git a/Sources/HPOpenWeather/Models/Forecasts/MinutelyForecast.swift b/Sources/HPOpenWeather/Models/Forecasts/MinutelyForecast.swift new file mode 100644 index 0000000..f5bd859 --- /dev/null +++ b/Sources/HPOpenWeather/Models/Forecasts/MinutelyForecast.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct MinutelyForecast: Codable, Equatable, Hashable { + + // MARK: - Nested Types + + enum CodingKeys: String, CodingKey { + case timestamp = "dt" + case precipitation + } + + // MARK: - Properties + + public let timestamp: Date + public let precipitation: Double + +} diff --git a/Sources/HPOpenWeather/Models/Moon.swift b/Sources/HPOpenWeather/Models/Moon.swift new file mode 100644 index 0000000..0986e02 --- /dev/null +++ b/Sources/HPOpenWeather/Models/Moon.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Type that holds information about moonset and moonrise times in UTC time. +public struct Moon: Codable, Equatable, Hashable { + + /// Moonset time. + public let moonset: Date + /// Moonrise time. + public let moonrise: Date + +} diff --git a/Sources/HPOpenWeather/Models/Precipitation.swift b/Sources/HPOpenWeather/Models/Precipitation.swift index fcdccc7..3e9472a 100644 --- a/Sources/HPOpenWeather/Models/Precipitation.swift +++ b/Sources/HPOpenWeather/Models/Precipitation.swift @@ -1,29 +1,33 @@ import Foundation -/// Type that holds information about recent precipitation +/// Type that holds information about recent precipitation. public struct Precipitation: Codable, Equatable, Hashable { + // MARK: - Nested Types + enum CodingKeys: String, CodingKey { case lastHour = "1h" case lastThreeHours = "3h" } - /// Precipitation volume for the last 1 hour, measured in mm + // MARK: - Properties + + /// Precipitation volume for the last 1 hour, measured in mm. public var lastHour: Double? - /// Precipitation volume for the last 3 hours, measured in mm + /// Precipitation volume for the last 3 hours, measured in mm. public var lastThreeHours: Double? - /// A convertible measurement of how much precipitation occured in the last hour if any + /// A convertible measurement of how much precipitation occured in the last hour if any. public var lastHourMeasurement: Measurement? { - guard let lastHour = lastHour else { + guard let lastHour else { return nil } return Measurement(value: lastHour, unit: .millimeters) } - /// A convertible measurement of how much precipitation occured in the last three hours if any + /// A convertible measurement of how much precipitation occured in the last three hours if any. public var lastThreeHoursMeasurement: Measurement? { - guard let lastThreeHours = lastThreeHours else { + guard let lastThreeHours else { return nil } return Measurement(value: lastThreeHours, unit: .millimeters) diff --git a/Sources/HPOpenWeather/Models/Sun.swift b/Sources/HPOpenWeather/Models/Sun.swift index 778dbfa..139dc7a 100644 --- a/Sources/HPOpenWeather/Models/Sun.swift +++ b/Sources/HPOpenWeather/Models/Sun.swift @@ -1,11 +1,11 @@ import Foundation -/// Type that holds information about sunrise and sunset times in UTC time +/// Type that holds information about sunrise and sunset times in UTC time. public struct Sun: Codable, Equatable, Hashable { - - /// Sunset time + + /// Sunset time. public let sunset: Date - /// Sunrise time + /// Sunrise time. public let sunrise: Date - + } diff --git a/Sources/HPOpenWeather/Models/Temperature.swift b/Sources/HPOpenWeather/Models/Temperature.swift index f2bc8fd..d663a3a 100644 --- a/Sources/HPOpenWeather/Models/Temperature.swift +++ b/Sources/HPOpenWeather/Models/Temperature.swift @@ -1,32 +1,24 @@ import Foundation -/// Type that holds information about daily temperature changes -public struct Temperature: Codable, Equatable, Hashable { +/// Type that holds information about daily temperature changes. +public struct Temperature: Equatable, Hashable { - /// The actually measured temperature + /// The actually measured temperature. public let actual: Double - /// The feels-like temperature + /// The feels-like temperature. public let feelsLike: Double - /// A convertible measurement of the actually measured temperature - public var actualMeasurement: Measurement { - actualMeasurement(units: OpenWeather.shared.units) - } - - /// A convertible measurement of the actually measured temperature - /// - Parameter units: The units to use when formatting the `actual` property - public func actualMeasurement(units: WeatherResponse.Units) -> Measurement { + /// A convertible measurement of the actually measured temperature. + /// - Parameter units: The units to use when formatting the `actual` property. This should be the same as what you used when making the request. + /// - Returns: a measurement in the provided unit + public func actualMeasurement(units: WeatherUnits) -> Measurement { Measurement(value: actual, unit: units.temperatureUnit) } - /// A convertible measurement of how the actually measured temperature feels like - public var feelsLikeMeasurement: Measurement { - feelsLikeMeasurement(units: OpenWeather.shared.units) - } - - /// A convertible measurement of how the actually measured temperature feels like - /// - Parameter units: The units to use when formatting the `feelsLike` property - public func feelsLikeMeasurement(units: WeatherResponse.Units) -> Measurement { + /// A convertible measurement of how the actually measured temperature feels like. + /// - Parameter units: The units to use when formatting the `feelsLike` property. This should be the same as what you used when making the request. + /// - Returns: a measurement in the provided unit + public func feelsLikeMeasurement(units: WeatherUnits) -> Measurement { Measurement(value: feelsLike, unit: units.temperatureUnit) } diff --git a/Sources/HPOpenWeather/Models/Weather+Language.swift b/Sources/HPOpenWeather/Models/Weather+Language.swift new file mode 100644 index 0000000..ce207b0 --- /dev/null +++ b/Sources/HPOpenWeather/Models/Weather+Language.swift @@ -0,0 +1,52 @@ +import Foundation + +/// The language that should be used in API responses for example for weather condition descriptions. +public enum WeatherLanguage: String, Codable { + + case afrikaans = "af" + case arabic = "ar" + case azerbaijani = "az" + case bulgarian = "bg" + case catalan = "ca" + case czech = "cz" + case danish = "da" + case german = "de" + case greek = "el" + case english = "en" + case basque = "eu" + case persian = "fa" + case finnish = "fi" + case french = "fr" + case galician = "gl" + case hebrew = "he" + case hindi = "hi" + case croatian = "hr" + case hungarian = "hu" + case indonesian = "id" + case italian = "it" + case japanese = "ja" + case korean = "kr" + case latvian = "la" + case lithuanian = "lt" + case macedonian = "mk" + case norwegian = "no" + case dutch = "nl" + case polish = "pl" + case portuguese = "pt" + case portugueseBrasil = "pt_br" + case romanian = "ro" + case russian = "ru" + case swedish = "sv" + case slovak = "sk" + case slovenian = "sl" + case spanish = "es" + case serbian = "sr" + case thai = "th" + case turkish = "tr" + case ukrainian = "ua" + case vietnamese = "vi" + case chineseSimplified = "zh_cn" + case chineseTraditional = "zh_tw" + case zulu = "zu" + +} diff --git a/Sources/HPOpenWeather/Models/Weather+Units.swift b/Sources/HPOpenWeather/Models/Weather+Units.swift new file mode 100644 index 0000000..7415fde --- /dev/null +++ b/Sources/HPOpenWeather/Models/Weather+Units.swift @@ -0,0 +1,35 @@ +import Foundation + +/// The units that should the data in the API responses should be formatted in. +public enum WeatherUnits: String, Codable { + + /// Temperature in Kelvin and wind speed in meter/sec. + case standard + /// Temperature in Celsius and wind speed in meter/sec. + case metric + /// Temperature in Fahrenheit and wind speed in miles/hour. + case imperial + + var temperatureUnit: UnitTemperature { + switch self { + case .standard: + return .kelvin + case .metric: + return .celsius + case .imperial: + return .fahrenheit + } + } + + var windSpeedUnit: UnitSpeed { + switch self { + case .standard: + return .metersPerSecond + case .metric: + return .metersPerSecond + case .imperial: + return .milesPerHour + } + } + +} diff --git a/Sources/HPOpenWeather/Models/Weather.swift b/Sources/HPOpenWeather/Models/Weather.swift new file mode 100644 index 0000000..2ac48bc --- /dev/null +++ b/Sources/HPOpenWeather/Models/Weather.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct Weather: Codable, Equatable, Hashable { + + // MARK: - Nested Types + + enum CodingKeys: String, CodingKey { + case timezoneIdentifier = "timezone" + case currentWeather = "current" + case minutelyForecasts = "minutely" + case hourlyForecasts = "hourly" + case dailyForecasts = "daily" + case alerts + + // These keys are not actually present in the response from the OpenWeather API. + // We inject them manually after decoding the response in order to persist these settings + // if you want to cache the response for example. + case language + case units + } + + // MARK: - Properties + + public let timezone: TimeZone + public let currentWeather: CurrentWeather? + public let minutelyForecasts: [MinutelyForecast]? + public let hourlyForecasts: [HourlyForecast]? + public let dailyForecasts: [DailyForecast]? + /// Government weather alerts data from major national weather warning systems. + public let alerts: [WeatherAlert]? + + public internal(set) var language: WeatherLanguage? + public internal(set) var units: WeatherUnits? + + // MARK: - Init + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let timezoneIdentifier = try container.decode(String.self, forKey: .timezoneIdentifier) + guard let timezone = TimeZone(identifier: timezoneIdentifier) else { + throw OpenWeatherError.invalidTimeZoneIdentifier(timezoneIdentifier) + } + self.timezone = timezone + + self.currentWeather = try container.decodeIfPresent(CurrentWeather.self, forKey: .currentWeather) + self.minutelyForecasts = try container.decodeIfPresent([MinutelyForecast].self, forKey: .minutelyForecasts) + self.hourlyForecasts = try container.decodeIfPresent([HourlyForecast].self, forKey: .hourlyForecasts) + self.dailyForecasts = try container.decodeIfPresent([DailyForecast].self, forKey: .dailyForecasts) + self.alerts = try container.decodeIfPresent([WeatherAlert].self, forKey: .alerts) + + self.language = try container.decodeIfPresent(WeatherLanguage.self, forKey: .language) + self.units = try container.decodeIfPresent(WeatherUnits.self, forKey: .units) + } + + // MARK: - Encoding + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(timezone.identifier, forKey: .timezoneIdentifier) + try container.encodeIfPresent(currentWeather, forKey: .currentWeather) + try container.encodeIfPresent(minutelyForecasts, forKey: .minutelyForecasts) + try container.encodeIfPresent(hourlyForecasts, forKey: .hourlyForecasts) + try container.encodeIfPresent(dailyForecasts, forKey: .dailyForecasts) + try container.encodeIfPresent(alerts, forKey: .alerts) + + try container.encodeIfPresent(language, forKey: .language) + try container.encodeIfPresent(units, forKey: .units) + } + +} diff --git a/Sources/HPOpenWeather/Models/WeatherAlert.swift b/Sources/HPOpenWeather/Models/WeatherAlert.swift index 7659d38..987d3b2 100644 --- a/Sources/HPOpenWeather/Models/WeatherAlert.swift +++ b/Sources/HPOpenWeather/Models/WeatherAlert.swift @@ -1,18 +1,9 @@ import Foundation -/// Type that holds information about weather alerts +/// Type that holds information about weather alerts. public struct WeatherAlert: Codable, Hashable, Equatable { - /// Name of the alert source. Please read here the full list of alert sources - public let senderName: String - /// Alert event name - public let eventName: String - //// Date and time of the start of the alert - public let startDate: Date - //// Date and time of the end of the alert - public let endDate: Date - /// Description of the alert - public let description: String + // MARK: - Nested Types enum CodingKeys: String, CodingKey { case senderName = "sender_name" @@ -22,4 +13,19 @@ public struct WeatherAlert: Codable, Hashable, Equatable { case description } + // MARK: - Properties + + /// Name of the alert source. + /// + /// A full list of possible sources can be found [here](https://openweathermap.org/api/one-call-3#listsource) + public let senderName: String + /// Alert event name. + public let eventName: String + //// Date and time of the start of the alert. + public let startDate: Date + //// Date and time of the end of the alert. + public let endDate: Date + /// Description of the alert. + public let description: String + } diff --git a/Sources/HPOpenWeather/Models/WeatherCondition.swift b/Sources/HPOpenWeather/Models/WeatherCondition.swift index 7f95d66..7d714ee 100644 --- a/Sources/HPOpenWeather/Models/WeatherCondition.swift +++ b/Sources/HPOpenWeather/Models/WeatherCondition.swift @@ -1,15 +1,15 @@ import Foundation -/// Type that holds information about weather conditions +/// Type that holds information about weather conditions. public struct WeatherCondition: Codable, Equatable, Hashable { - /// The weather condition ID + /// The weather condition ID. public let id: Int - /// Group of weather parameters + /// Group of weather parameters. public let main: String - /// The weather condition within the group + /// The weather condition within the group. public let description: String - /// The ID of the corresponding weather icon + /// The ID of the corresponding weather icon. public let icon: WeatherIcon } diff --git a/Sources/HPOpenWeather/Models/WeatherIcon.swift b/Sources/HPOpenWeather/Models/WeatherIcon.swift index bbb1a14..39d8e0a 100644 --- a/Sources/HPOpenWeather/Models/WeatherIcon.swift +++ b/Sources/HPOpenWeather/Models/WeatherIcon.swift @@ -1,9 +1,10 @@ +import Foundation + #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif -import Foundation public enum WeatherIcon: String, Codable, CaseIterable { @@ -29,37 +30,37 @@ public enum WeatherIcon: String, Codable, CaseIterable { } @available(iOS 13.0, macOS 11.0, tvOS 13.0, watchOS 6.0, *) -public extension WeatherIcon { +extension WeatherIcon { - var systemImageName: String { + public var systemImageName: String { makeIconName(filled: false) } - var systemImageNameFilled: String { + public var systemImageNameFilled: String { makeIconName(filled: true) } -#if canImport(UIKit) + #if canImport(UIKit) - func filledUIImage(withConfiguration configuration: UIImage.Configuration? = nil) -> UIImage? { + public func filledUIImage(withConfiguration configuration: UIImage.Configuration? = nil) -> UIImage? { UIImage(systemName: systemImageNameFilled, withConfiguration: configuration) } - func outlineUIImage(withConfiguration configuration: UIImage.Configuration? = nil) -> UIImage? { + public func outlineUIImage(withConfiguration configuration: UIImage.Configuration? = nil) -> UIImage? { UIImage(systemName: systemImageName, withConfiguration: configuration) } -#elseif canImport(AppKit) + #elseif canImport(AppKit) - func filledNSImage(accessibilityDescription: String? = nil) -> NSImage? { + public func filledNSImage(accessibilityDescription: String? = nil) -> NSImage? { NSImage(systemSymbolName: systemImageNameFilled, accessibilityDescription: accessibilityDescription) } - func outlineNSImage(accessibilityDescription: String? = nil) -> NSImage? { + public func outlineNSImage(accessibilityDescription: String? = nil) -> NSImage? { NSImage(systemSymbolName: systemImageName, accessibilityDescription: accessibilityDescription) } -#endif + #endif private func makeIconName(filled: Bool) -> String { let iconName: String diff --git a/Sources/HPOpenWeather/Models/Wind.swift b/Sources/HPOpenWeather/Models/Wind.swift index 414e32d..cc5b7ac 100644 --- a/Sources/HPOpenWeather/Models/Wind.swift +++ b/Sources/HPOpenWeather/Models/Wind.swift @@ -1,24 +1,20 @@ import Foundation -/// Type that holds information about wind speed and direction measured in degrees +/// Type that holds information about wind speed and direction measured in degrees. public struct Wind: Codable, Equatable, Hashable { - /// The current wind speed depending on the request's unit (metric: meter/second, imperial: miles/hour) + /// The current wind speed depending on the request's unit (metric: meter/second, imperial: miles/hour). public let speed: Double? - /// Wind gust speed (metric: meter/sec, imperial: miles/hour) + /// Wind gust speed (metric: meter/sec, imperial: miles/hour). public let gust: Double? - /// The wind direction measured in degrees from North + /// The wind direction measured in degrees from North. public let degrees: Double? - - /// A measurement of the `speed` property if existing, measured in the units currently specified in `OpenWeather.shared` - public var speedMeasurement: Measurement? { - speedMeasurement(units: OpenWeather.shared.units) - } - /// A measurement of the `speed` property if existing, measured in the passed in units - /// - Parameter units: The units to use when formatting the `speed` property - public func speedMeasurement(units: WeatherResponse.Units) -> Measurement? { - guard let speed = speed else { + /// A measurement of the `speed` property if existing, measured in the passed in units. + /// - Parameter units: The units to use when formatting the `speed` property. This should be the same as what you used when making the request. + /// - Returns: a measurement in the provided unit + public func speedMeasurement(units: WeatherUnits) -> Measurement? { + guard let speed else { return nil } return Measurement(value: speed, unit: units.windSpeedUnit) diff --git a/Sources/HPOpenWeather/OpenWeather.swift b/Sources/HPOpenWeather/OpenWeather.swift index 3ab6e95..ddd6bc2 100644 --- a/Sources/HPOpenWeather/OpenWeather.swift +++ b/Sources/HPOpenWeather/OpenWeather.swift @@ -1,26 +1,26 @@ import CoreLocation import Foundation -/// A type to request current weather conditions and forecasts +/// A type to request current weather conditions and forecasts. public final class OpenWeather { // MARK: - Nested Types - /// Type that can be used to configure all settings at once + /// Type that can be used to configure all settings at once. public struct Settings { - /// The API key to use for weather requests - let apiKey : String - /// The language that will be used in weather responses - let language: WeatherResponse.Language - /// The units that will be used in weather responses - let units: WeatherResponse.Units - - /// Initialises a new settings instance + /// The API key to use for weather requests. + let apiKey: String + /// The language that will be used in weather responses. + let language: WeatherLanguage + /// The units that will be used in weather responses. + let units: WeatherUnits + + /// Initialises a new settings instance. /// - Parameters: /// - apiKey: The API key to use for weather requests /// - language: The language that will be used in weather responses /// - units: The units that will be used in weather responses - public init(apiKey: String, language: WeatherResponse.Language = .english, units: WeatherResponse.Units = .metric) { + public init(apiKey: String, language: WeatherLanguage = .english, units: WeatherUnits = .metric) { self.language = language self.units = units self.apiKey = apiKey @@ -29,25 +29,22 @@ public final class OpenWeather { // MARK: - Properties - /// A shared instance of the weather client - public static let shared = OpenWeather() - - /// The OpenWeatherMap API key to authorize requests - public var apiKey : String? - /// The language that should be used in API responses - public var language: WeatherResponse.Language = .english - /// The units that should be used to format the API responses - public var units: WeatherResponse.Units = .metric + /// The OpenWeather API key to authorize requests. + public var apiKey: String? + /// The language that should be used in API responses. + public var language: WeatherLanguage = .english + /// The units that should be used to format the API responses. + public var units: WeatherUnits = .metric // MARK: - Init - /// Initialised a new instance of `OpenWeather` and applies the specified API key + /// Initialised a new instance of `OpenWeather` and applies the specified API key. /// - Parameter apiKey: the API key to authenticate with the OpenWeatherMap API public init(apiKey: String? = nil) { self.apiKey = apiKey } - /// Initialised a new instance of `OpenWeather` and applies the specified settimgs + /// Initialised a new instance of `OpenWeather` and applies the specified settings. /// - Parameter settings: the settings to apply, including API key, language and units public init(settings: Settings) { self.apiKey = settings.apiKey @@ -57,57 +54,59 @@ public final class OpenWeather { // MARK: - Sending Requests - /// Sends the specified request to the OpenWeather API + /// Requests a weather forecast for the specified location. /// - Parameters: /// - coordinate: The coordinate for which the weather will be requested /// - excludedFields: An array specifying the fields that will be excluded from the response /// - date: The date for which you want to request the weather. If no date is provided, the current weather will be retrieved /// - urlSession: The `URLSession` that will be used schedule requests /// - Returns: A weather response object - public func weatherResponse( - coordinate: CLLocationCoordinate2D, - excludedFields: [WeatherRequest.ExcludableField]? = nil, + /// - Throws: If no API key was provided, the request was misconfigured, the networking failed or the response failed to decode + public func weather( + for coordinate: CLLocationCoordinate2D, + excludedFields: [ExcludableField]? = nil, date: Date? = nil, urlSession: URLSession = .shared - ) async throws -> WeatherResponse { + ) async throws -> Weather { + guard let apiKey, !apiKey.isEmpty else { + throw OpenWeatherError.invalidAPIKey + } + + let settings = Settings(apiKey: apiKey, language: language, units: units) let request = WeatherRequest( coordinate: coordinate, excludedFields: excludedFields, - date: date + date: date, + settings: settings ) - return try await weatherResponse(request, urlSession: urlSession) - } - - /// Sends the specified request to the OpenWeather API - /// - Parameters: - /// - request: The request object that holds information about request location, date, etc. - /// - urlSession: The `URLSession` that will be used schedule requests - /// - Returns: A weather response object - public func weatherResponse(_ request: WeatherRequest, urlSession: URLSession = .shared) async throws -> WeatherRequest.Output { - guard let apiKey = apiKey else { - throw NSError.noApiKey - } - - let settings = Settings(apiKey: apiKey, language: language, units: units) - let networkRequest = try request.makeNetworkRequest(settings: settings, urlSession: urlSession) - var response = try await networkRequest.response().output - response.units = settings.units - response.language = settings.language - return response + return try await request.response(urlSession: urlSession).output } - /// Sends the specified request to the OpenWeather API + /// Requests a weather forecast for the specified location. /// - Parameters: - /// - request: The request object that holds information about request location, date, etc. + /// - coordinate: The coordinate for which the weather will be requested + /// - excludedFields: An array specifying the fields that will be excluded from the response + /// - date: The date for which you want to request the weather. If no date is provided, the current weather will be retrieved /// - urlSession: The `URLSession` that will be used schedule requests /// - completion: A completion that will be called with the result of the network request /// - Returns: A network task that can be used to cancel the request @discardableResult - public func schedule(_ request: WeatherRequest, urlSession: URLSession = .shared, completion: @escaping (Result) -> Void) -> Task { + public func requestWeather( + for coordinate: CLLocationCoordinate2D, + excludedFields: [ExcludableField]? = nil, + date: Date? = nil, + urlSession: URLSession = .shared, + completion: @escaping @Sendable (Result) -> Void + ) -> Task { Task { do { - let response = try await weatherResponse(request, urlSession: urlSession) + let response = try await weather( + for: coordinate, + excludedFields: excludedFields, + date: date, + urlSession: urlSession + ) completion(.success(response)) } catch { completion(.failure(error)) @@ -115,14 +114,4 @@ public final class OpenWeather { } } - // MARK: - Applying Settings - - /// Applies new settings to the weather client - /// - Parameter settings: The weather client settings, including an API key, language and units - public func apply(_ settings: Settings) { - apiKey = settings.apiKey - language = settings.language - units = settings.units - } - } diff --git a/Sources/HPOpenWeather/OpenWeatherAPIError.swift b/Sources/HPOpenWeather/OpenWeatherAPIError.swift new file mode 100644 index 0000000..ac40104 --- /dev/null +++ b/Sources/HPOpenWeather/OpenWeatherAPIError.swift @@ -0,0 +1,23 @@ +import Foundation + +/// An error that is thrown when the API returns an error. +public struct OpenWeatherAPIError: Error, Decodable { + + // MARK: - Nested Types + + enum CodingKeys: String, CodingKey { + case code = "cod" + case message + case parameters + } + + // MARK: - Properties + + /// The error code, such as 400, 404 or 5xx. + public let code: Int + /// The error message or description. + public let message: String + /// List of request parameters names that are related to this particular error. + public let parameters: [String]? + +} diff --git a/Sources/HPOpenWeather/OpenWeatherError.swift b/Sources/HPOpenWeather/OpenWeatherError.swift new file mode 100644 index 0000000..4a49b78 --- /dev/null +++ b/Sources/HPOpenWeather/OpenWeatherError.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum OpenWeatherError: LocalizedError, Equatable { + + case invalidRequestTimestamp + case invalidAPIKey + case noCurrentConditionReturned + case invalidTimeZoneIdentifier(_ identifier: String) + + public var errorDescription: String? { + switch self { + case .invalidRequestTimestamp: + return "The request timestamp is invalid" + case .invalidAPIKey: + return "The API key is missing or empty" + case .noCurrentConditionReturned: + return "No current condition was returned" + case .invalidTimeZoneIdentifier(let identifier): + return "The timezone identifier '\(identifier)' is invalid" + } + } + +} diff --git a/Sources/HPOpenWeather/Requests/APINetworkRequest.swift b/Sources/HPOpenWeather/Requests/APINetworkRequest.swift deleted file mode 100644 index 9e799ec..0000000 --- a/Sources/HPOpenWeather/Requests/APINetworkRequest.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import HPNetwork - -struct APINetworkRequest: DecodableRequest { - - typealias Output = WeatherResponse - - static let decoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .secondsSince1970 - return decoder - }() - - let url: URL? - let urlSession: URLSession - let requestMethod: NetworkRequestMethod = .get - let headerFields = [NetworkRequestHeaderField.contentTypeJSON] - - var decoder: JSONDecoder { - APINetworkRequest.decoder - } - - func makeURL() throws -> URL { - guard let url = url else { - throw NSError(code: 6, description: "Could not create URL") - } - return url - } - -} diff --git a/Sources/HPOpenWeather/Requests/ExcludableField.swift b/Sources/HPOpenWeather/Requests/ExcludableField.swift new file mode 100644 index 0000000..3969d2d --- /dev/null +++ b/Sources/HPOpenWeather/Requests/ExcludableField.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum ExcludableField: String, Codable { + case current + case minutely + case hourly + case daily + case alerts +} diff --git a/Sources/HPOpenWeather/Requests/WeatherRequest+Combine.swift b/Sources/HPOpenWeather/Requests/WeatherRequest+Combine.swift deleted file mode 100644 index d32b1f5..0000000 --- a/Sources/HPOpenWeather/Requests/WeatherRequest+Combine.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Combine -import Foundation - -public extension WeatherRequest { - - func publisher( - apiKey: String, - language: WeatherResponse.Language = .english, - units: WeatherResponse.Units = .metric, - urlSession: URLSession = .shared, - finishingQueue: DispatchQueue = .main) -> AnyPublisher - { - publisher( - settings: OpenWeather.Settings(apiKey: apiKey, language: language, units: units), - urlSession: urlSession, - finishingQueue: finishingQueue - ) - } - - func publisher(settings: OpenWeather.Settings, urlSession: URLSession = .shared, finishingQueue: DispatchQueue = .main) -> AnyPublisher { - let request = APINetworkRequest(url: makeURL(settings: settings), urlSession: urlSession) - return request.dataTaskPublisher() - } - -} diff --git a/Sources/HPOpenWeather/Requests/WeatherRequest+ExcludableField.swift b/Sources/HPOpenWeather/Requests/WeatherRequest+ExcludableField.swift deleted file mode 100644 index bf70b80..0000000 --- a/Sources/HPOpenWeather/Requests/WeatherRequest+ExcludableField.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -public extension WeatherRequest { - - enum ExcludableField: String, Codable { - case current - case minutely - case hourly - case daily - case alerts - } - -} diff --git a/Sources/HPOpenWeather/Requests/WeatherRequest.swift b/Sources/HPOpenWeather/Requests/WeatherRequest.swift index 8123b16..9435af2 100644 --- a/Sources/HPOpenWeather/Requests/WeatherRequest.swift +++ b/Sources/HPOpenWeather/Requests/WeatherRequest.swift @@ -1,68 +1,79 @@ -import Foundation import CoreLocation +import Foundation import HPNetwork import HPURLBuilder -public struct WeatherRequest: Codable { +struct WeatherRequest: DecodableRequest { // MARK: - Associated Types - public typealias Output = WeatherResponse + typealias Output = Weather // MARK: - Properties - public let coordinate: CLLocationCoordinate2D - public let excludedFields: [ExcludableField]? - public let date: Date? + let coordinate: CLLocationCoordinate2D + let excludedFields: [ExcludableField]? + let date: Date? + let settings: OpenWeather.Settings - // MARK: - Init + let requestMethod: HTTPRequest.Method = .get - public init(coordinate: CLLocationCoordinate2D, excludedFields: [ExcludableField]? = nil, date: Date? = nil) { - self.coordinate = coordinate - self.excludedFields = excludedFields?.hp_nilIfEmpty() - self.date = date + var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder } - // MARK: - OpenWeatherRequest + // MARK: - DecodableRequest - func makeURL(settings: OpenWeather.Settings) -> URL? { - URL.build { + func makeURL() throws -> URL { + if let date, Date.now.addingTimeInterval(.day * 4) < date { + throw OpenWeatherError.invalidRequestTimestamp + } + return try URL.buildThrowing { Host("api.openweathermap.org") PathComponent("data") - PathComponent("2.5") + PathComponent("3.0") PathComponent("onecall") PathComponent(date != nil ? "timemachine" : nil) QueryItem(name: "lat", value: coordinate.latitude, digits: 5) QueryItem(name: "lon", value: coordinate.longitude, digits: 5) + QueryItem(name: "appid", value: settings.apiKey) QueryItem(name: "dt", value: date.flatMap({ Int($0.timeIntervalSince1970) })) QueryItem(name: "exclude", value: excludedFields?.compactMap({ $0.rawValue })) - QueryItem(name: "appid", value: settings.apiKey) QueryItem(name: "units", value: settings.units.rawValue) QueryItem(name: "lang", value: settings.language.rawValue) } } - func makeNetworkRequest(settings: OpenWeather.Settings, urlSession: URLSession) throws -> APINetworkRequest { - if let date = date, date < Date(), abs(date.timeIntervalSinceNow) <= 6 * .hour { - throw NSError.timeMachineDate + func convertResponse(data: Data, response: HTTPResponse) throws -> Weather { + switch response.status.kind { + case .informational, .successful: + var weather = try decoder.decode(Weather.self, from: data) + weather.units = settings.units + weather.language = settings.language + return weather + case .clientError, .invalid, .redirection, .serverError: + var errorToThrow: any Error + do { + errorToThrow = try decoder.decode(OpenWeatherAPIError.self, from: data) + } catch { + errorToThrow = URLError(URLError.Code(rawValue: response.status.code)) + } + throw errorToThrow } - return APINetworkRequest(url: makeURL(settings: settings), urlSession: urlSession) } -} - -extension Collection { - - func hp_nilIfEmpty() -> Self? { - isEmpty ? nil : self + func validateResponse(_ response: HTTPResponse) throws { + // do nothing, validation will be handled by convertResponse instead } } extension TimeInterval { - static let minute = 60.00 - static let hour = 3600.00 - static let day = 86400.00 + static let minute = 60.0 + static let hour = minute * 60 + static let day = hour * 24 } diff --git a/Sources/HPOpenWeather/Response/WeatherResponse+Language.swift b/Sources/HPOpenWeather/Response/WeatherResponse+Language.swift deleted file mode 100644 index 624b882..0000000 --- a/Sources/HPOpenWeather/Response/WeatherResponse+Language.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation - -public extension WeatherResponse { - - /// The language that should be used in API responses for example for weather condition descriptions - enum Language: String, Codable { - case afrikaans = "af" - case arabic = "ar" - case azerbaijani = "az" - case bulgarian = "bg" - case catalan = "ca" - case czech = "cz" - case danish = "da" - case german = "de" - case greek = "el" - case english = "en" - case basque = "eu" - case persian = "fa" - case finnish = "fi" - case french = "fr" - case galician = "gl" - case hebrew = "he" - case hindi = "hi" - case croatian = "hr" - case hungarian = "hu" - case indonesian = "id" - case italian = "it" - case japanese = "ja" - case korean = "kr" - case latvian = "la" - case lithuanian = "lt" - case macedonian = "mk" - case norwegian = "no" - case dutch = "nl" - case polish = "pl" - case portuguese = "pt" - case portugueseBrasil = "pt_br" - case romanian = "ro" - case russian = "ru" - case swedish = "sv" - case slovak = "sk" - case slovenian = "sl" - case spanish = "es" - case serbian = "sr" - case thai = "th" - case turkish = "tr" - case ukrainian = "ua" - case vietnamese = "vi" - case chineseSimplified = "zh_cn" - case chineseTraditional = "zh_tw" - case zulu = "zu" - } - -} diff --git a/Sources/HPOpenWeather/Response/WeatherResponse+Units.swift b/Sources/HPOpenWeather/Response/WeatherResponse+Units.swift deleted file mode 100644 index 38c0fbf..0000000 --- a/Sources/HPOpenWeather/Response/WeatherResponse+Units.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -public extension WeatherResponse { - - /// The units that should the data in the API responses should be formatted in - enum Units: String, Codable { - - /// Temperature in Kelvin and wind speed in meter/sec - case standard - /// Temperature in Celsius and wind speed in meter/sec - case metric - /// Temperature in Fahrenheit and wind speed in miles/hour - case imperial - - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) - var temperatureUnit: UnitTemperature { - switch self { - case .standard: - return .kelvin - case .metric: - return .celsius - case .imperial: - return .fahrenheit - } - } - - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) - var windSpeedUnit: UnitSpeed { - switch self { - case .standard: - return .metersPerSecond - case .metric: - return .metersPerSecond - case .imperial: - return .milesPerHour - } - } - - } - -} diff --git a/Sources/HPOpenWeather/Response/WeatherResponse.swift b/Sources/HPOpenWeather/Response/WeatherResponse.swift deleted file mode 100644 index 3e1804e..0000000 --- a/Sources/HPOpenWeather/Response/WeatherResponse.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -public struct WeatherResponse: Codable, Equatable, Hashable { - - private let timezoneIdentifier: String - public let currentWeather: CurrentWeather? - public let hourlyForecasts: [HourlyForecast]? - public let dailyForecasts: [DailyForecast]? - /// Government weather alerts data from major national weather warning systems - public let alerts: [WeatherAlert]? - - public internal(set) var language: WeatherResponse.Language? - public internal(set) var units: WeatherResponse.Units? - - public var timezone: TimeZone? { - TimeZone(identifier: timezoneIdentifier) - } - - enum CodingKeys: String, CodingKey { - case timezoneIdentifier = "timezone" - case currentWeather = "current" - case hourlyForecasts = "hourly" - case dailyForecasts = "daily" - case alerts - } - -} - -public struct WeatherResponseContainer: Codable, Equatable, Hashable { - - public let response: WeatherResponse - public let language: WeatherResponse.Language - public let units: WeatherResponse.Units - -} diff --git a/Tests/HPOpenWeatherTests/HPOpenWeatherTests.swift b/Tests/HPOpenWeatherTests/HPOpenWeatherTests.swift deleted file mode 100644 index 1414432..0000000 --- a/Tests/HPOpenWeatherTests/HPOpenWeatherTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import XCTest -import HPNetwork -@testable import HPOpenWeather - -final class HPOpenWeatherTests: XCTestCase { - - override class func setUp() { - super.setUp() - OpenWeather.shared.apiKey = TestSecret.apiKey - } - - override class func tearDown() { - super.tearDown() - OpenWeather.shared.apiKey = nil - } - - func testCurrentRequest() async throws { - do { - _ = try await OpenWeather.shared.weatherResponse(coordinate: .init(latitude: 52.5200, longitude: 13.4050)) - } catch let error as NSError { - print(error) - throw error - } - } - - func testTimeMachineRequestFailing() async throws { - let request = WeatherRequest(coordinate: .init(latitude: 52.5200, longitude: 13.4050), date: Date().addingTimeInterval(-1 * .hour)) - - await HPAssertThrowsError { - try await OpenWeather.shared.weatherResponse(request) - } - } - - func testTimeMachineRequest() async { - let request = WeatherRequest(coordinate: .init(latitude: 52.5200, longitude: 13.4050), date: Date().addingTimeInterval(-7 * .hour)) - - await HPAssertThrowsNoError { - try await OpenWeather.shared.weatherResponse(request) - } - } - - func testPublisher() { - let request = WeatherRequest(coordinate: .init(latitude: 52.5200, longitude: 13.4050)) - - let expectationFinished = expectation(description: "finished") - let expectationReceive = expectation(description: "receiveValue") - //let expectationFailure = expectation(description: "failure") - - let cancellable = request.publisher(apiKey: TestSecret.apiKey).sink( - receiveCompletion: { result in - switch result { - case .failure(let error): - XCTFail(error.localizedDescription) - case .finished: - expectationFinished.fulfill() - } - }, receiveValue: { response in - expectationReceive.fulfill() - } - ) - - waitForExpectations(timeout: 10) { error in - cancellable.cancel() - } - } - -} - -extension Encodable { - - func encodeAndDecode(type: T.Type) throws -> T { - let jsonEncoder = JSONEncoder() - jsonEncoder.dateEncodingStrategy = .secondsSince1970 - let encodedData = try jsonEncoder.encode(self) - - let jsonDecoder = JSONDecoder() - jsonDecoder.dateDecodingStrategy = .secondsSince1970 - return try jsonDecoder.decode(type.self, from: encodedData) - } - -} - -func HPAssertThrowsError(_ work: () async throws -> T) async { - do { - _ = try await work() - XCTFail("Block should throw") - } catch { - return - } -} - -func HPAssertThrowsNoError(_ work: () async throws -> T) async { - do { - _ = try await work() - } catch let error { - XCTFail(error.localizedDescription) - } -} - -/// Asserts that the result is not a failure -func XCTAssertResult(_ result: Result) { - if case .failure(let error as NSError) = result { - XCTFail(error.localizedDescription) - } -} - -/// Asserts that the result is not a failure -func XCTAssertResultError(_ result: Result) { - if case .success(_) = result { - XCTFail("Result was not an error") - } -} diff --git a/Tests/HPOpenWeatherTests/OpenWeatherTests.swift b/Tests/HPOpenWeatherTests/OpenWeatherTests.swift new file mode 100644 index 0000000..72ad07d --- /dev/null +++ b/Tests/HPOpenWeatherTests/OpenWeatherTests.swift @@ -0,0 +1,100 @@ +import HPNetwork +import HPNetworkMock +import XCTest + +@testable import HPOpenWeather + +final class OpenWeatherTests: XCTestCase { + + // MARK: - Properties + + private lazy var mockedURLSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLSessionMock.self] + return URLSession(configuration: configuration) + }() + + // MARK: - Test Lifecycle + + override func tearDown() { + URLSessionMock.unregisterAllMockedRequests() + super.tearDown() + } + + // MARK: - Tests + + func testNewApiRequest() async throws { + try makeWeatherResponse() + + let settings = OpenWeather.Settings(apiKey: "debug") + let request = WeatherRequest( + coordinate: .init(latitude: 52.5200, longitude: 13.4050), + excludedFields: nil, + date: nil, + settings: settings + ) + let weather = try await request.response(urlSession: mockedURLSession).output + + let currentWeather = try XCTUnwrap(weather.currentWeather) + XCTAssertEqual(currentWeather.timestamp, Date(timeIntervalSince1970: 1_713_795_125)) + XCTAssertEqual(weather.units, .metric) + } + + func testInvalidApiKey() async throws { + let settings = OpenWeather.Settings(apiKey: "debug") + let request = WeatherRequest( + coordinate: .init(latitude: 52.5200, longitude: 13.4050), + excludedFields: nil, + date: nil, + settings: settings + ) + + do { + _ = try await request.response(urlSession: .shared).output + } catch { + let apiError = try XCTUnwrap(error as? OpenWeatherAPIError) + XCTAssertEqual(apiError.code, 401) + } + } + + func testInvalidTimeMachineTimestamp() async throws { + let settings = OpenWeather.Settings(apiKey: "debug") + let request = WeatherRequest( + coordinate: .init(latitude: 52.5200, longitude: 13.4050), + excludedFields: nil, + date: Date.distantFuture, + settings: settings + ) + + do { + _ = try await request.response(urlSession: .shared).output + } catch { + let apiError = try XCTUnwrap(error as? OpenWeatherError) + XCTAssertEqual(apiError, .invalidRequestTimestamp) + } + } + + // MARK: - Helpers + + private func makeWeatherResponse() throws { + let url = try XCTUnwrap(URL(string: "https://api.openweathermap.org/data/3.0/onecall")) + let jsonDataURL = try XCTUnwrap( + Bundle.module.url( + forResource: "3-0-test-response", + withExtension: "json" + ) + ) + let jsonData = try Data(contentsOf: jsonDataURL) + + _ = URLSessionMock.mockRequest(to: url, ignoresQuery: true) { _ in + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": ContentType.applicationJSON.rawValue] + )! + return (jsonData, response) + } + } + +} diff --git a/Tests/HPOpenWeatherTests/Resources/3-0-test-response.json b/Tests/HPOpenWeatherTests/Resources/3-0-test-response.json new file mode 100644 index 0000000..d005c13 --- /dev/null +++ b/Tests/HPOpenWeatherTests/Resources/3-0-test-response.json @@ -0,0 +1,1713 @@ +{ + "lat": 52.52, + "lon": 13.405, + "timezone": "Europe/Berlin", + "timezone_offset": 7200, + "current": { + "dt": 1713795125, + "sunrise": 1713757952, + "sunset": 1713809805, + "temp": 6.31, + "feels_like": 5.11, + "pressure": 1006, + "humidity": 69, + "dew_point": 1.05, + "uvi": 1.6, + "clouds": 0, + "visibility": 10000, + "wind_speed": 1.79, + "wind_deg": 110, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ] + }, + "minutely": [ + { + "dt": 1713795180, + "precipitation": 0 + }, + { + "dt": 1713795240, + "precipitation": 0 + }, + { + "dt": 1713795300, + "precipitation": 0 + }, + { + "dt": 1713795360, + "precipitation": 0 + }, + { + "dt": 1713795420, + "precipitation": 0 + }, + { + "dt": 1713795480, + "precipitation": 0 + }, + { + "dt": 1713795540, + "precipitation": 0 + }, + { + "dt": 1713795600, + "precipitation": 0 + }, + { + "dt": 1713795660, + "precipitation": 0 + }, + { + "dt": 1713795720, + "precipitation": 0 + }, + { + "dt": 1713795780, + "precipitation": 0 + }, + { + "dt": 1713795840, + "precipitation": 0 + }, + { + "dt": 1713795900, + "precipitation": 0 + }, + { + "dt": 1713795960, + "precipitation": 0 + }, + { + "dt": 1713796020, + "precipitation": 0 + }, + { + "dt": 1713796080, + "precipitation": 0 + }, + { + "dt": 1713796140, + "precipitation": 0 + }, + { + "dt": 1713796200, + "precipitation": 0 + }, + { + "dt": 1713796260, + "precipitation": 0 + }, + { + "dt": 1713796320, + "precipitation": 0 + }, + { + "dt": 1713796380, + "precipitation": 0 + }, + { + "dt": 1713796440, + "precipitation": 0 + }, + { + "dt": 1713796500, + "precipitation": 0 + }, + { + "dt": 1713796560, + "precipitation": 0 + }, + { + "dt": 1713796620, + "precipitation": 0 + }, + { + "dt": 1713796680, + "precipitation": 0 + }, + { + "dt": 1713796740, + "precipitation": 0 + }, + { + "dt": 1713796800, + "precipitation": 0 + }, + { + "dt": 1713796860, + "precipitation": 0 + }, + { + "dt": 1713796920, + "precipitation": 0 + }, + { + "dt": 1713796980, + "precipitation": 0 + }, + { + "dt": 1713797040, + "precipitation": 0 + }, + { + "dt": 1713797100, + "precipitation": 0 + }, + { + "dt": 1713797160, + "precipitation": 0 + }, + { + "dt": 1713797220, + "precipitation": 0 + }, + { + "dt": 1713797280, + "precipitation": 0 + }, + { + "dt": 1713797340, + "precipitation": 0 + }, + { + "dt": 1713797400, + "precipitation": 0 + }, + { + "dt": 1713797460, + "precipitation": 0 + }, + { + "dt": 1713797520, + "precipitation": 0 + }, + { + "dt": 1713797580, + "precipitation": 0 + }, + { + "dt": 1713797640, + "precipitation": 0 + }, + { + "dt": 1713797700, + "precipitation": 0 + }, + { + "dt": 1713797760, + "precipitation": 0 + }, + { + "dt": 1713797820, + "precipitation": 0 + }, + { + "dt": 1713797880, + "precipitation": 0 + }, + { + "dt": 1713797940, + "precipitation": 0 + }, + { + "dt": 1713798000, + "precipitation": 0 + }, + { + "dt": 1713798060, + "precipitation": 0 + }, + { + "dt": 1713798120, + "precipitation": 0 + }, + { + "dt": 1713798180, + "precipitation": 0 + }, + { + "dt": 1713798240, + "precipitation": 0 + }, + { + "dt": 1713798300, + "precipitation": 0 + }, + { + "dt": 1713798360, + "precipitation": 0 + }, + { + "dt": 1713798420, + "precipitation": 0 + }, + { + "dt": 1713798480, + "precipitation": 0 + }, + { + "dt": 1713798540, + "precipitation": 0 + }, + { + "dt": 1713798600, + "precipitation": 0 + }, + { + "dt": 1713798660, + "precipitation": 0 + }, + { + "dt": 1713798720, + "precipitation": 0 + } + ], + "hourly": [ + { + "dt": 1713794400, + "temp": 6.31, + "feels_like": 4.45, + "pressure": 1006, + "humidity": 69, + "dew_point": 1.05, + "uvi": 1.6, + "clouds": 0, + "visibility": 10000, + "wind_speed": 2.5, + "wind_deg": 19, + "wind_gust": 3.01, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 0.24, + "rain": { + "1h": 0.13 + } + }, + { + "dt": 1713798000, + "temp": 6.59, + "feels_like": 4.88, + "pressure": 1009, + "humidity": 64, + "dew_point": 0.27, + "uvi": 0.97, + "clouds": 20, + "visibility": 10000, + "wind_speed": 2.38, + "wind_deg": 38, + "wind_gust": 3.26, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "pop": 0.03 + }, + { + "dt": 1713801600, + "temp": 6.86, + "feels_like": 5.68, + "pressure": 1012, + "humidity": 58, + "dew_point": -0.72, + "uvi": 0.56, + "clouds": 40, + "visibility": 10000, + "wind_speed": 1.85, + "wind_deg": 40, + "wind_gust": 2.53, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1713805200, + "temp": 6.83, + "feels_like": 5.95, + "pressure": 1016, + "humidity": 55, + "dew_point": -1.39, + "uvi": 0.22, + "clouds": 59, + "visibility": 10000, + "wind_speed": 1.57, + "wind_deg": 41, + "wind_gust": 1.73, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713808800, + "temp": 6.33, + "feels_like": 6.33, + "pressure": 1019, + "humidity": 54, + "dew_point": -2.02, + "uvi": 0, + "clouds": 79, + "visibility": 10000, + "wind_speed": 1.33, + "wind_deg": 66, + "wind_gust": 1.27, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713812400, + "temp": 5.66, + "feels_like": 5.66, + "pressure": 1022, + "humidity": 56, + "dew_point": -2.51, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.29, + "wind_deg": 115, + "wind_gust": 1.74, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713816000, + "temp": 5.07, + "feels_like": 3.78, + "pressure": 1022, + "humidity": 59, + "dew_point": -2.36, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.7, + "wind_deg": 138, + "wind_gust": 2.56, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713819600, + "temp": 4.54, + "feels_like": 2.62, + "pressure": 1022, + "humidity": 60, + "dew_point": -2.43, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.21, + "wind_deg": 159, + "wind_gust": 3.88, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713823200, + "temp": 3.72, + "feels_like": 2.08, + "pressure": 1022, + "humidity": 62, + "dew_point": -2.87, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.82, + "wind_deg": 166, + "wind_gust": 3.2, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713826800, + "temp": 3.18, + "feels_like": 1.57, + "pressure": 1022, + "humidity": 64, + "dew_point": -2.99, + "uvi": 0, + "clouds": 96, + "visibility": 10000, + "wind_speed": 1.73, + "wind_deg": 193, + "wind_gust": 3.06, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713830400, + "temp": 2.91, + "feels_like": 1.47, + "pressure": 1022, + "humidity": 66, + "dew_point": -2.97, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.57, + "wind_deg": 211, + "wind_gust": 2.9, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713834000, + "temp": 2.7, + "feels_like": 1.05, + "pressure": 1021, + "humidity": 67, + "dew_point": -2.85, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 1.7, + "wind_deg": 212, + "wind_gust": 2.91, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713837600, + "temp": 2.19, + "feels_like": 0.45, + "pressure": 1021, + "humidity": 70, + "dew_point": -2.89, + "uvi": 0, + "clouds": 72, + "visibility": 10000, + "wind_speed": 1.71, + "wind_deg": 217, + "wind_gust": 3.06, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713841200, + "temp": 1.63, + "feels_like": -0.58, + "pressure": 1021, + "humidity": 72, + "dew_point": -2.98, + "uvi": 0, + "clouds": 72, + "visibility": 10000, + "wind_speed": 2.02, + "wind_deg": 227, + "wind_gust": 3.86, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713844800, + "temp": 1.2, + "feels_like": -1.47, + "pressure": 1020, + "humidity": 74, + "dew_point": -2.99, + "uvi": 0, + "clouds": 78, + "visibility": 10000, + "wind_speed": 2.37, + "wind_deg": 226, + "wind_gust": 4.72, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713848400, + "temp": 1.61, + "feels_like": -1.23, + "pressure": 1020, + "humidity": 73, + "dew_point": -2.7, + "uvi": 0.13, + "clouds": 83, + "visibility": 10000, + "wind_speed": 2.62, + "wind_deg": 241, + "wind_gust": 4.82, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713852000, + "temp": 2.83, + "feels_like": 0.07, + "pressure": 1020, + "humidity": 75, + "dew_point": -1.3, + "uvi": 0.4, + "clouds": 84, + "visibility": 10000, + "wind_speed": 2.79, + "wind_deg": 270, + "wind_gust": 4.72, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713855600, + "temp": 4.41, + "feels_like": 2.05, + "pressure": 1020, + "humidity": 71, + "dew_point": -0.34, + "uvi": 0.92, + "clouds": 65, + "visibility": 10000, + "wind_speed": 2.68, + "wind_deg": 265, + "wind_gust": 4.19, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713859200, + "temp": 6.08, + "feels_like": 3.83, + "pressure": 1020, + "humidity": 61, + "dew_point": -0.88, + "uvi": 1.63, + "clouds": 62, + "visibility": 10000, + "wind_speed": 2.96, + "wind_deg": 266, + "wind_gust": 4.32, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713862800, + "temp": 7.2, + "feels_like": 4.99, + "pressure": 1019, + "humidity": 52, + "dew_point": -2.03, + "uvi": 2.56, + "clouds": 75, + "visibility": 10000, + "wind_speed": 3.25, + "wind_deg": 267, + "wind_gust": 4.81, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713866400, + "temp": 8.24, + "feels_like": 5.97, + "pressure": 1018, + "humidity": 44, + "dew_point": -3.43, + "uvi": 3.47, + "clouds": 81, + "visibility": 10000, + "wind_speed": 3.75, + "wind_deg": 276, + "wind_gust": 5.07, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713870000, + "temp": 8.81, + "feels_like": 6.58, + "pressure": 1018, + "humidity": 38, + "dew_point": -4.6, + "uvi": 3.45, + "clouds": 84, + "visibility": 10000, + "wind_speed": 3.91, + "wind_deg": 287, + "wind_gust": 4.91, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713873600, + "temp": 9, + "feels_like": 6.9, + "pressure": 1017, + "humidity": 36, + "dew_point": -5.17, + "uvi": 2.82, + "clouds": 87, + "visibility": 10000, + "wind_speed": 3.75, + "wind_deg": 294, + "wind_gust": 4.62, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713877200, + "temp": 9.49, + "feels_like": 7.58, + "pressure": 1017, + "humidity": 35, + "dew_point": -5.28, + "uvi": 1.83, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.58, + "wind_deg": 299, + "wind_gust": 4.39, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713880800, + "temp": 9.65, + "feels_like": 7.99, + "pressure": 1016, + "humidity": 34, + "dew_point": -5.36, + "uvi": 1.69, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.17, + "wind_deg": 301, + "wind_gust": 4.04, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713884400, + "temp": 9.59, + "feels_like": 7.98, + "pressure": 1015, + "humidity": 35, + "dew_point": -5.25, + "uvi": 0.95, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.06, + "wind_deg": 302, + "wind_gust": 3.77, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713888000, + "temp": 9.26, + "feels_like": 7.72, + "pressure": 1015, + "humidity": 36, + "dew_point": -4.87, + "uvi": 0.52, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.85, + "wind_deg": 304, + "wind_gust": 3.24, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713891600, + "temp": 8.57, + "feels_like": 7.27, + "pressure": 1014, + "humidity": 40, + "dew_point": -4.26, + "uvi": 0.2, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.31, + "wind_deg": 309, + "wind_gust": 2.45, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713895200, + "temp": 7.68, + "feels_like": 6.98, + "pressure": 1014, + "humidity": 43, + "dew_point": -3.95, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.52, + "wind_deg": 318, + "wind_gust": 1.75, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713898800, + "temp": 6.98, + "feels_like": 6.98, + "pressure": 1014, + "humidity": 47, + "dew_point": -3.68, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 1.02, + "wind_deg": 353, + "wind_gust": 1.17, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713902400, + "temp": 6.46, + "feels_like": 6.46, + "pressure": 1014, + "humidity": 49, + "dew_point": -3.61, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.25, + "wind_deg": 50, + "wind_gust": 1.31, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713906000, + "temp": 5.97, + "feels_like": 4.77, + "pressure": 1014, + "humidity": 51, + "dew_point": -3.55, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 1.74, + "wind_deg": 83, + "wind_gust": 2.47, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713909600, + "temp": 5.49, + "feels_like": 3.88, + "pressure": 1013, + "humidity": 53, + "dew_point": -3.48, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.06, + "wind_deg": 104, + "wind_gust": 3.53, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713913200, + "temp": 4.85, + "feels_like": 3.17, + "pressure": 1012, + "humidity": 55, + "dew_point": -3.62, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.02, + "wind_deg": 110, + "wind_gust": 3.63, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713916800, + "temp": 4.27, + "feels_like": 2.76, + "pressure": 1012, + "humidity": 56, + "dew_point": -3.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 1.78, + "wind_deg": 119, + "wind_gust": 3.27, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713920400, + "temp": 3.99, + "feels_like": 2.57, + "pressure": 1012, + "humidity": 57, + "dew_point": -3.83, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 1.67, + "wind_deg": 118, + "wind_gust": 3.02, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713924000, + "temp": 3.47, + "feels_like": 1.61, + "pressure": 1011, + "humidity": 58, + "dew_point": -3.95, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 1.98, + "wind_deg": 116, + "wind_gust": 3.7, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713927600, + "temp": 3.01, + "feels_like": 1.06, + "pressure": 1011, + "humidity": 61, + "dew_point": -3.88, + "uvi": 0, + "clouds": 66, + "visibility": 10000, + "wind_speed": 1.99, + "wind_deg": 117, + "wind_gust": 3.83, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04n" + } + ], + "pop": 0 + }, + { + "dt": 1713931200, + "temp": 2.65, + "feels_like": 0.77, + "pressure": 1010, + "humidity": 63, + "dew_point": -3.64, + "uvi": 0, + "clouds": 59, + "visibility": 10000, + "wind_speed": 1.88, + "wind_deg": 112, + "wind_gust": 3.49, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713934800, + "temp": 3.44, + "feels_like": 1.66, + "pressure": 1010, + "humidity": 63, + "dew_point": -2.99, + "uvi": 0.17, + "clouds": 55, + "visibility": 10000, + "wind_speed": 1.9, + "wind_deg": 111, + "wind_gust": 3.4, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713938400, + "temp": 5.04, + "feels_like": 3.74, + "pressure": 1010, + "humidity": 59, + "dew_point": -2.23, + "uvi": 0.53, + "clouds": 51, + "visibility": 10000, + "wind_speed": 1.7, + "wind_deg": 108, + "wind_gust": 2.68, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713942000, + "temp": 6.82, + "feels_like": 5.79, + "pressure": 1010, + "humidity": 54, + "dew_point": -1.79, + "uvi": 1.12, + "clouds": 55, + "visibility": 10000, + "wind_speed": 1.7, + "wind_deg": 101, + "wind_gust": 2.19, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713945600, + "temp": 8.48, + "feels_like": 7.38, + "pressure": 1010, + "humidity": 49, + "dew_point": -1.73, + "uvi": 1.94, + "clouds": 38, + "visibility": 10000, + "wind_speed": 2.05, + "wind_deg": 96, + "wind_gust": 2.56, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1713949200, + "temp": 9.97, + "feels_like": 9.05, + "pressure": 1009, + "humidity": 43, + "dew_point": -2.11, + "uvi": 2.79, + "clouds": 32, + "visibility": 10000, + "wind_speed": 2.14, + "wind_deg": 99, + "wind_gust": 2.47, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1713952800, + "temp": 11.14, + "feels_like": 9.3, + "pressure": 1008, + "humidity": 38, + "dew_point": -2.66, + "uvi": 3.36, + "clouds": 45, + "visibility": 10000, + "wind_speed": 1.96, + "wind_deg": 105, + "wind_gust": 2.22, + "weather": [ + { + "id": 802, + "main": "Clouds", + "description": "scattered clouds", + "icon": "03d" + } + ], + "pop": 0 + }, + { + "dt": 1713956400, + "temp": 12.05, + "feels_like": 10.2, + "pressure": 1008, + "humidity": 34, + "dew_point": -3.09, + "uvi": 3.4, + "clouds": 56, + "visibility": 10000, + "wind_speed": 1.66, + "wind_deg": 106, + "wind_gust": 1.97, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713960000, + "temp": 11.73, + "feels_like": 9.87, + "pressure": 1007, + "humidity": 35, + "dew_point": -3.17, + "uvi": 2.58, + "clouds": 63, + "visibility": 10000, + "wind_speed": 2.06, + "wind_deg": 96, + "wind_gust": 1.83, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "pop": 0 + }, + { + "dt": 1713963600, + "temp": 12.09, + "feels_like": 10.29, + "pressure": 1007, + "humidity": 36, + "dew_point": -2.53, + "uvi": 2.13, + "clouds": 100, + "visibility": 10000, + "wind_speed": 3.1, + "wind_deg": 96, + "wind_gust": 2.17, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "pop": 0 + } + ], + "daily": [ + { + "dt": 1713783600, + "sunrise": 1713757952, + "sunset": 1713809805, + "moonrise": 1713804660, + "moonset": 1713756240, + "moon_phase": 0.45, + "summary": "Expect a day of partly cloudy with rain", + "temp": { + "day": 6.46, + "min": -0.14, + "max": 6.86, + "night": 4.54, + "eve": 6.83, + "morn": 0.55 + }, + "feels_like": { + "day": 4.47, + "night": 2.62, + "eve": 5.95, + "morn": -2.18 + }, + "pressure": 1016, + "humidity": 55, + "dew_point": -1.7, + "wind_speed": 4.28, + "wind_deg": 36, + "wind_gust": 9.62, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "clouds": 60, + "pop": 0.42, + "rain": 0.13, + "uvi": 3.11 + }, + { + "dt": 1713870000, + "sunrise": 1713844225, + "sunset": 1713896310, + "moonrise": 1713895500, + "moonset": 1713843180, + "moon_phase": 0.48, + "summary": "There will be partly cloudy today", + "temp": { + "day": 8.81, + "min": 1.2, + "max": 9.65, + "night": 5.97, + "eve": 8.57, + "morn": 1.61 + }, + "feels_like": { + "day": 6.58, + "night": 4.77, + "eve": 7.27, + "morn": -1.23 + }, + "pressure": 1018, + "humidity": 38, + "dew_point": -4.6, + "wind_speed": 3.91, + "wind_deg": 287, + "wind_gust": 5.07, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": 84, + "pop": 0, + "uvi": 3.47 + }, + { + "dt": 1713956400, + "sunrise": 1713930500, + "sunset": 1713982815, + "moonrise": 1713986520, + "moonset": 1713930240, + "moon_phase": 0.5, + "summary": "There will be partly cloudy today", + "temp": { + "day": 12.05, + "min": 2.65, + "max": 12.09, + "night": 7.11, + "eve": 11.14, + "morn": 3.44 + }, + "feels_like": { + "day": 10.2, + "night": 4.96, + "eve": 9.33, + "morn": 1.66 + }, + "pressure": 1008, + "humidity": 34, + "dew_point": -3.09, + "wind_speed": 3.44, + "wind_deg": 45, + "wind_gust": 7.56, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": 56, + "pop": 0, + "uvi": 3.4 + }, + { + "dt": 1714042800, + "sunrise": 1714016776, + "sunset": 1714069320, + "moonrise": 1714077660, + "moonset": 1714017480, + "moon_phase": 0.55, + "summary": "There will be partly cloudy today", + "temp": { + "day": 10.8, + "min": 4.47, + "max": 12.46, + "night": 6.99, + "eve": 9.86, + "morn": 4.85 + }, + "feels_like": { + "day": 8.88, + "night": 4.96, + "eve": 8.3, + "morn": 3.15 + }, + "pressure": 1005, + "humidity": 36, + "dew_point": -3.67, + "wind_speed": 3.09, + "wind_deg": 354, + "wind_gust": 5.38, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": 100, + "pop": 0, + "uvi": 2.66 + }, + { + "dt": 1714129200, + "sunrise": 1714103053, + "sunset": 1714155825, + "moonrise": 0, + "moonset": 1714105020, + "moon_phase": 0.58, + "summary": "There will be partly cloudy today", + "temp": { + "day": 14.89, + "min": 4.75, + "max": 14.89, + "night": 9.67, + "eve": 12.47, + "morn": 7.12 + }, + "feels_like": { + "day": 13.32, + "night": 9.67, + "eve": 10.92, + "morn": 6.36 + }, + "pressure": 1010, + "humidity": 34, + "dew_point": -0.95, + "wind_speed": 3.29, + "wind_deg": 219, + "wind_gust": 4.33, + "weather": [ + { + "id": 803, + "main": "Clouds", + "description": "broken clouds", + "icon": "04d" + } + ], + "clouds": 73, + "pop": 0, + "uvi": 3.19 + }, + { + "dt": 1714215600, + "sunrise": 1714189331, + "sunset": 1714242329, + "moonrise": 1714168860, + "moonset": 1714193160, + "moon_phase": 0.61, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { + "day": 18.41, + "min": 6.58, + "max": 18.81, + "night": 12.2, + "eve": 15.59, + "morn": 9.08 + }, + "feels_like": { + "day": 17.14, + "night": 11.17, + "eve": 14.48, + "morn": 7.32 + }, + "pressure": 1012, + "humidity": 32, + "dew_point": 1.18, + "wind_speed": 4.6, + "wind_deg": 132, + "wind_gust": 9.27, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 7, + "pop": 0, + "uvi": 4 + }, + { + "dt": 1714302000, + "sunrise": 1714275610, + "sunset": 1714328834, + "moonrise": 1714259640, + "moonset": 1714282140, + "moon_phase": 0.64, + "summary": "There will be partly cloudy today", + "temp": { + "day": 21.09, + "min": 9.57, + "max": 21.71, + "night": 14.94, + "eve": 18.3, + "morn": 11.65 + }, + "feels_like": { + "day": 20.25, + "night": 14.27, + "eve": 17.54, + "morn": 10.86 + }, + "pressure": 1017, + "humidity": 38, + "dew_point": 6.37, + "wind_speed": 2.63, + "wind_deg": 108, + "wind_gust": 5.85, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": 100, + "pop": 0, + "uvi": 4 + }, + { + "dt": 1714388400, + "sunrise": 1714361891, + "sunset": 1714415338, + "moonrise": 1714349580, + "moonset": 1714372140, + "moon_phase": 0.68, + "summary": "There will be partly cloudy today", + "temp": { + "day": 22.15, + "min": 11.98, + "max": 23.78, + "night": 18.74, + "eve": 20.61, + "morn": 13.9 + }, + "feels_like": { + "day": 21.49, + "night": 18.37, + "eve": 20.19, + "morn": 13.3 + }, + "pressure": 1021, + "humidity": 41, + "dew_point": 8.34, + "wind_speed": 4.45, + "wind_deg": 91, + "wind_gust": 10.64, + "weather": [ + { + "id": 804, + "main": "Clouds", + "description": "overcast clouds", + "icon": "04d" + } + ], + "clouds": 100, + "pop": 0, + "uvi": 4 + } + ], + "alerts": [ + { + "sender_name": "Deutscher Wetterdienst", + "event": "frost", + "start": 1713816000, + "end": 1713855600, + "description": "There is a risk of frost (level 1 of 2).\nMinimum temperature: -1 - -4 °C; near surface: -4 - -7 °C", + "tags": ["Extreme low temperature"] + } + ] +} diff --git a/Tests/HPOpenWeatherTests/TestSecret.swift b/Tests/HPOpenWeatherTests/TestSecret.swift deleted file mode 100644 index 712e092..0000000 --- a/Tests/HPOpenWeatherTests/TestSecret.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -struct TestSecret { - - static let apiKey: String! = ProcessInfo.processInfo.environment["API_KEY"] - -} diff --git a/Tests/HPOpenWeatherTests/WeatherIconTests.swift b/Tests/HPOpenWeatherTests/WeatherIconTests.swift index b72eb65..caa350c 100644 --- a/Tests/HPOpenWeatherTests/WeatherIconTests.swift +++ b/Tests/HPOpenWeatherTests/WeatherIconTests.swift @@ -1,15 +1,13 @@ import XCTest + @testable import HPOpenWeather final class WeatherIconTests: XCTestCase { - #if canImport(UIKit) - @available(iOS 13.0, tvOS 13.0, *) - func testAllSystemImages() { - WeatherIcon.allCases.forEach { - XCTAssertNotNil($0.filledUIImage()) - } - } - #endif + func testAllSystemImages() { + for icon in WeatherIcon.allCases { + XCTAssertNotNil(icon.filledNSImage()) + } + } } diff --git a/Tests/HPOpenWeatherTests/WeatherTests.swift b/Tests/HPOpenWeatherTests/WeatherTests.swift new file mode 100644 index 0000000..4cf04a8 --- /dev/null +++ b/Tests/HPOpenWeatherTests/WeatherTests.swift @@ -0,0 +1,33 @@ +import HPNetwork +import XCTest + +@testable import HPOpenWeather + +final class WeatherTests: XCTestCase { + + func testEncoding() throws { + let weather = try makeWeatherResponse() + XCTAssertNoThrow(try JSONEncoder().encode(weather)) + } + + func testEncodingAndDecoding() throws { + let weather = try makeWeatherResponse() + let encodedJSON = try JSONEncoder().encode(weather) + let decodedWeather = try JSONDecoder().decode(Weather.self, from: encodedJSON) + + XCTAssertEqual(decodedWeather, weather) + } + + private func makeWeatherResponse() throws -> Weather { + let jsonDataURL = try XCTUnwrap( + Bundle.module.url( + forResource: "3-0-test-response", + withExtension: "json" + ) + ) + let jsonData = try Data(contentsOf: jsonDataURL) + + return try JSONDecoder().decode(Weather.self, from: jsonData) + } + +} diff --git a/config/periphery.yml b/config/periphery.yml new file mode 100644 index 0000000..85b884a --- /dev/null +++ b/config/periphery.yml @@ -0,0 +1 @@ +retain_public: true diff --git a/config/swift-format.json b/config/swift-format.json new file mode 100644 index 0000000..e7bcf68 --- /dev/null +++ b/config/swift-format.json @@ -0,0 +1,67 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": false, + "lineLength": 140, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": ["XCTAssertNoThrow"] + }, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": true, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": true, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true + }, + "spacesAroundRangeFormationOperators": true, + "tabWidth": 4, + "version": 1 +}