obscuravpn_api/cmd/
mod.rs

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    /// Debugging information, not intended for end-users.
58    #[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}