Skip to main content

noaa_tides/
lib.rs

1//! noaa-tides
2//!
3//! Library to fetch NOAA tide and currents data from their [CO-OPS API](https://api.tidesandcurrents.noaa.gov/api/prod/).
4//!
5//! The CO-OPS API is a single endpoint with multiple products that can be requested with different combinations of
6//! query parameters. This library was built to provide a type-safe interface for building requests and deserializing responses into
7//! dedicated structs. This library currently supports the "predictions" product, which includes predicted tide heights for
8//! specified stations and date ranges.
9//!
10//! Contributions to support additional products are welcome!
11//!
12//! No API keys are required since the NOAA CO-OPS API does not require authentication, please be mindful with usage.
13//!
14//! # Example
15//!
16//! Below is an example to fetch high/low tide predictions for the San Francisco Golden Gate station for January 2026
17//! ```no_run
18//! use noaa_tides::{params, NoaaTideClient, PredictionsRequest};
19//!
20//! use chrono::NaiveDate;
21//!
22//! #[tokio::main]
23//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//!     let client = NoaaTideClient::new();
25//!
26//!     let request = PredictionsRequest {
27//!         station: "9414290".into(),
28//!         date_range: params::DateRange {
29//!             begin_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
30//!             end_date: NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
31//!         },
32//!         datum: params::Datum::MLLW,
33//!         time_zone: params::Timezone::LST_LDT,
34//!         interval: params::Interval::HighLow,
35//!         units: params::Units::English,
36//!     };
37//!
38//!     let data = client.fetch_predictions(&request).await?;
39//!     println!("High/low tide predictions:");
40//!     for p in data.predictions.iter() {
41//!         println!(
42//!             "{} - {:?} tide height: {}",
43//!             p.datetime,
44//!             p.tide_type.unwrap(),
45//!             p.height
46//!         );
47//!     }
48//!     Ok(())
49//! }
50
51//!
52
53/// Module with parameter types for building requests
54pub mod params;
55/// Module with product request and response types
56pub mod products;
57
58pub use crate::products::predictions::{PredictionsRequest, PredictionsResponse};
59
60use reqwest::Client;
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63
64const BASE_URL: &str = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter";
65
66/// Client to get data from the NOAA Tides and Currents API
67pub struct NoaaTideClient {
68    http: Client,
69    base_url: String,
70}
71
72impl NoaaTideClient {
73    pub fn new() -> Self {
74        Self {
75            http: Client::new(),
76            base_url: BASE_URL.to_string(),
77        }
78    }
79
80    /// Fetch tide predictions for a given request
81    pub async fn fetch_predictions(
82        &self,
83        params: &PredictionsRequest,
84    ) -> Result<PredictionsResponse, NoaaTideError> {
85        self.fetch_product("predictions", params).await
86    }
87
88    async fn fetch_product<P, R>(&self, product_name: &str, params: &P) -> Result<R, NoaaTideError>
89    where
90        P: Serialize,
91        R: serde::de::DeserializeOwned,
92    {
93        let response = self
94            .http
95            .get(&self.base_url)
96            .query(&params)
97            .query(&[("product", product_name), ("format", "json")])
98            .send()
99            .await?
100            .json::<NoaaResponse<R>>()
101            .await?;
102        match response {
103            NoaaResponse::Success(data) => Ok(data),
104            NoaaResponse::Failure(wrapper) => Err(NoaaTideError::ApiError(wrapper.error.message)),
105        }
106    }
107}
108
109impl Default for NoaaTideClient {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(untagged)]
117enum NoaaResponse<T> {
118    Success(T),
119    Failure(ErrorWrapper),
120}
121
122/// Represents an error with its message returned by the NOAA API
123#[derive(Debug, Deserialize)]
124struct ApiError {
125    message: String,
126}
127
128/// Wrapper for NOAA API error responses
129#[derive(Debug, Deserialize)]
130struct ErrorWrapper {
131    error: ApiError,
132}
133
134/// Possible errors when fetching data from the NOAA API
135#[derive(Error, Debug)]
136pub enum NoaaTideError {
137    #[error("Network error: {0}")]
138    HttpError(#[from] reqwest::Error),
139
140    #[error("NOAA API returned an error: {0}")]
141    ApiError(String),
142
143    #[error("Unknown error occurred")]
144    Unknown,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[derive(Debug, Serialize)]
152    struct MockProductRequest {
153        station: String,
154    }
155
156    #[derive(Debug, Deserialize)]
157    struct MockProductResponse {
158        value: i32,
159    }
160
161    #[tokio::test]
162    async fn verify_query_parameters() {
163        let mut server = mockito::Server::new_async().await;
164        let mock = server
165            .mock("GET", "/")
166            .match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
167                "station".into(),
168                "1234567".into(),
169            )]))
170            .with_status(200)
171            .with_header("content-type", "application/json")
172            .with_body(r#"{"value": 10}"#)
173            .create_async()
174            .await;
175
176        let client = NoaaTideClient {
177            http: Client::new(),
178            base_url: server.url(),
179        };
180
181        let request = MockProductRequest {
182            station: "1234567".to_string(),
183        };
184
185        let result: Result<MockProductResponse, NoaaTideError> =
186            client.fetch_product("some_product", &request).await;
187        assert!(result.is_ok());
188        mock.assert_async().await;
189        assert_eq!(result.unwrap().value, 10);
190    }
191}