sanity_api/
lib.rs

1use reqwest::{header, Client};
2use serde::{de::DeserializeOwned, Deserialize, Serialize};
3use url::Url;
4
5#[async_trait::async_trait]
6pub trait Api {
7    async fn login(&self, request: LoginRequest) -> anyhow::Result<LoginResponse>;
8
9    async fn create_project(
10        &self,
11        request: CreateProjectRequest,
12        token: String,
13    ) -> anyhow::Result<CreateProjectResponse>;
14
15    async fn register_task_version(
16        &self,
17        request: CreateTaskVersionRequest,
18        token: String,
19    ) -> anyhow::Result<CreateTaskVersionResponse>;
20}
21
22#[derive(Serialize, Deserialize)]
23pub struct Error {
24    pub message: String,
25    pub chain: Vec<String>,
26}
27
28impl<'a> From<&'a anyhow::Error> for Error {
29    fn from(err: &'a anyhow::Error) -> Self {
30        Error {
31            message: err.to_string(),
32            chain: err.chain().map(|e| e.to_string()).collect::<Vec<_>>(),
33        }
34    }
35}
36
37impl From<Error> for anyhow::Error {
38    fn from(value: Error) -> Self {
39        match value.chain.as_slice() {
40            [] => anyhow::Error::msg(value.message),
41            [head @ .., last] => head
42                .into_iter()
43                .rev()
44                .fold(anyhow::Error::msg(last.to_string()), |e, m| {
45                    e.context(m.to_string())
46                }),
47        }
48    }
49}
50
51#[derive(Serialize, Deserialize)]
52pub struct LoginRequest {
53    pub email: String,
54    pub password: String,
55}
56
57#[derive(Serialize, Deserialize)]
58pub enum LoginResponse {
59    CreatedAccount { email: String, token: String },
60    LoggedIn { email: String, token: String },
61}
62
63#[derive(Serialize, Deserialize)]
64pub struct CreateProjectRequest {
65    pub nickname: Option<String>,
66}
67
68#[derive(Serialize, Deserialize)]
69pub struct CreateProjectResponse {
70    pub id: String,
71    pub nickname: Option<String>,
72}
73
74#[derive(Serialize, Deserialize)]
75pub struct CreateTaskVersionRequest {
76    pub project_id: String,
77    pub task_id: String,
78}
79
80#[derive(Serialize, Deserialize, Debug)]
81pub struct CreateTaskVersionResponse {
82    pub project_id: String,
83    pub task_id: String,
84    pub hash: String,
85    pub created_at: chrono::NaiveDateTime,
86}
87
88#[derive(Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "snake_case")]
90pub enum Method {
91    Login,
92    CreateProject,
93    RegisterTaskVersion,
94}
95
96pub struct HttpApi {
97    client: Client,
98    base: Url,
99}
100
101impl HttpApi {
102    pub fn new(base: &Url) -> anyhow::Result<Self> {
103        anyhow::ensure!(base.as_str().ends_with("/"), "API url must end with a /");
104
105        Ok(Self {
106            client: Client::new(),
107            base: base.clone(),
108        })
109    }
110
111    async fn call_endpoint<I, O>(
112        &self,
113        endpoint: Method,
114        body: I,
115        token: impl Into<Option<String>>,
116    ) -> anyhow::Result<O>
117    where
118        I: Serialize,
119        O: DeserializeOwned,
120    {
121        let url = self.base.join(&serde_json::to_string(&endpoint)?)?;
122        let request = self.client.post(url);
123        let request = if let Some(token) = token.into() {
124            request.header(header::AUTHORIZATION, format!("Bearer {token}"))
125        } else {
126            request
127        };
128
129        let response = request.json(&body).send().await?;
130
131        let status = response.status();
132        if status.is_success() {
133            Ok(response.json().await?)
134        } else if status.is_client_error() || status.is_server_error() {
135            Err(response.json::<Error>().await?.into())
136        } else {
137            anyhow::bail!("Unhandled error code {}", status);
138        }
139    }
140}
141
142#[async_trait::async_trait]
143impl Api for HttpApi {
144    async fn login(&self, request: LoginRequest) -> anyhow::Result<LoginResponse> {
145        self.call_endpoint(Method::Login, request, None).await
146    }
147
148    async fn create_project(
149        &self,
150        request: CreateProjectRequest,
151        token: String,
152    ) -> anyhow::Result<CreateProjectResponse> {
153        self.call_endpoint(Method::CreateProject, request, token)
154            .await
155    }
156
157    async fn register_task_version(
158        &self,
159        request: CreateTaskVersionRequest,
160        token: String,
161    ) -> anyhow::Result<CreateTaskVersionResponse> {
162        self.call_endpoint(Method::RegisterTaskVersion, request, token)
163            .await
164    }
165}