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(¶ms)
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}