1mod account;
2mod exit;
3mod lightning;
4mod prices;
5mod relay;
6mod stripe;
7mod tunnel;
8
9pub use account::*;
10pub use exit::*;
11pub use lightning::*;
12pub use prices::*;
13pub use relay::*;
14use std::any::Any;
15pub use stripe::*;
16pub use tunnel::*;
17
18use serde::{de::DeserializeOwned, Deserialize, Serialize};
19use thiserror::Error;
20use url::Url;
21
22use crate::types::AuthToken;
23use crate::ClientError;
24
25pub trait Cmd: Serialize + DeserializeOwned + std::fmt::Debug {
26 type Output: Serialize + DeserializeOwned + 'static + std::fmt::Debug;
27 const METHOD: http::Method;
28 const PATH: &'static str;
29
30 fn to_request(&self, base_url: impl AsRef<str>, auth_token: &AuthToken) -> anyhow::Result<http::Request<String>> {
31 let url = Url::parse(base_url.as_ref())?.join(Self::PATH)?;
32 let mut request = http::Request::builder()
33 .method(Self::METHOD)
34 .uri(url.as_str())
35 .header(http::header::AUTHORIZATION, format!("Bearer {}", auth_token.as_str()))
36 .header(http::header::CONTENT_TYPE, "application/json")
37 .body(String::new())?;
38 if Self::METHOD != http::Method::GET {
39 *request.body_mut() = serde_json::to_string(self)?;
40 }
41 Ok(request)
42 }
43}
44
45#[derive(Clone, Debug, Error)]
46#[error("{}", self.body.msg)]
47pub struct ApiError {
48 pub status: http::StatusCode,
49 pub body: ApiErrorBody,
50}
51
52#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
53pub struct ApiErrorBody {
54 pub error: ApiErrorKind,
55 pub msg: String,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub detail: Option<String>,
60}
61
62#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
63pub enum ApiErrorKind {
64 AccountExpired {},
65 BadRequest {},
66 InternalError {},
67 MissingOrInvalidAuthToken {},
68 NoApiRoute {},
69 NoMatchingExit {},
70 RateLimitExceeded {},
71 SignupLimitExceeded {},
72 TunnelLimitExceeded {},
73
74 #[serde(untagged)]
75 Unknown(serde_json::Value),
76}
77
78#[derive(Error, Debug)]
79#[error("Unexpected API response: {source}")]
80pub struct ProtocolError {
81 pub status: http::StatusCode,
82 pub raw: String,
83 pub source: anyhow::Error,
84}
85
86pub async fn parse_response<T: 'static + DeserializeOwned>(res: reqwest::Response) -> Result<T, ClientError> {
87 let is_json = res
88 .headers()
89 .get(http::header::CONTENT_TYPE)
90 .is_some_and(|h| h.as_bytes() == b"application/json");
91 if !is_json {
92 let status = res.status();
93 return match res.text().await {
94 Ok(raw) => Err(ClientError::ProtocolError(ProtocolError {
95 status,
96 raw,
97 source: anyhow::anyhow!("Non-JSON {status} response"),
98 })),
99 Err(err) => Err(ClientError::ProtocolError(ProtocolError {
100 status,
101 raw: String::new(),
102 source: err.into(),
103 })),
104 };
105 }
106
107 let status = res.status();
108 if !status.is_success() {
109 return Err(ClientError::ApiError(ApiError {
110 status,
111 body: res.json().await.map_err(|err| {
112 ClientError::ProtocolError(ProtocolError {
113 status,
114 raw: String::new(),
115 source: err.into(),
116 })
117 })?,
118 }));
119 }
120 let empty: Box<dyn Any> = Box::new(());
121 if let Ok(empty) = empty.downcast::<T>() {
122 return Ok(*empty);
123 }
124 Ok(res.json().await.map_err(anyhow::Error::new)?)
125}
126
127#[cfg(test)]
128pub(crate) fn check_cmd_json<T: Cmd>(cmd_json: Option<&str>, output_json: Option<&str>)
129where
130 <T as Cmd>::Output: 'static,
131{
132 if T::METHOD == http::Method::GET {
133 assert!(cmd_json.is_none())
134 } else {
135 let cmd_json = cmd_json.unwrap();
136 let cmd: T = serde_json::from_str(cmd_json).unwrap();
137 let cmd_json: serde_json::Value = serde_json::from_str(cmd_json).unwrap();
138 assert_eq!(cmd_json, serde_json::to_value(cmd).unwrap());
139 }
140 let empty: &dyn Any = &();
141 if empty.is::<T::Output>() {
142 assert!(output_json.is_none())
143 } else {
144 let output_json = output_json.unwrap();
145 let output: T::Output = serde_json::from_str(output_json).unwrap();
146 let output_json: serde_json::Value = serde_json::from_str(output_json).unwrap();
147 assert_eq!(output_json, serde_json::to_value(output).unwrap());
148 }
149}
150
151#[test]
152fn check_err_json() {
153 assert_eq!(
154 serde_json::to_string(&ApiErrorBody {
155 error: ApiErrorKind::AccountExpired {},
156 msg: "Account Expired".into(),
157 detail: None,
158 })
159 .unwrap(),
160 r#"{"error":{"AccountExpired":{}},"msg":"Account Expired"}"#,
161 );
162
163 assert_eq!(
164 serde_json::from_str::<ApiErrorBody>(
165 r#"
166 {
167 "error": {"AccountExpired": {"future_field": 7}},
168 "msg": "Account Expired",
169 "future field": true
170 }
171 "#
172 )
173 .unwrap(),
174 ApiErrorBody {
175 error: ApiErrorKind::AccountExpired {},
176 msg: "Account Expired".into(),
177 detail: None,
178 }
179 );
180
181 assert_eq!(
182 serde_json::to_string(&ApiErrorBody {
183 error: ApiErrorKind::Unknown(serde_json::Value::String("Other".into())),
184 msg: "Foo".into(),
185 detail: Some("Extra help".into()),
186 })
187 .unwrap(),
188 r#"{"error":"Other","msg":"Foo","detail":"Extra help"}"#,
189 );
190
191 assert_eq!(
192 serde_json::from_str::<ApiErrorBody>(
193 r#"
194 {
195 "error": {"FutureVariant": {"future_field": 7}},
196 "msg": "Helpful message",
197 "future field": true,
198 "detail": "debug"
199 }
200 "#
201 )
202 .unwrap(),
203 ApiErrorBody {
204 error: ApiErrorKind::Unknown(
205 serde_json::from_str(
206 r#"
207 {"FutureVariant": {"future_field": 7}}
208 "#
209 )
210 .unwrap()
211 ),
212 msg: "Helpful message".into(),
213 detail: Some("debug".to_string()),
214 }
215 );
216
217 assert_eq!(
218 serde_json::from_str::<ApiErrorBody>(
219 r#"
220 {
221 "error": "Other",
222 "msg": "Helpful message",
223 "future field": true
224 }
225 "#
226 )
227 .unwrap(),
228 ApiErrorBody {
229 error: ApiErrorKind::Unknown(serde_json::Value::String("Other".into())),
230 msg: "Helpful message".into(),
231 detail: None,
232 }
233 );
234}