diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..2c38942
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: test
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: erlef/setup-beam@v1
+ with:
+ otp-version: ">=27"
+ gleam-version: "1.3.2"
+ rebar3-version: "3"
+ # elixir-version: "1.15.4"
+ - run: gleam deps download
+ - run: gleam test
+ - run: gleam format --check src test
diff --git a/README.md b/README.md
index 7812678..dc861fc 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# 🌤️ Sunny (WIP)
+# 🌤️ Sunny
An [Open-meteo API](https://open-meteo.com/) client written in Gleam.
@@ -12,44 +12,64 @@ Makes it easier to get weather forecasts, current and past weather data with dif
Add this package to your gleam project (not online yet)
```sh
-gleam add sunny@1
+gleam add sunny
```
-### Getting the coordinates of a city
+### Getting the current temperature in a city
+
+ Example code
+
```gleam
+import gleam/dict
+import gleam/io
+import gleam/option
+
import sunny
+import sunny/api/forecast
+import sunny/api/forecast/instant
import sunny/api/geocoding
+import sunny/measurement
pub fn main() {
- // Use `new_commercial("")` if you have a commercial Open-meteo
- // API access
let sunny = sunny.new()
let assert Ok(location) =
- geocoding.get_first_location(sunny, {
+ sunny
+ |> geocoding.get_first_location(
geocoding.params("marseille")
- |> geocoding.set_language(geocoding.French)
- })
+ |> geocoding.set_language(geocoding.French),
+ )
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(geocoding.location_to_position(location))
+ |> forecast.set_current([instant.Temperature2m]),
+ )
+
+ let assert option.Some(current_data) = forecast_result.current
+
+ let assert Ok(temperature) =
+ current_data.data |> dict.get(instant.Temperature2m)
io.println(
location.name
- <> " is located at :\n"
- <> float.to_string(location.latitude)
- <> "\n"
- <> float.to_string(location.longitude),
+ <> "'s current temperature is : "
+ <> measurement.to_string(temperature),
)
}
```
+
+
+More examples in the `test/examples` directory
Further documentation can be found at .
## Contributing
-The project is open for contributions ! Make a fork, and once you made the changes you wanted, make a PR.
+Contributions are very welcome ! Make a fork, and once you made the changes you wanted, make a PR.
### Todo
-- Weather forecast API
- Historical forecast API
- Air quality API
-- Make tests to make sure nothing is breaking
diff --git a/examples/city_info.gleam b/examples/city_info.gleam
deleted file mode 100644
index ca075e0..0000000
--- a/examples/city_info.gleam
+++ /dev/null
@@ -1,22 +0,0 @@
-import sunny
-import sunny/api/geocoding
-
-pub fn main() {
- // Use `new_commercial("")` if you have a commercial Open-meteo
- // API access
- let sunny = sunny.new()
-
- let assert Ok(location) =
- geocoding.get_first_location(sunny, {
- geocoding.params("marseille")
- |> geocoding.set_language(geocoding.French)
- })
-
- io.println(
- location.name
- <> " is located at :\n"
- <> float.to_string(location.latitude)
- <> "\n"
- <> float.to_string(location.longitude),
- )
-}
diff --git a/gleam.toml b/gleam.toml
index e9c5df0..80baa59 100644
--- a/gleam.toml
+++ b/gleam.toml
@@ -1,5 +1,6 @@
name = "sunny"
version = "0.2.0"
+gleam = ">= 0.32.0"
# Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager.
@@ -14,9 +15,10 @@ version = "0.2.0"
[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
-gleam_json = ">= 2.0.0 and < 3.0.0"
gleam_http = ">= 3.6.0 and < 4.0.0"
efetch = ">= 2.0.1 and < 3.0.0"
+birl = ">= 1.7.1 and < 2.0.0"
+gleam_json = ">= 1.0.1 and < 2.0.0"
[dev-dependencies]
-gleeunit = ">= 1.0.0 and < 2.0.0"
+glacier = ">= 1.1.0 and < 2.0.0"
diff --git a/manifest.toml b/manifest.toml
index 13f17fd..8dfae2a 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -2,20 +2,32 @@
# You typically do not need to edit this file
packages = [
+ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
+ { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" },
{ name = "efetch", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_stdlib"], otp_app = "efetch", source = "hex", outer_checksum = "849E38898C13436517C31A16D94C4642B3FFC444E26E293A14A1E3A8B7081C8A" },
+ { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" },
+ { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" },
+ { name = "glacier", version = "1.1.0", build_tools = ["gleam"], requirements = ["argv", "fs", "glacier_gleeunit", "gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "shellout", "simplifile"], otp_app = "glacier", source = "hex", outer_checksum = "FC194FE584147BA997BAA1D33305A64BFE3F01112DBC70D27A3AC491A4338CC7" },
+ { name = "glacier_gleeunit", version = "1.2.1001", build_tools = ["gleam"], requirements = ["argv", "gleam_stdlib"], otp_app = "glacier_gleeunit", source = "hex", outer_checksum = "F63ABBCE21DDBB0410B1365756BA4F897B504EA567A24462A1BC3291972FC981" },
+ { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" },
+ { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" },
{ name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
{ name = "gleam_fetch", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "4AE60B21A9A664137A79B1BEB93F751CB27F1DDED4086CA00C0260F5FFACBD80" },
{ name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" },
{ name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" },
{ name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" },
- { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" },
+ { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" },
{ name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" },
- { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" },
+ { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" },
+ { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" },
+ { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" },
+ { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" },
]
[requirements]
+birl = { version = ">= 1.7.1 and < 2.0.0" }
efetch = { version = ">= 2.0.1 and < 3.0.0" }
+glacier = { version = ">= 1.1.0 and < 2.0.0" }
gleam_http = { version = ">= 3.6.0 and < 4.0.0" }
-gleam_json = { version = ">= 2.0.0 and < 3.0.0" }
+gleam_json = { version = ">= 1.0.1 and < 2.0.0"}
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
-gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
diff --git a/src/sunny.gleam b/src/sunny.gleam
index e92d2c6..57bf8b0 100644
--- a/src/sunny.gleam
+++ b/src/sunny.gleam
@@ -5,15 +5,17 @@ import sunny/internal/defaults
/// need anything more).
///
/// If you have a commercial Open-meteo API acess, check out `new_commercial`.
-///
-/// To change some client parameters (such as the base url), check out the
-/// `sunny/client` module.
pub fn new() -> Client {
Client(defaults.base_url, False, "")
}
-/// Creates a new commercial Open-meteo client with the default values
+/// Creates a new commercial Open-meteo client with the default values.
/// Takes your Open-meteo api key as an argument.
pub fn new_commercial(key: String) -> Client {
Client(defaults.base_url, True, key)
}
+
+/// Takes a Client and returns a new one with a custom base url.
+pub fn set_base_url(client: Client, url: String) -> Client {
+ Client(..client, base_url: url)
+}
diff --git a/src/sunny/api/forecast.gleam b/src/sunny/api/forecast.gleam
new file mode 100644
index 0000000..a8a24da
--- /dev/null
+++ b/src/sunny/api/forecast.gleam
@@ -0,0 +1,687 @@
+//// The module for interactiong with the Forecast API.
+////
+//// ## Example
+////
+//// Get the hourly forecast of a city
+////
+//// ```gleam
+//// import birl
+////
+//// import gleam/float
+//// import gleam/io
+//// import gleam/list
+////
+//// import sunny
+//// import sunny/api/forecast
+//// import sunny/api/forecast/data
+//// import sunny/api/forecast/instant
+//// import sunny/position
+//// import sunny/wmo_code
+////
+//// pub fn main() {
+//// // Use `new_commercial("")` if you have a commercial Open-meteo
+//// // API access.
+//// let sunny = sunny.new()
+////
+//// // You can get the coordinates of a place using the Geocoding API. See
+//// // `sunny/api/geocoding`, or the `city_info` example.
+//// //
+//// // Once you have a `Location`, use `geocoding.location_to_position()` to
+//// // convert it to a position.
+//// let position = position.Position(43.0, 5.0)
+////
+//// let assert Ok(forecast_result) =
+//// sunny
+//// |> forecast.get_forecast(
+//// forecast.params(position)
+//// // All available variables are listed in the `sunny/api/forecast/instant`
+//// // module.
+//// // Daily variables are in `sunny/api/forecast/daily`.
+//// |> forecast.set_hourly([instant.WeatherCode])
+//// |> forecast.set_forecast_days(1),
+//// )
+////
+//// let assert Ok(hourly_weather) =
+//// forecast_result.hourly
+//// |> data.range_to_data_list(instant.WeatherCode)
+////
+//// hourly_weather
+//// |> list.each(fn(timed_data) {
+//// io.debug(
+//// birl.to_time_string(timed_data.time)
+//// <> " : "
+//// // `wmo_code.to_string` translates the `Int` WMOCode to a human-readable
+//// // `String`.
+//// <> wmo_code.to_string(float.round(timed_data.data.value)),
+//// )
+//// })
+//// }
+//// ```
+
+import birl
+
+import gleam/float
+import gleam/int
+import gleam/list
+import gleam/option
+import gleam/result
+
+import sunny/api/forecast/daily
+import sunny/api/forecast/data
+import sunny/api/forecast/instant
+import sunny/errors
+import sunny/internal/api/forecast
+import sunny/internal/client
+import sunny/internal/utils
+import sunny/position
+
+pub type TemperatureUnit {
+ Celsius
+ Fahrenheit
+}
+
+fn temp_unit_to_string(u: TemperatureUnit) -> String {
+ case u {
+ Celsius -> "celsius"
+ Fahrenheit -> "fahrenheit"
+ }
+}
+
+pub type WindSpeedUnit {
+ KilometersPerHour
+ MetersPerSecond
+ MilesPerHour
+ Knots
+}
+
+fn wind_unit_to_string(u: WindSpeedUnit) -> String {
+ case u {
+ KilometersPerHour -> "kmh"
+ MetersPerSecond -> "ms"
+ MilesPerHour -> "mph"
+ Knots -> "kn"
+ }
+}
+
+pub type PrecipitationUnit {
+ Millimeters
+ Inches
+}
+
+fn precipitation_unit_to_string(u: PrecipitationUnit) -> String {
+ case u {
+ Millimeters -> "mm"
+ Inches -> "inch"
+ }
+}
+
+pub type CellSelection {
+ Land
+ Sea
+ Nearest
+}
+
+fn cell_select_to_string(c: CellSelection) -> String {
+ case c {
+ Land -> "land"
+ Sea -> "sea"
+ Nearest -> "nearest"
+ }
+}
+
+/// The result of a request to the Forecast API.
+pub type ForecastResult {
+ ForecastResult(
+ position: position.Position,
+ elevation: Float,
+ utc_offset_seconds: Int,
+ timezone: String,
+ timezone_abbreviation: String,
+ hourly: data.TimeRangedData(instant.InstantVariable),
+ daily: data.TimeRangedData(daily.DailyVariable),
+ minutely: data.TimeRangedData(instant.InstantVariable),
+ current: option.Option(data.CurrentData(instant.InstantVariable)),
+ )
+}
+
+/// The different parameters available on the Forecast API.
+///
+/// See for further reference.
+pub type ForecastParams {
+ ForecastParams(
+ // TODO: Support multiple positions in one call
+ position: position.Position,
+ hourly: List(instant.InstantVariable),
+ daily: List(daily.DailyVariable),
+ /// Get data every 15 minutes. Some data can't be optained every 15
+ /// minutes, so it will be interpolated over the hour (or more if needed)
+ minutely: List(instant.InstantVariable),
+ current: List(instant.InstantVariable),
+ temperature_unit: TemperatureUnit,
+ wind_speed_unit: WindSpeedUnit,
+ precipitation_unit: PrecipitationUnit,
+ /// Full list here :
+ ///
+ /// You can set `timezone` to `auto`, and the API will deduce the local
+ /// timezone from the coordinates.
+ ///
+ /// For abbreviations, in my experience only three letter ones work
+ /// (e.g. `CEST` returns an error).
+ timezone: String,
+ /// Will be clamped between 0 and 92.
+ past_days: Int,
+ /// Will be clamped between 0 and 16.
+ forecast_days: Int,
+ /// If `forecast_hours` is negative or null, it will be set to `option.None`.
+ forecast_hours: option.Option(Int),
+ /// If `forecast_minutely_15` is negative or null, it will be set to `option.None`.
+ forecast_minutely_15: option.Option(Int),
+ /// If `past_hours` is negative or null, it will be set to `option.None`.
+ past_hours: option.Option(Int),
+ /// If `past_minutely_15` is negative or null, it will be set to `option.None`.
+ past_minutely_15: option.Option(Int),
+ start_date: option.Option(birl.Time),
+ end_date: option.Option(birl.Time),
+ start_hour: option.Option(birl.Time),
+ end_hour: option.Option(birl.Time),
+ start_minutely_15: option.Option(birl.Time),
+ end_minutely_15: option.Option(birl.Time),
+ // TODO: support multiple models
+ cell_selection: CellSelection,
+ )
+}
+
+/// Creates a new ForecastParams with the default values
+pub fn params(position: position.Position) -> ForecastParams {
+ ForecastParams(
+ position,
+ [],
+ [],
+ [],
+ [],
+ Celsius,
+ KilometersPerHour,
+ Millimeters,
+ "GMT",
+ 0,
+ 7,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ option.None,
+ Land,
+ )
+}
+
+/// Returns a new ForecastParams with the specified temperature unit.
+pub fn set_temperature_unit(
+ params: ForecastParams,
+ unit: TemperatureUnit,
+) -> ForecastParams {
+ ForecastParams(..params, temperature_unit: unit)
+}
+
+/// Returns a new ForecastParams with the specified temperature unit.
+pub fn set_wind_speed_unit(
+ params: ForecastParams,
+ unit: WindSpeedUnit,
+) -> ForecastParams {
+ ForecastParams(..params, wind_speed_unit: unit)
+}
+
+/// Returns a new ForecastParams with the specified temperature unit.
+pub fn set_precipitation_unit(
+ params: ForecastParams,
+ unit: PrecipitationUnit,
+) -> ForecastParams {
+ ForecastParams(..params, precipitation_unit: unit)
+}
+
+/// Returns a new ForecastParams with the specified cell selection.
+pub fn set_cell_selection(
+ params: ForecastParams,
+ cell_selection: CellSelection,
+) -> ForecastParams {
+ ForecastParams(..params, cell_selection: cell_selection)
+}
+
+/// Returns a new ForecastParams with the specified timezone.
+pub fn set_timezone(params: ForecastParams, timezone: String) -> ForecastParams {
+ ForecastParams(..params, timezone: timezone)
+}
+
+/// Returns a new ForecastParams with the specified forecast days.
+///
+/// `forecast_days` will be clamped between 0 and 16.
+pub fn set_forecast_days(
+ params: ForecastParams,
+ forecast_days: Int,
+) -> ForecastParams {
+ ForecastParams(..params, forecast_days: int.clamp(forecast_days, 0, 16))
+}
+
+/// Returns a new ForecastParams with the specified past days.
+///
+/// `past_days` will be clamped between 0 and 92.
+pub fn set_past_days(params: ForecastParams, past_days: Int) -> ForecastParams {
+ ForecastParams(..params, past_days: int.clamp(past_days, 0, 95))
+}
+
+/// Returns a new ForecastParams with the specified forecast hours.
+///
+/// If `forecast_hours` is negative or null, it will be set to `option.None`.
+pub fn set_forecast_hours(
+ params: ForecastParams,
+ forecast_hours: Int,
+) -> ForecastParams {
+ case forecast_hours {
+ _ if forecast_hours <= 0 ->
+ ForecastParams(..params, forecast_hours: option.None)
+ _ -> ForecastParams(..params, forecast_hours: option.Some(forecast_hours))
+ }
+}
+
+/// Returns a new ForecastParams with the specified forecast minutely 15.
+///
+/// If `forecast_minutely_15` is negative or null, it will be set to `option.None`.
+pub fn set_forecast_minutely_15(
+ params: ForecastParams,
+ forecast_minutely_15: Int,
+) -> ForecastParams {
+ case forecast_minutely_15 {
+ _ if forecast_minutely_15 <= 0 ->
+ ForecastParams(..params, forecast_minutely_15: option.None)
+ _ ->
+ ForecastParams(
+ ..params,
+ forecast_minutely_15: option.Some(forecast_minutely_15),
+ )
+ }
+}
+
+/// Returns a new ForecastParams with the specified past hours.
+///
+/// If `past_hours` is negative or null, it will be set to `option.None`.
+pub fn set_past_hours(params: ForecastParams, past_hours: Int) -> ForecastParams {
+ case past_hours {
+ _ if past_hours <= 0 -> ForecastParams(..params, past_hours: option.None)
+ _ -> ForecastParams(..params, past_hours: option.Some(past_hours))
+ }
+}
+
+/// Returns a new ForecastParams with the specified past minutely 15.
+///
+/// If `past_minutely_15` is negative or null, it will be set to `option.None`.
+pub fn set_past_minutely_15(
+ params: ForecastParams,
+ past_minutely_15: Int,
+) -> ForecastParams {
+ case past_minutely_15 {
+ _ if past_minutely_15 <= 0 ->
+ ForecastParams(..params, past_minutely_15: option.None)
+ _ ->
+ ForecastParams(..params, past_minutely_15: option.Some(past_minutely_15))
+ }
+}
+
+/// Returns a new ForecastParams with the specified start date.
+pub fn set_start_date(
+ params: ForecastParams,
+ start_date: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, start_date: option.Some(start_date))
+}
+
+/// Returns a new ForecastParams with the specified end date.
+pub fn set_end_date(
+ params: ForecastParams,
+ end_date: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, end_date: option.Some(end_date))
+}
+
+/// Returns a new ForecastParams with the specified start hour.
+pub fn set_start_hour(
+ params: ForecastParams,
+ start_hour: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, start_hour: option.Some(start_hour))
+}
+
+/// Returns a new ForecastParams with the specified end hour.
+pub fn set_end_hour(
+ params: ForecastParams,
+ end_hour: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, end_hour: option.Some(end_hour))
+}
+
+/// Returns a new ForecastParams with the specified start minutely 15.
+pub fn set_start_minutely_15(
+ params: ForecastParams,
+ start_minutely_15: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, start_minutely_15: option.Some(start_minutely_15))
+}
+
+/// Returns a new ForecastParams with the specified end minutely 15.
+pub fn set_end_minutely_15(
+ params: ForecastParams,
+ end_minutely_15: birl.Time,
+) -> ForecastParams {
+ ForecastParams(..params, end_minutely_15: option.Some(end_minutely_15))
+}
+
+/// Returns a new ForecastParams with the specified hourly list.
+pub fn set_hourly(
+ params: ForecastParams,
+ hourly_list: List(instant.InstantVariable),
+) -> ForecastParams {
+ ForecastParams(..params, hourly: hourly_list)
+}
+
+/// Returns a new `ForecastParams` with all the hourly variables except the
+/// ones in the `except` argument.
+pub fn set_all_hourly(
+ params: ForecastParams,
+ except: List(instant.InstantVariable),
+) -> ForecastParams {
+ set_all(params, except, instant.all, set_hourly)
+}
+
+/// Returns a new ForecastParams with the specified daily list
+pub fn set_daily(
+ params: ForecastParams,
+ daily_list: List(daily.DailyVariable),
+) -> ForecastParams {
+ ForecastParams(..params, daily: daily_list)
+}
+
+/// Returns a new `ForecastParams` with all the daily variables except the
+/// ones in the `except` argument
+pub fn set_all_daily(
+ params: ForecastParams,
+ except: List(daily.DailyVariable),
+) -> ForecastParams {
+ set_all(params, except, daily.all, set_daily)
+}
+
+/// Returns a new ForecastParams with the specified 15-minutely list
+pub fn set_minutely(
+ params: ForecastParams,
+ minutely_list: List(instant.InstantVariable),
+) -> ForecastParams {
+ ForecastParams(..params, minutely: minutely_list)
+}
+
+/// Returns a new `ForecastParams` with all the minutely variables except the
+/// ones in the `except` argument.
+pub fn set_all_minutely(
+ params: ForecastParams,
+ except: List(instant.InstantVariable),
+) -> ForecastParams {
+ set_all(params, except, instant.all, set_minutely)
+}
+
+/// Returns a new ForecastParams with the specified hourly list
+pub fn set_current(
+ params: ForecastParams,
+ current_list: List(instant.InstantVariable),
+) -> ForecastParams {
+ ForecastParams(..params, current: current_list)
+}
+
+/// Returns a new `ForecastParams` with all the current variables except the
+/// ones in the `except` argument.
+pub fn set_all_current(
+ params: ForecastParams,
+ except: List(instant.InstantVariable),
+) -> ForecastParams {
+ set_all(params, except, instant.all, set_current)
+}
+
+fn set_all(
+ params: ForecastParams,
+ except: List(a),
+ all: List(a),
+ set_fn: fn(ForecastParams, List(a)) -> ForecastParams,
+) -> ForecastParams {
+ case except {
+ [] -> params |> set_fn(all)
+ [_, ..] ->
+ params
+ |> set_fn(list.filter(all, fn(x) { list.contains(except, x) }))
+ }
+}
+
+/// Get a `ForecastResult` according to the specified `ForecastParams`.
+pub fn get_forecast(
+ client: client.Client,
+ params: ForecastParams,
+) -> Result(ForecastResult, errors.SunnyError) {
+ make_request(client, params)
+}
+
+fn make_request(
+ client: client.Client,
+ params: ForecastParams,
+) -> Result(ForecastResult, errors.SunnyError) {
+ let params = verify_params(params)
+
+ use json_string <- result.try(
+ utils.get_final_url(
+ client.base_url,
+ "",
+ client.commercial,
+ "/forecast",
+ client.key,
+ params |> forecast_params_to_params_list,
+ )
+ |> utils.make_request
+ |> result.map_error(fn(x) { errors.HttpError(x) }),
+ )
+ use raw_result <- result.try(forecast.raw_forecast_result_from_json(
+ json_string,
+ ))
+ raw_result
+ |> refine_raw_result()
+ |> result.map_error(fn(e) { errors.SunnyInternalError(e) })
+}
+
+fn verify_params(params: ForecastParams) -> ForecastParams {
+ ForecastParams(
+ ..params,
+ hourly: list.unique(params.hourly),
+ daily: list.unique(params.daily),
+ minutely: list.unique(params.minutely),
+ current: list.unique(params.current),
+ )
+ |> set_past_days(params.past_days)
+ |> set_forecast_days(params.forecast_days)
+ |> set_if_some(params.forecast_hours, set_forecast_hours)
+ |> set_if_some(params.forecast_minutely_15, set_forecast_minutely_15)
+ |> set_if_some(params.past_hours, set_past_hours)
+ |> set_if_some(params.past_minutely_15, set_past_minutely_15)
+}
+
+fn set_if_some(
+ params: ForecastParams,
+ opt: option.Option(a),
+ func: fn(ForecastParams, a) -> ForecastParams,
+) -> ForecastParams {
+ case opt {
+ option.Some(x) -> func(params, x)
+ option.None -> params
+ }
+}
+
+fn forecast_params_to_params_list(
+ params: ForecastParams,
+) -> List(utils.RequestParameter) {
+ option_to_param_list(params.forecast_hours, "forecast_hours", int.to_string)
+ |> list.append(list_to_param_list(
+ params.hourly,
+ "hourly",
+ forecast.instant_to_string,
+ ))
+ |> list.append(list_to_param_list(
+ params.daily,
+ "daily",
+ forecast.daily_to_string,
+ ))
+ |> list.append(list_to_param_list(
+ params.minutely,
+ "minutely_15",
+ forecast.instant_to_string,
+ ))
+ |> list.append(list_to_param_list(
+ params.current,
+ "current",
+ forecast.instant_to_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.forecast_minutely_15,
+ "forecast_minutely_15",
+ int.to_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.past_hours,
+ "past_hours",
+ int.to_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.past_minutely_15,
+ "past_minutely_15",
+ int.to_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.start_date,
+ "start_date",
+ birl.to_naive_date_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.end_date,
+ "end_date",
+ birl.to_naive_date_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.start_hour,
+ "start_hour",
+ birl.to_naive_time_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.end_hour,
+ "end_hour",
+ birl.to_naive_date_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.start_minutely_15,
+ "start_minutely_15",
+ birl.to_naive_date_string,
+ ))
+ |> list.append(option_to_param_list(
+ params.end_minutely_15,
+ "end_minutely_15",
+ birl.to_naive_date_string,
+ ))
+ |> list.append([
+ utils.RequestParameter(
+ "latitude",
+ params.position.latitude |> float.to_string,
+ ),
+ utils.RequestParameter(
+ "longitude",
+ params.position.longitude |> float.to_string,
+ ),
+ utils.RequestParameter(
+ "temperature_unit",
+ params.temperature_unit |> temp_unit_to_string,
+ ),
+ utils.RequestParameter(
+ "wind_speed_unit",
+ params.wind_speed_unit |> wind_unit_to_string,
+ ),
+ utils.RequestParameter(
+ "precipitation_unit",
+ params.precipitation_unit |> precipitation_unit_to_string,
+ ),
+ utils.RequestParameter("timezone", params.timezone),
+ utils.RequestParameter("past_days", params.past_days |> int.to_string),
+ utils.RequestParameter(
+ "forecast_days",
+ params.forecast_days |> int.to_string,
+ ),
+ utils.RequestParameter(
+ "cell_selection",
+ params.cell_selection |> cell_select_to_string,
+ ),
+ ])
+}
+
+fn option_to_param_list(
+ opt: option.Option(a),
+ opt_string: String,
+ to_string_fn: fn(a) -> String,
+) -> List(utils.RequestParameter) {
+ case opt {
+ option.Some(x) -> [utils.RequestParameter(opt_string, x |> to_string_fn)]
+ option.None -> []
+ }
+}
+
+fn list_to_param_list(
+ l: List(a),
+ l_string: String,
+ to_string_fn: fn(a) -> String,
+) -> List(utils.RequestParameter) {
+ case l {
+ [_, ..] -> [
+ l
+ |> utils.param_list_to_string(to_string_fn)
+ |> utils.RequestParameter(l_string, _),
+ ]
+ [] -> []
+ }
+}
+
+fn refine_raw_result(
+ raw: forecast.RawForecastResult,
+) -> Result(ForecastResult, errors.InternalError) {
+ use hourly <- result.try(forecast.refine_raw_time_ranged_data(
+ raw.hourly,
+ raw.hourly_units,
+ forecast.string_to_instant,
+ ))
+ use daily <- result.try(forecast.refine_raw_time_ranged_data(
+ raw.daily,
+ raw.daily_units,
+ forecast.string_to_daily,
+ ))
+ use minutely <- result.try(forecast.refine_raw_time_ranged_data(
+ raw.minutely,
+ raw.minutely_units,
+ forecast.string_to_instant,
+ ))
+ use current <- result.try(forecast.refine_raw_current_data(
+ raw.current,
+ raw.current_units,
+ forecast.string_to_instant,
+ ))
+ Ok(ForecastResult(
+ position.Position(raw.latitude, raw.longitude),
+ raw.elevation,
+ raw.utc_offset_seconds,
+ raw.timezone,
+ raw.timezone_abbreviation,
+ hourly,
+ daily,
+ minutely,
+ current,
+ ))
+}
diff --git a/src/sunny/api/forecast/daily.gleam b/src/sunny/api/forecast/daily.gleam
new file mode 100644
index 0000000..61d0f7e
--- /dev/null
+++ b/src/sunny/api/forecast/daily.gleam
@@ -0,0 +1,35 @@
+/// Variables that can be obtained at for a specific day (for the `daily` field)
+///
+/// See
+pub type DailyVariable {
+ MaximumTemperature2m
+ MinimumTemperature2m
+ ApparentTemperatureMax
+ ApparentTemperatureMin
+ PrecipitationSum
+ RainSum
+ ShowersSum
+ SnowfallSum
+ PrecipitationHours
+ PrecipitationProbabilityMax
+ PrecipitationProbabilityMin
+ PrecipitationProbabilityMean
+ WorstWeatherCode
+ // TODO: Implemeny `Sunrise` and `Sunset` variables (they're problematic because they're not integers nor floats, but dates.)
+ // Sunrise
+ // Sunset
+ SunshineSuration
+ DaylightDuration
+ WindSpeed10mMax
+ WindGusts10mMax
+ WindDirection10mDominant
+}
+
+/// A list of all available daily variables.
+pub const all = [
+ MaximumTemperature2m, MinimumTemperature2m, ApparentTemperatureMax,
+ ApparentTemperatureMin, PrecipitationSum, RainSum, ShowersSum, SnowfallSum,
+ PrecipitationHours, PrecipitationProbabilityMax, PrecipitationProbabilityMin,
+ PrecipitationProbabilityMean, WorstWeatherCode, SunshineSuration,
+ DaylightDuration, WindSpeed10mMax, WindGusts10mMax, WindDirection10mDominant,
+]
diff --git a/src/sunny/api/forecast/data.gleam b/src/sunny/api/forecast/data.gleam
new file mode 100644
index 0000000..122b657
--- /dev/null
+++ b/src/sunny/api/forecast/data.gleam
@@ -0,0 +1,121 @@
+//// A module containing useful functions to handle API results.
+
+import birl
+import gleam/dict
+import gleam/list
+import gleam/result
+import sunny/errors
+import sunny/measurement
+
+/// Data over a time range.
+pub type TimeRangedData(data_type) {
+ TimeRangedData(
+ /// A list of `birl.Time`, with each index corresponding to the index of
+ /// the `data` argument.
+ ///
+ /// For example, `list.first(time)` is the time when the first measurement
+ /// was taken
+ time: List(birl.Time),
+ data: dict.Dict(data_type, List(measurement.Measurement)),
+ )
+}
+
+/// Data at a specific time.
+pub type CurrentData(data_type) {
+ CurrentData(
+ time: birl.Time,
+ data: dict.Dict(data_type, measurement.Measurement),
+ )
+}
+
+/// A measurement at a specific time.
+///
+/// Similar to `CurrentData` but with only one `Measurement`.
+pub type Data {
+ Data(time: birl.Time, data: measurement.Measurement)
+}
+
+/// Converts a `TimeRangedData` to a `CurrentData`, given a specific `time`.
+pub fn range_to_current(
+ from data: TimeRangedData(a),
+ at time: birl.Time,
+) -> Result(CurrentData(a), errors.SunnyError) {
+ case data.time {
+ [] ->
+ Error(
+ errors.DataError(errors.DataNotFoundError(
+ "Invalid time for provided data.",
+ )),
+ )
+ [head, ..tail] ->
+ case head == time {
+ False -> {
+ let new_data =
+ data.data
+ |> dict.map_values(fn(_, v) {
+ // Should be ok, because the `time` length is the same as `v`
+ // length.
+ let assert [_, ..new] = v
+ new
+ })
+ range_to_current(TimeRangedData(time: tail, data: new_data), time)
+ }
+ True ->
+ Ok(CurrentData(
+ time: time,
+ data: dict.fold(data.data, dict.new(), fn(d, k, v) {
+ dict.insert(d, k, {
+ let assert [head, ..] = v
+ head
+ })
+ }),
+ ))
+ }
+ }
+}
+
+/// Converts a `TimeRangedData` to a list of `Data` for a specific `InstantVariable`
+/// or `DailyVariable`.
+///
+/// Used in the `hourly_forecast` example.
+pub fn range_to_data_list(
+ from data: TimeRangedData(a),
+ get var: a,
+) -> Result(List(Data), errors.SunnyError) {
+ use l <- result.try(
+ get_range_var(data, var) |> result.map_error(fn(e) { errors.DataError(e) }),
+ )
+ do_range_to_data_list(data.time, l, [])
+ |> result.map_error(fn(e) { errors.SunnyInternalError(e) })
+}
+
+fn do_range_to_data_list(
+ time: List(birl.Time),
+ l: List(measurement.Measurement),
+ result: List(Data),
+) -> Result(List(Data), errors.InternalError) {
+ case time, l {
+ [], [] -> Ok(result)
+ [t, ..t_tail], [m, ..m_tail] ->
+ result
+ // This could, maybe, be optimized, by appending in the other way.
+ |> list.append([Data(t, m)])
+ |> do_range_to_data_list(t_tail, m_tail, _)
+ _, _ ->
+ // Should not happen because the two lists should have the same length.
+ Error(errors.InternalError(
+ "Please open an issue on Github if you encountered this error.",
+ ))
+ }
+}
+
+fn get_range_var(
+ from data: TimeRangedData(a),
+ get var: a,
+) -> Result(List(measurement.Measurement), errors.DataError) {
+ data.data
+ |> dict.get(var)
+ |> result.map_error(fn(_) {
+ errors.DataNotFoundError("Could not find variable in data.")
+ })
+}
diff --git a/src/sunny/api/forecast/instant.gleam b/src/sunny/api/forecast/instant.gleam
new file mode 100644
index 0000000..0eb93b6
--- /dev/null
+++ b/src/sunny/api/forecast/instant.gleam
@@ -0,0 +1,49 @@
+/// Variables that can be obtained at a specific time (for `hourly`, `minutely`
+/// and `current` fields)
+///
+/// See
+pub type InstantVariable {
+ Temperature2m
+ Temperature80m
+ Temperature120m
+ Temperature180m
+ RelativeHumidity2m
+ DewPoint2m
+ ApparentTemperature
+ PressureMsl
+ SurfacePressure
+ CloudCover
+ CloudCoverLow
+ CloudCoverMid
+ CloudCoverHigh
+ WindSpeed10m
+ WindSpeed80m
+ WindSpeed120m
+ WindSpeed180m
+ WindDirection10m
+ WindDirection80m
+ WindDirection120m
+ WindDirection180m
+ WindGusts10m
+ Precipitation
+ Snowfall
+ PrecipitationProbability
+ Rain
+ Showers
+ WeatherCode
+ SnowDepth
+ FreezingLevelHeight
+ Visibility
+ IsDay
+}
+
+/// A list of all available instant variables.
+pub const all = [
+ Temperature2m, Temperature80m, Temperature120m, Temperature180m,
+ RelativeHumidity2m, DewPoint2m, ApparentTemperature, PressureMsl,
+ SurfacePressure, CloudCover, CloudCoverLow, CloudCoverMid, CloudCoverHigh,
+ WindSpeed10m, WindSpeed80m, WindSpeed120m, WindSpeed180m, WindDirection10m,
+ WindDirection80m, WindDirection120m, WindDirection180m, WindGusts10m,
+ Precipitation, Snowfall, PrecipitationProbability, Rain, Showers, WeatherCode,
+ SnowDepth, FreezingLevelHeight, Visibility, IsDay,
+]
diff --git a/src/sunny/api/geocoding.gleam b/src/sunny/api/geocoding.gleam
index 95fd90e..16cc20e 100644
--- a/src/sunny/api/geocoding.gleam
+++ b/src/sunny/api/geocoding.gleam
@@ -1,8 +1,8 @@
-//// The module for interacting with the Geocoding API.
-//// Useful for getting the coordinates of a city to then get the weather
-//// forecast.
+//// The module for interacting with the Geocoding API. Useful for getting the
+//// coordinates of a city to then get the weather forecast.
+////
+//// ## Example
////
-//// ### Example
//// ```gleam
//// import sunny
//// import sunny/api/geocoding
@@ -13,10 +13,11 @@
//// let sunny = sunny.new()
////
//// let assert Ok(location) =
-//// geocoding.get_first_location(sunny, {
+//// sunny
+//// |> geocoding.get_first_location(
//// geocoding.params("marseille")
//// |> geocoding.set_language(geocoding.French)
-//// })
+//// )
////
//// io.println(
//// location.name
@@ -26,7 +27,7 @@
//// <> float.to_string(location.longitude),
//// )
//// }
-//// ```gleam
+//// ```
import gleam/dict
import gleam/dynamic.{dict, field, float, int, list, optional_field, string}
@@ -35,10 +36,10 @@ import gleam/json
import gleam/list
import gleam/option
import gleam/result
-import sunny/client
import sunny/errors
import sunny/internal/client.{type Client, Client} as _
import sunny/internal/utils
+import sunny/position
/// Enumeration of the available languages for the geocoding API.
/// Changing the language will impact the search results.
@@ -56,12 +57,9 @@ pub type Language {
/// Represents a location on good old earth. Can be obtained with the geocoding
/// API.
+///
+/// See
pub type Location {
- /// See https://open-meteo.com/en/docs/geocoding-api for more information
- /// on the fields.
- ///
- /// If you want to use specific coordinates, use the `Coordinates`
- /// constructor.
Location(
latitude: Float,
longitude: Float,
@@ -71,8 +69,8 @@ pub type Location {
feature_code: String,
country_code: String,
country_id: Int,
- population: Int,
- post_codes: List(String),
+ population: option.Option(Int),
+ post_codes: option.Option(List(String)),
admin1: option.Option(String),
admin2: option.Option(String),
admin3: option.Option(String),
@@ -84,6 +82,11 @@ pub type Location {
)
}
+/// Converts a location to a position.
+pub fn location_to_position(location: Location) -> position.Position {
+ position.Position(location.latitude, location.longitude)
+}
+
/// The different parameters needed to make a request to the geocoding API
pub type GeocodingParams {
GeocodingParams(name: String, count: Int, language: Language)
@@ -93,7 +96,7 @@ pub type GeocodingParams {
pub fn get_locations(
client: Client,
params: GeocodingParams,
-) -> Result(List(Location), errors.OMApiError) {
+) -> Result(List(Location), errors.SunnyError) {
make_request(client, params)
}
@@ -102,12 +105,17 @@ pub fn get_locations(
pub fn get_first_location(
client: Client,
params: GeocodingParams,
-) -> Result(Location, errors.OMApiError) {
+) -> Result(Location, errors.SunnyError) {
use locations <- result.try(get_locations(client, set_count(params, 1)))
case locations {
[head, ..] -> Ok(head)
// Shouldn't happen because an error would be returned by `get_locations`
- [] -> panic as "`get_locations` gave empty list instead of error."
+ [] ->
+ Error(
+ errors.SunnyInternalError(errors.InternalError(
+ "`get_locations` gave empty list instead of error.",
+ )),
+ )
}
}
@@ -122,12 +130,10 @@ pub fn params(name: String) -> GeocodingParams {
/// Creates a new GeocodingParams from the one specified, changing its count
/// field.
+///
+/// The count will be clamped between 1 and 100.
pub fn set_count(params: GeocodingParams, count: Int) -> GeocodingParams {
- case count {
- count if count > 100 || count < 1 ->
- panic as "Geocoding parameter count must be between 1 and 100."
- _ -> GeocodingParams(..params, count: count)
- }
+ GeocodingParams(..params, count: int.clamp(count, 1, 100))
}
/// Creates a new GeocodingParams from the one specified, changing its language
@@ -142,16 +148,22 @@ pub fn set_language(
fn make_request(
client: Client,
params: GeocodingParams,
-) -> Result(List(Location), errors.OMApiError) {
+) -> Result(List(Location), errors.SunnyError) {
+ let params = case params.count {
+ c if c > 100 || c < 1 -> set_count(params, c)
+ _ -> params
+ }
+
case
- utils.make_request(utils.get_final_url(
- client.get_base_url(client),
+ utils.get_final_url(
+ client.base_url,
"geocoding",
- client.is_commercial(client),
+ client.commercial,
"/search",
- client.get_api_key(client),
- geocoding_params_to_params_list(params),
- ))
+ client.key,
+ params |> geocoding_params_to_params_list,
+ )
+ |> utils.make_request
{
Ok(body) -> locations_from_json(body)
Error(err) -> Error(errors.HttpError(err))
@@ -166,7 +178,7 @@ fn geocoding_params_to_params_list(
utils.RequestParameter("count", int.to_string(params.count)),
utils.RequestParameter(
"language",
- language_to_country_code(params.language),
+ params.language |> language_to_country_code,
),
utils.RequestParameter("format", "json"),
]
@@ -192,7 +204,7 @@ type LocationList {
fn locations_from_json(
json_string: String,
-) -> Result(List(Location), errors.OMApiError) {
+) -> Result(List(Location), errors.SunnyError) {
let geocoding_decoder =
dynamic.decode1(
LocationList,
@@ -208,8 +220,8 @@ fn locations_from_json(
field("feature_code", of: string),
field("country_code", of: string),
field("country_id", of: int),
- field("population", of: int),
- field("postcodes", of: list(string)),
+ optional_field("population", of: int),
+ optional_field("postcodes", of: list(string)),
optional_field("admin1", of: string),
optional_field("admin2", of: string),
optional_field("admin3", of: string),
@@ -226,7 +238,12 @@ fn locations_from_json(
|> result.map(fn(locations_maybe) {
case locations_maybe {
LocationList(option.Some(locations)) -> Ok(locations)
- _ -> Error(errors.NoResults)
+ _ ->
+ Error(
+ errors.ApiError(errors.NoResultsError(
+ "Geocoding search gave no results",
+ )),
+ )
}
})
|> result.flatten
diff --git a/src/sunny/client.gleam b/src/sunny/client.gleam
deleted file mode 100644
index 78aeb4b..0000000
--- a/src/sunny/client.gleam
+++ /dev/null
@@ -1,23 +0,0 @@
-import sunny/internal/client.{type Client, Client}
-
-/// Takes a Client and returns a new one with a custom base url.
-pub fn set_base_url(client: Client, url: String) -> Client {
- Client(..client, base_url: url)
-}
-
-/// Whether the given Client is commercial or not.
-pub fn is_commercial(client: Client) -> Bool {
- client.commercial
-}
-
-/// Returns the base url of the given Client.
-pub fn get_base_url(client: Client) -> String {
- client.base_url
-}
-
-/// Gets the api_key of the given client.
-///
-/// If the client is not commercial, returns an empty string.
-pub fn get_api_key(client: Client) -> String {
- client.key
-}
diff --git a/src/sunny/errors.gleam b/src/sunny/errors.gleam
index 1b85bd6..64c6c48 100644
--- a/src/sunny/errors.gleam
+++ b/src/sunny/errors.gleam
@@ -1,9 +1,31 @@
+//// A module with the errors you could get using Sunny.
+
import efetch
import gleam/json
-/// A type describing any error that could occur while using this library
-pub type OMApiError {
- HttpError(efetch.HttpError)
- DecodeError(json.DecodeError)
- NoResults
+pub type SunnyError {
+ /// Something went wrong with the HTTP request.
+ HttpError(err: efetch.HttpError)
+ /// Something went wrong with decoding the json obtained with the request.
+ DecodeError(err: json.DecodeError)
+ /// An API-related error (e.g. wrong arguments)
+ ApiError(err: ApiError)
+ /// Something went wrong while handling data.
+ DataError(err: DataError)
+ /// Something went wrong internally. If you get this error, please create an
+ /// issue on Github.
+ SunnyInternalError(err: InternalError)
+}
+
+pub type ApiError {
+ NoResultsError(msg: String)
+ InvalidArgumentError(msg: String)
+}
+
+pub type DataError {
+ DataNotFoundError(msg: String)
+}
+
+pub type InternalError {
+ InternalError(msg: String)
}
diff --git a/src/sunny/internal/api/forecast.gleam b/src/sunny/internal/api/forecast.gleam
new file mode 100644
index 0000000..a9a5db1
--- /dev/null
+++ b/src/sunny/internal/api/forecast.gleam
@@ -0,0 +1,356 @@
+import birl
+import gleam/dict
+import gleam/dynamic.{dict, field, float, int, list, optional_field, string}
+import gleam/float
+import gleam/int
+import gleam/json
+import gleam/list
+import gleam/option
+import gleam/result
+import sunny/api/forecast/daily
+import sunny/api/forecast/data
+import sunny/api/forecast/instant
+import sunny/errors
+import sunny/internal/utils
+import sunny/measurement
+
+pub type RawForecastResult {
+ RawForecastResult(
+ latitude: Float,
+ longitude: Float,
+ elevation: Float,
+ utc_offset_seconds: Int,
+ timezone: String,
+ timezone_abbreviation: String,
+ hourly: option.Option(dict.Dict(String, List(String))),
+ hourly_units: option.Option(dict.Dict(String, String)),
+ daily: option.Option(dict.Dict(String, List(String))),
+ daily_units: option.Option(dict.Dict(String, String)),
+ minutely: option.Option(dict.Dict(String, List(String))),
+ minutely_units: option.Option(dict.Dict(String, String)),
+ current: option.Option(dict.Dict(String, String)),
+ current_units: option.Option(dict.Dict(String, String)),
+ )
+}
+
+pub fn raw_forecast_result_from_json(
+ json_string: String,
+) -> Result(RawForecastResult, errors.SunnyError) {
+ let any_of_string_or_int_or_float =
+ dynamic.any([
+ string,
+ fn(x) { result.map(float(x), fn(f) { float.to_string(f) }) },
+ fn(x) { result.map(int(x), fn(n) { int.to_string(n) }) },
+ ])
+
+ let forecast_decoder =
+ utils.decode14(
+ RawForecastResult,
+ field("latitude", float),
+ field("longitude", float),
+ field("elevation", float),
+ field("utc_offset_seconds", int),
+ field("timezone", string),
+ field("timezone_abbreviation", string),
+ optional_field(
+ "hourly",
+ dict(string, list(any_of_string_or_int_or_float)),
+ ),
+ optional_field("hourly_units", dict(string, string)),
+ optional_field("daily", dict(string, list(any_of_string_or_int_or_float))),
+ optional_field("daily_units", dict(string, string)),
+ optional_field(
+ "minutely_15",
+ dict(string, list(any_of_string_or_int_or_float)),
+ ),
+ optional_field("minutely_15_units", dict(string, string)),
+ optional_field("current", dict(string, any_of_string_or_int_or_float)),
+ optional_field("current_units", dict(string, string)),
+ )
+ json.decode(from: json_string, using: forecast_decoder)
+ |> result.map_error(fn(e) { errors.DecodeError(e) })
+}
+
+pub fn instant_to_string(var: instant.InstantVariable) -> String {
+ case var {
+ instant.Temperature2m -> "temperature_2m"
+ instant.Temperature80m -> "temperature_80m"
+ instant.Temperature120m -> "temperature_120m"
+ instant.Temperature180m -> "temperature_180m"
+ instant.RelativeHumidity2m -> "relative_humidity_2m"
+ instant.DewPoint2m -> "dew_point_2m"
+ instant.ApparentTemperature -> "apparent_temperature"
+ instant.PressureMsl -> "pressure_msl"
+ instant.SurfacePressure -> "surface_pressure"
+ instant.CloudCover -> "cloud_cover"
+ instant.CloudCoverLow -> "cloud_cover_low"
+ instant.CloudCoverMid -> "cloud_cover_mid"
+ instant.CloudCoverHigh -> "cloud_cover_high"
+ instant.WindSpeed10m -> "wind_speed_10m"
+ instant.WindSpeed80m -> "wind_speed_80m"
+ instant.WindSpeed120m -> "wind_speed_120m"
+ instant.WindSpeed180m -> "wind_speed_180m"
+ instant.WindDirection10m -> "wind_direction_10m"
+ instant.WindDirection80m -> "wind_direction_80m"
+ instant.WindDirection120m -> "wind_direction_120m"
+ instant.WindDirection180m -> "wind_direction_180m"
+ instant.WindGusts10m -> "wind_gusts_10m"
+ instant.Precipitation -> "precipitation"
+ instant.Snowfall -> "snowfall"
+ instant.PrecipitationProbability -> "precipitation_probability"
+ instant.Rain -> "rain"
+ instant.Showers -> "showers"
+ instant.WeatherCode -> "weather_code"
+ instant.SnowDepth -> "snow_depth"
+ instant.FreezingLevelHeight -> "freezing_level_height"
+ instant.Visibility -> "visibility"
+ instant.IsDay -> "is_day"
+ }
+}
+
+pub fn string_to_instant(
+ s: String,
+) -> Result(instant.InstantVariable, errors.InternalError) {
+ case s {
+ "temperature_2m" -> instant.Temperature2m |> Ok()
+ "temperature_80m" -> instant.Temperature80m |> Ok()
+ "temperature_120m" -> instant.Temperature120m |> Ok()
+ "temperature_180m" -> instant.Temperature180m |> Ok()
+ "relative_humidity_2m" -> instant.RelativeHumidity2m |> Ok()
+ "dew_point_2m" -> instant.DewPoint2m |> Ok()
+ "apparent_temperature" -> instant.ApparentTemperature |> Ok()
+ "pressure_msl" -> instant.PressureMsl |> Ok()
+ "surface_pressure" -> instant.SurfacePressure |> Ok()
+ "cloud_cover" -> instant.CloudCover |> Ok()
+ "cloud_cover_low" -> instant.CloudCoverLow |> Ok()
+ "cloud_cover_mid" -> instant.CloudCoverMid |> Ok()
+ "cloud_cover_high" -> instant.CloudCoverHigh |> Ok()
+ "wind_speed_10m" -> instant.WindSpeed10m |> Ok()
+ "wind_speed_80m" -> instant.WindSpeed80m |> Ok()
+ "wind_speed_120m" -> instant.WindSpeed120m |> Ok()
+ "wind_speed_180m" -> instant.WindSpeed180m |> Ok()
+ "wind_direction_10m" -> instant.WindDirection10m |> Ok()
+ "wind_direction_80m" -> instant.WindDirection80m |> Ok()
+ "wind_direction_120m" -> instant.WindDirection120m |> Ok()
+ "wind_direction_180m" -> instant.WindDirection180m |> Ok()
+ "wind_gusts_10m" -> instant.WindGusts10m |> Ok()
+ "precipitation" -> instant.Precipitation |> Ok()
+ "snowfall" -> instant.Snowfall |> Ok()
+ "precipitation_probability" -> instant.PrecipitationProbability |> Ok()
+ "rain" -> instant.Rain |> Ok()
+ "showers" -> instant.Showers |> Ok()
+ "weather_code" -> instant.WeatherCode |> Ok()
+ "snow_depth" -> instant.SnowDepth |> Ok()
+ "freezing_level_height" -> instant.FreezingLevelHeight |> Ok()
+ "visibility" -> instant.Visibility |> Ok()
+ "is_day" -> instant.IsDay |> Ok()
+ _ ->
+ Error(errors.InternalError(
+ "Unexpected error : string_to_instant(\"" <> s <> "\")",
+ ))
+ }
+}
+
+pub fn daily_to_string(d: daily.DailyVariable) -> String {
+ case d {
+ daily.MaximumTemperature2m -> "temperature_2m_max"
+ daily.MinimumTemperature2m -> "temperature_2m_min"
+ daily.ApparentTemperatureMax -> "apparent_temperature_max"
+ daily.ApparentTemperatureMin -> "apparent_temperature_min"
+ daily.PrecipitationSum -> "precipitation_sum"
+ daily.RainSum -> "rain_sum"
+ daily.ShowersSum -> "showers_sum"
+ daily.SnowfallSum -> "snowfall_sum"
+ daily.PrecipitationHours -> "precipitation_hours"
+ daily.PrecipitationProbabilityMax -> "precipitation_probability_max"
+ daily.PrecipitationProbabilityMin -> "precipitation_probability_min"
+ daily.PrecipitationProbabilityMean -> "precipitation_probability_mean"
+ daily.WorstWeatherCode -> "weather_code"
+ //daily.Sunrise -> "sunrise"
+ //daily.Sunset -> "sunset"
+ daily.SunshineSuration -> "sunshine_duration"
+ daily.DaylightDuration -> "daylight_duration"
+ daily.WindSpeed10mMax -> "wind_speed_10m_max"
+ daily.WindGusts10mMax -> "wind_gusts_10m_max"
+ daily.WindDirection10mDominant -> "wind_direction_10m_dominant"
+ }
+}
+
+pub fn string_to_daily(
+ s: String,
+) -> Result(daily.DailyVariable, errors.InternalError) {
+ case s {
+ "temperature_2m_max" -> daily.MaximumTemperature2m |> Ok()
+ "temperature_2m_min" -> daily.MinimumTemperature2m |> Ok()
+ "apparent_temperature_max" -> daily.ApparentTemperatureMax |> Ok()
+ "apparent_temperature_min" -> daily.ApparentTemperatureMin |> Ok()
+ "precipitation_sum" -> daily.PrecipitationSum |> Ok()
+ "rain_sum" -> daily.RainSum |> Ok()
+ "showers_sum" -> daily.ShowersSum |> Ok()
+ "snowfall_sum" -> daily.SnowfallSum |> Ok()
+ "precipitation_hours" -> daily.PrecipitationHours |> Ok()
+ "precipitation_probability_max" -> daily.PrecipitationProbabilityMax |> Ok()
+ "precipitation_probability_min" -> daily.PrecipitationProbabilityMin |> Ok()
+ "precipitation_probability_mean" ->
+ daily.PrecipitationProbabilityMean |> Ok()
+ "weather_code" -> daily.WorstWeatherCode |> Ok()
+ //"sunrise" -> daily.Sunrise |> Ok()
+ //"sunset" -> daily.Sunset |> Ok()
+ "sunshine_duration" -> daily.SunshineSuration |> Ok()
+ "daylight_duration" -> daily.DaylightDuration |> Ok()
+ "wind_speed_10m_max" -> daily.WindSpeed10mMax |> Ok()
+ "wind_gusts_10m_max" -> daily.WindGusts10mMax |> Ok()
+ "wind_direction_10m_dominant" -> daily.WindDirection10mDominant |> Ok()
+ _ ->
+ Error(errors.InternalError(
+ "Unexpected error : string_to_instant(\"" <> s <> "\")",
+ ))
+ }
+}
+
+pub fn refine_raw_time_ranged_data(
+ data_opt: option.Option(dict.Dict(String, List(String))),
+ data_units_opt: option.Option(dict.Dict(String, String)),
+ from_string_fn: fn(String) -> Result(a, errors.InternalError),
+) -> Result(data.TimeRangedData(a), errors.InternalError) {
+ case data_opt, data_units_opt {
+ option.Some(data), option.Some(data_units) -> {
+ use time <- result.try(
+ dict.get(data, "time")
+ // Convert the time strings to `birl.Time`
+ |> result.map(fn(l) { list.map(l, birl.from_naive) |> result.all })
+ |> result.flatten
+ |> result.map_error(fn(_) {
+ errors.InternalError(
+ "Unexpected error : `time` field should be present.",
+ )
+ }),
+ )
+ let data =
+ data
+ |> dict.drop(["time"])
+ // Check that :
+ // - all keys correspond to an `instant.InstantVariable`
+ // - all values are `Float`
+ // - all keys have a corresponding unit
+ // Every condition should be ok if everything works as intended
+ case
+ list.all(dict.to_list(data), fn(tuple) {
+ let #(k, v) = tuple
+
+ case from_string_fn(k) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ && list.all(v, fn(value) {
+ case utils.parse_float_or_int(value) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ })
+ && case dict.get(data_units, k) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ })
+ {
+ True ->
+ data
+ |> dict.fold(
+ data.TimeRangedData(time: time, data: dict.new()),
+ fn(d, k, v) {
+ // It's Ok because it has been checked earlier
+ let assert Ok(var) = from_string_fn(k)
+ let assert Ok(unit) = dict.get(data_units, k)
+
+ let data_list =
+ v
+ |> list.map(fn(s) {
+ let res = utils.parse_float_or_int(s)
+ case res {
+ Ok(f) -> measurement.Measurement(f, unit)
+ // Will not happen because checked earlier
+ Error(_) -> panic as "This should not happen"
+ }
+ })
+ data.TimeRangedData(
+ ..d,
+ data: d.data |> dict.insert(var, data_list),
+ )
+ },
+ )
+ |> Ok
+ False ->
+ Error(errors.InternalError(
+ "Unexpected error : data should contain keys corresponding to `a` and float values",
+ ))
+ }
+ }
+ // Return empty data is not present.
+ _, _ -> Ok(data.TimeRangedData([], dict.new()))
+ }
+}
+
+pub fn refine_raw_current_data(
+ data_opt: option.Option(dict.Dict(String, String)),
+ data_units_opt: option.Option(dict.Dict(String, String)),
+ from_string_fn: fn(String) -> Result(a, errors.InternalError),
+) -> Result(option.Option(data.CurrentData(a)), errors.InternalError) {
+ case data_opt, data_units_opt {
+ option.Some(data), option.Some(data_units) -> {
+ use time <- result.try(
+ dict.get(data, "time")
+ |> result.map(birl.from_naive)
+ |> result.flatten
+ |> result.map_error(fn(_) {
+ errors.InternalError(
+ "Unexpected error : `time` field should be present.",
+ )
+ }),
+ )
+ let data = data |> dict.drop(["time", "interval"])
+ case
+ list.all(dict.to_list(data), fn(tuple) {
+ let #(k, v) = tuple
+
+ case from_string_fn(k) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ && case utils.parse_float_or_int(v) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ && case dict.get(data_units, k) {
+ Ok(_) -> True
+ Error(_) -> False
+ }
+ })
+ {
+ True ->
+ data
+ |> dict.fold(
+ data.CurrentData(time: time, data: dict.new()),
+ fn(d, k, v) {
+ // It's Ok because it has been checked earlier
+ let assert Ok(var) = from_string_fn(k)
+ let assert Ok(unit) = dict.get(data_units, k)
+ let assert Ok(value) = utils.parse_float_or_int(v)
+ data.CurrentData(
+ ..d,
+ data: d.data
+ |> dict.insert(var, measurement.Measurement(value, unit)),
+ )
+ },
+ )
+ |> option.Some
+ |> Ok
+ False ->
+ Error(errors.InternalError(
+ "Unexpected error : data should contain keys corresponding to `a` and float values",
+ ))
+ }
+ }
+ _, _ -> Ok(option.None)
+ }
+}
diff --git a/src/sunny/internal/utils.gleam b/src/sunny/internal/utils.gleam
index 84ad34b..9c282a7 100644
--- a/src/sunny/internal/utils.gleam
+++ b/src/sunny/internal/utils.gleam
@@ -1,8 +1,11 @@
import efetch
import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic}
+import gleam/float
import gleam/http/request
+import gleam/int
import gleam/list
import gleam/result.{try}
+import gleam/string
pub fn make_request(url: String) -> Result(String, efetch.HttpError) {
let assert Ok(request) = request.to(url)
@@ -41,11 +44,28 @@ pub fn get_final_url(
<> "?"
<> list.fold(params, "", fn(a, b) { a <> b.key <> "=" <> b.value <> "&" })
<> case commercial {
- True -> "apikey" <> "=" <> apikey
+ True -> "&apikey" <> "=" <> apikey
False -> ""
}
}
+pub fn param_list_to_string(
+ params: List(a),
+ to_string_fn: fn(a) -> String,
+) -> String {
+ params
+ |> list.fold("", fn(a, b) { a <> to_string_fn(b) <> "," })
+ |> string.drop_right(1)
+}
+
+pub fn parse_float_or_int(s: String) -> Result(Float, Nil) {
+ let res = int.parse(s)
+ case res {
+ Ok(i) -> Ok(int.to_float(i))
+ Error(_) -> float.parse(s)
+ }
+}
+
// Taken from https://github.com/gleam-lang/stdlib/blob/v0.39.0/src/gleam/dynamic.gleam#L1530
fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) {
case result {
@@ -55,6 +75,80 @@ fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) {
}
// Adapted from gleam/dynamic source code
+
+pub fn decode14(
+ constructor: fn(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14) ->
+ t,
+ t1: Decoder(t1),
+ t2: Decoder(t2),
+ t3: Decoder(t3),
+ t4: Decoder(t4),
+ t5: Decoder(t5),
+ t6: Decoder(t6),
+ t7: Decoder(t7),
+ t8: Decoder(t8),
+ t9: Decoder(t9),
+ t10: Decoder(t10),
+ t11: Decoder(t11),
+ t12: Decoder(t12),
+ t13: Decoder(t13),
+ t14: Decoder(t14),
+) -> Decoder(t) {
+ fn(x: Dynamic) {
+ case
+ t1(x),
+ t2(x),
+ t3(x),
+ t4(x),
+ t5(x),
+ t6(x),
+ t7(x),
+ t8(x),
+ t9(x),
+ t10(x),
+ t11(x),
+ t12(x),
+ t13(x),
+ t14(x)
+ {
+ Ok(a),
+ Ok(b),
+ Ok(c),
+ Ok(d),
+ Ok(e),
+ Ok(f),
+ Ok(g),
+ Ok(h),
+ Ok(i),
+ Ok(j),
+ Ok(k),
+ Ok(l),
+ Ok(m),
+ Ok(n)
+ -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k, l, m, n))
+ a, b, c, d, e, f, g, h, i, j, k, l, m, n ->
+ Error(
+ list.concat([
+ all_errors(a),
+ all_errors(b),
+ all_errors(c),
+ all_errors(d),
+ all_errors(e),
+ all_errors(f),
+ all_errors(g),
+ all_errors(h),
+ all_errors(i),
+ all_errors(j),
+ all_errors(k),
+ all_errors(l),
+ all_errors(m),
+ all_errors(n),
+ ]),
+ )
+ }
+ }
+}
+
pub fn decode18(
constructor: fn(
t1,
diff --git a/src/sunny/measurement.gleam b/src/sunny/measurement.gleam
new file mode 100644
index 0000000..f48db53
--- /dev/null
+++ b/src/sunny/measurement.gleam
@@ -0,0 +1,15 @@
+import gleam/float
+
+/// Represents a physical measurement with a unit
+pub type Measurement {
+ Measurement(value: Float, unit: String)
+}
+
+/// Converts a measurement to a string, displaying its unit.
+pub fn to_string(m: Measurement) -> String {
+ float.to_string(m.value)
+ <> case m.unit {
+ "" -> ""
+ _ -> " " <> m.unit
+ }
+}
diff --git a/src/sunny/position.gleam b/src/sunny/position.gleam
new file mode 100644
index 0000000..aa8503f
--- /dev/null
+++ b/src/sunny/position.gleam
@@ -0,0 +1,4 @@
+/// Describes a position on earth using latitude and longitude.
+pub type Position {
+ Position(latitude: Float, longitude: Float)
+}
diff --git a/src/sunny/wmo_code.gleam b/src/sunny/wmo_code.gleam
new file mode 100644
index 0000000..dc852ca
--- /dev/null
+++ b/src/sunny/wmo_code.gleam
@@ -0,0 +1,49 @@
+/// Translates the `Int` WMOCode to a human-readable
+///`String`.
+///
+/// See https://open-meteo.com/en/docs (at the bottom of the page).
+pub fn to_string(code: Int) {
+ case code {
+ 0 -> "Clear sky"
+
+ 1 -> "Mainly clear"
+ 2 -> "Partly cloudy"
+ 3 -> "Overcast"
+
+ 45 -> "Fog"
+ 48 -> "Depositing rime fog"
+
+ 51 -> "Light drizzle"
+ 53 -> "Moderate drizzle"
+ 55 -> "Dense drizzle"
+
+ 56 -> "Light freezing drizzle"
+ 57 -> "Dense freezing drizzle"
+
+ 61 -> "Slight rain"
+ 63 -> "Moderate rain"
+ 65 -> "Heavy rain"
+
+ 66 -> "Light freezing rain"
+ 67 -> "Heavy freezing rain"
+
+ 71 -> "Slight snow"
+ 73 -> "Moderate snow"
+ 75 -> "Heavy snow"
+
+ 77 -> "Snow grains"
+
+ 80 -> "Slight rain shower"
+ 81 -> "Moderate rain shower"
+ 82 -> "Violent rain shower"
+
+ 85 -> "Slight snow shower"
+ 86 -> "Heavy snow shower"
+
+ 95 -> "Slight or moderate thunderstorm"
+ 96 -> "Thunderstorm with slight hail"
+ 99 -> "Thunderstorm with heavy hail"
+
+ _ -> "Unknown WMO code"
+ }
+}
diff --git a/test/examples/city_info_test.gleam b/test/examples/city_info_test.gleam
new file mode 100644
index 0000000..7dfb634
--- /dev/null
+++ b/test/examples/city_info_test.gleam
@@ -0,0 +1,31 @@
+import gleam/int
+import gleam/io
+import gleam/option
+import sunny
+import sunny/api/geocoding
+
+pub fn city_info_test() {
+ // Use `new_commercial("")` if you have a commercial Open-meteo
+ // API access.
+ let sunny = sunny.new()
+
+ let assert Ok(location) =
+ sunny
+ // If the location your searching for isn't the first result, try
+ // `geocoding.get_locations`
+ |> geocoding.get_first_location(
+ geocoding.params("marseille")
+ // Changing the language can impact the search results.
+ |> geocoding.set_language(geocoding.French),
+ )
+
+ let assert option.Some(population) = location.population
+
+ io.println(
+ location.name
+ <> ", "
+ <> location.country_code
+ <> " has a population of : "
+ <> int.to_string(population),
+ )
+}
diff --git a/test/examples/current_temperature_test.gleam b/test/examples/current_temperature_test.gleam
new file mode 100644
index 0000000..211a3c3
--- /dev/null
+++ b/test/examples/current_temperature_test.gleam
@@ -0,0 +1,42 @@
+import gleam/dict
+import gleam/io
+import gleam/option
+import sunny
+import sunny/api/forecast
+import sunny/api/forecast/data
+import sunny/api/forecast/instant
+import sunny/measurement
+import sunny/position
+
+pub fn current_temperature_test() {
+ // Use `new_commercial("")` if you have a commercial Open-meteo
+ // API access.
+ let sunny = sunny.new()
+
+ // You can get the coordinates of a place using the Geocoding API. See
+ // `sunny/api/geocoding`, or the `city_info` example.
+ //
+ // Once you have a `Location`, use `geocoding.location_to_position()` to
+ // convert it to a position.
+ let position = position.Position(43.0, 5.0)
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(position)
+ // All available variables are listed in the `sunny/api/forecast/instant` module.
+ // Daily variables are in `sunny/api/forecast/daily`.
+ |> forecast.set_current([instant.Temperature2m]),
+ )
+
+ let assert option.Some(data.CurrentData(data: data, ..)) =
+ forecast_result.current
+
+ let assert Ok(current_temperature) =
+ data
+ |> dict.get(instant.Temperature2m)
+
+ io.println(
+ "Current temperature : " <> measurement.to_string(current_temperature),
+ )
+}
diff --git a/test/examples/hourly_forecast.gleam b/test/examples/hourly_forecast.gleam
new file mode 100644
index 0000000..0df7942
--- /dev/null
+++ b/test/examples/hourly_forecast.gleam
@@ -0,0 +1,49 @@
+import birl
+import gleam/float
+import gleam/io
+import gleam/list
+import sunny
+import sunny/api/forecast
+import sunny/api/forecast/data
+import sunny/api/forecast/instant
+import sunny/position
+import sunny/wmo_code
+
+pub fn hourly_forecast_test() {
+ // Use `new_commercial("")` if you have a commercial Open-meteo
+ // API access.
+ let sunny = sunny.new()
+
+ // You can get the coordinates of a place using the Geocoding API. See
+ // `sunny/api/geocoding`, or the `city_info` example.
+ //
+ // Once you have a `Location`, use `geocoding.location_to_position()` to
+ // convert it to a position.
+ let position = position.Position(43.0, 5.0)
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(position)
+ // All available variables are listed in the `sunny/api/forecast/instant`
+ // module.
+ // Daily variables are in `sunny/api/forecast/daily`.
+ |> forecast.set_hourly([instant.WeatherCode])
+ |> forecast.set_forecast_days(1),
+ )
+
+ let assert Ok(hourly_weather) =
+ forecast_result.hourly
+ |> data.range_to_data_list(instant.WeatherCode)
+
+ hourly_weather
+ |> list.each(fn(timed_data) {
+ io.debug(
+ birl.to_time_string(timed_data.time)
+ <> " : "
+ // `wmo_code.to_string` translates the `Int` WMOCode to a human-readable
+ // `String`.
+ <> wmo_code.to_string(float.round(timed_data.data.value)),
+ )
+ })
+}
diff --git a/test/forecast_test.gleam b/test/forecast_test.gleam
new file mode 100644
index 0000000..44dbd4b
--- /dev/null
+++ b/test/forecast_test.gleam
@@ -0,0 +1,126 @@
+import glacier/should
+import gleam/dict
+import gleam/list
+import gleam/option
+import sunny
+import sunny/api/forecast
+import sunny/api/forecast/daily
+import sunny/api/forecast/instant
+import sunny/position
+
+const coords = position.Position(43.0, 5.0)
+
+pub fn hourly_all_test() {
+ let sunny = sunny.new()
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(coords)
+ |> forecast.set_all_hourly([]),
+ )
+
+ use var <- list.each(instant.all)
+ let l =
+ forecast_result.hourly.data
+ |> dict.get(var)
+ |> should.be_ok
+
+ list.length(l)
+ |> should.equal(list.length(forecast_result.hourly.time))
+}
+
+pub fn daily_all_test() {
+ let sunny = sunny.new()
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(coords)
+ |> forecast.set_all_daily([]),
+ )
+
+ use var <- list.each(daily.all)
+ let l =
+ forecast_result.daily.data
+ |> dict.get(var)
+ |> should.be_ok
+
+ list.length(l)
+ |> should.equal(list.length(forecast_result.daily.time))
+}
+
+pub fn minutely_all_test() {
+ let sunny = sunny.new()
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(coords) |> forecast.set_all_minutely([]),
+ )
+
+ use var <- list.each(instant.all)
+ let l =
+ forecast_result.minutely.data
+ |> dict.get(var)
+ |> should.be_ok
+
+ list.length(l)
+ |> should.equal(list.length(forecast_result.minutely.time))
+}
+
+pub fn current_all_test() {
+ let sunny = sunny.new()
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(coords) |> forecast.set_all_current([]),
+ )
+
+ let assert option.Some(current) = forecast_result.current
+
+ use var <- list.each(instant.all)
+ current.data
+ |> dict.get(var)
+ |> should.be_ok
+}
+
+pub fn none_test() {
+ let sunny = sunny.new()
+
+ let assert Ok(forecast_result) =
+ sunny
+ |> forecast.get_forecast(forecast.params(coords))
+
+ forecast_result.hourly.time
+ |> list.is_empty
+ |> should.be_true
+
+ forecast_result.daily.time
+ |> list.is_empty
+ |> should.be_true
+
+ forecast_result.minutely.time
+ |> list.is_empty
+ |> should.be_true
+
+ forecast_result.current
+ |> should.be_none
+
+ {
+ use var <- list.each(instant.all)
+ forecast_result.hourly.data
+ |> dict.get(var)
+ |> should.be_error
+
+ forecast_result.minutely.data
+ |> dict.get(var)
+ |> should.be_error
+ }
+
+ use var <- list.each(daily.all)
+ forecast_result.daily.data
+ |> dict.get(var)
+ |> should.be_error
+}
diff --git a/test/geocoding_test.gleam b/test/geocoding_test.gleam
new file mode 100644
index 0000000..9b386a4
--- /dev/null
+++ b/test/geocoding_test.gleam
@@ -0,0 +1,53 @@
+import glacier/should
+import gleam/list
+import gleam/result
+import sunny
+import sunny/api/geocoding
+import sunny/errors
+
+pub fn ok_result_test() {
+ let sunny = sunny.new()
+
+ let ok_result =
+ sunny |> geocoding.get_locations(geocoding.params("marseille"))
+ ok_result |> should.be_ok
+}
+
+pub fn no_result_test() {
+ let sunny = sunny.new()
+
+ let err_result = sunny |> geocoding.get_locations(geocoding.params("a"))
+
+ err_result |> should.be_error
+
+ use err <- result.map_error(err_result)
+ err
+ |> should.equal(
+ errors.ApiError(errors.NoResultsError("Geocoding search gave no results")),
+ )
+}
+
+pub fn commercial_test() {
+ // Any API key should work, because the API still returns results even if it
+ // is incorrect.
+ let sunny = sunny.new_commercial("api_key")
+
+ let ok_result =
+ sunny |> geocoding.get_locations(geocoding.params("marseille"))
+ ok_result |> should.be_ok
+}
+
+pub fn first_location_test() {
+ let sunny = sunny.new()
+
+ let params = geocoding.params("marseille")
+
+ let assert Ok(locations) = sunny |> geocoding.get_locations(params)
+
+ let assert Ok(first_location) = sunny |> geocoding.get_first_location(params)
+
+ use first_location2 <- result.map(list.first(locations))
+ first_location
+ |> should.equal(first_location2)
+ |> Ok
+}
diff --git a/test/sunny_test.gleam b/test/sunny_test.gleam
new file mode 100644
index 0000000..b904113
--- /dev/null
+++ b/test/sunny_test.gleam
@@ -0,0 +1,41 @@
+import glacier
+import glacier/should
+import gleam/dict
+import sunny
+import sunny/api/forecast
+import sunny/api/forecast/instant
+import sunny/api/geocoding
+
+pub fn main() {
+ glacier.main()
+}
+
+pub fn geocoding_and_forecast_test() {
+ let sunny = sunny.new()
+
+ let position =
+ sunny
+ |> geocoding.get_first_location(geocoding.params("marseille"))
+ |> should.be_ok
+ |> geocoding.location_to_position
+
+ let forecast_result =
+ sunny
+ |> forecast.get_forecast(
+ forecast.params(position)
+ |> forecast.set_current([instant.Temperature2m]),
+ )
+ |> should.be_ok
+
+ let current =
+ forecast_result.current
+ |> should.be_some
+
+ current.data
+ |> dict.get(instant.Temperature2m)
+ |> should.be_ok
+
+ current.data
+ |> dict.get(instant.ApparentTemperature)
+ |> should.be_error
+}