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}