1use reqwest::Client;
7use serde::{Deserialize, Serialize};
8
9pub const DEFAULT_CLOUD_URL: &str = "https://api.mcpr.app";
11
12#[derive(Debug, Deserialize)]
15pub struct CliLoginResponse {
16 pub request_id: String,
17}
18
19#[derive(Debug, Deserialize)]
20pub struct CliVerifyResponse {
21 pub token: String,
22 pub user: User,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26pub struct User {
27 pub id: String,
28 pub email: String,
29 pub name: Option<String>,
30}
31
32#[derive(Debug, Clone, Deserialize)]
33pub struct Project {
34 pub id: String,
35 pub name: String,
36 pub slug: String,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub struct Server {
41 pub id: String,
42 pub name: String,
43 pub slug: String,
44 pub project_id: String,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48pub struct Endpoint {
49 pub id: String,
50 pub name: String,
51 pub status: String,
52 pub server_id: Option<String>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct TunnelToken {
57 pub id: String,
58 pub token: String,
59 pub name: Option<String>,
60}
61
62#[derive(Serialize)]
65struct CliLoginRequest<'a> {
66 email: &'a str,
67}
68
69#[derive(Serialize)]
70struct CliVerifyRequest<'a> {
71 request_id: &'a str,
72 code: &'a str,
73}
74
75#[derive(Serialize)]
76struct CreateProjectRequest<'a> {
77 name: &'a str,
78 slug: &'a str,
79}
80
81#[derive(Serialize)]
82struct CreateServerRequest<'a> {
83 name: &'a str,
84 slug: &'a str,
85}
86
87#[derive(Serialize)]
88struct CreateEndpointRequest<'a> {
89 name: &'a str,
90}
91
92#[derive(Serialize)]
93struct CreateTokenRequest<'a> {
94 name: Option<&'a str>,
95}
96
97#[derive(Debug)]
100pub struct CloudError {
101 pub status: Option<u16>,
102 pub message: String,
103}
104
105impl std::fmt::Display for CloudError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 if let Some(status) = self.status {
108 write!(f, "cloud API error ({}): {}", status, self.message)
109 } else {
110 write!(f, "cloud API error: {}", self.message)
111 }
112 }
113}
114
115impl std::error::Error for CloudError {}
116
117impl From<reqwest::Error> for CloudError {
118 fn from(e: reqwest::Error) -> Self {
119 CloudError {
120 status: e.status().map(|s| s.as_u16()),
121 message: e.to_string(),
122 }
123 }
124}
125
126type Result<T> = std::result::Result<T, CloudError>;
127
128#[derive(Deserialize)]
130struct ErrorBody {
131 #[serde(alias = "error")]
132 message: Option<String>,
133}
134
135pub struct CloudClient {
139 http: Client,
140 base_url: String,
141 jwt: Option<String>,
142}
143
144impl CloudClient {
145 pub fn new(base_url: &str) -> Self {
147 Self {
148 http: Client::new(),
149 base_url: base_url.trim_end_matches('/').to_string(),
150 jwt: None,
151 }
152 }
153
154 pub fn set_jwt(&mut self, token: String) {
156 self.jwt = Some(token);
157 }
158
159 pub fn is_authenticated(&self) -> bool {
161 self.jwt.is_some()
162 }
163
164 pub async fn cli_login(&self, email: &str) -> Result<CliLoginResponse> {
168 let url = format!("{}/api/auth/cli/login", self.base_url);
169 let resp = self
170 .http
171 .post(&url)
172 .json(&CliLoginRequest { email })
173 .send()
174 .await?;
175 Self::parse_response(resp).await
176 }
177
178 pub async fn cli_verify(&self, request_id: &str, code: &str) -> Result<CliVerifyResponse> {
180 let url = format!("{}/api/auth/cli/verify", self.base_url);
181 let resp = self
182 .http
183 .post(&url)
184 .json(&CliVerifyRequest { request_id, code })
185 .send()
186 .await?;
187 Self::parse_response(resp).await
188 }
189
190 pub async fn list_projects(&self) -> Result<Vec<Project>> {
194 self.get("/api/projects").await
195 }
196
197 pub async fn create_project(&self, name: &str, slug: &str) -> Result<Project> {
199 let url = format!("{}/api/projects", self.base_url);
200 let resp = self
201 .authed_request(reqwest::Method::POST, &url)
202 .json(&CreateProjectRequest { name, slug })
203 .send()
204 .await?;
205 Self::parse_response(resp).await
206 }
207
208 pub async fn list_servers(&self, project_id: &str) -> Result<Vec<Server>> {
212 self.get(&format!("/api/servers/by-project/{project_id}"))
213 .await
214 }
215
216 pub async fn create_server(&self, project_id: &str, name: &str, slug: &str) -> Result<Server> {
218 let url = format!("{}/api/servers/by-project/{project_id}", self.base_url);
219 let resp = self
220 .authed_request(reqwest::Method::POST, &url)
221 .json(&CreateServerRequest { name, slug })
222 .send()
223 .await?;
224 Self::parse_response(resp).await
225 }
226
227 pub async fn list_endpoints_by_server(&self, server_id: &str) -> Result<Vec<Endpoint>> {
231 self.get(&format!("/api/endpoints/by-server/{server_id}"))
232 .await
233 }
234
235 pub async fn create_endpoint_by_server(&self, server_id: &str, name: &str) -> Result<Endpoint> {
237 let url = format!("{}/api/endpoints/by-server/{server_id}", self.base_url);
238 let resp = self
239 .authed_request(reqwest::Method::POST, &url)
240 .json(&CreateEndpointRequest { name })
241 .send()
242 .await?;
243 Self::parse_response(resp).await
244 }
245
246 pub async fn create_project_token(
250 &self,
251 project_id: &str,
252 name: Option<&str>,
253 ) -> Result<TunnelToken> {
254 let url = format!("{}/api/projects/{project_id}/tokens", self.base_url);
255 let resp = self
256 .authed_request(reqwest::Method::POST, &url)
257 .json(&CreateTokenRequest { name })
258 .send()
259 .await?;
260 Self::parse_response(resp).await
261 }
262
263 async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
267 let url = format!("{}{path}", self.base_url);
268 let resp = self
269 .authed_request(reqwest::Method::GET, &url)
270 .send()
271 .await?;
272 Self::parse_response(resp).await
273 }
274
275 fn authed_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
277 let mut req = self.http.request(method, url);
278 if let Some(jwt) = &self.jwt {
279 req = req.bearer_auth(jwt);
280 }
281 req
282 }
283
284 async fn parse_response<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T> {
286 let status = resp.status();
287 if status.is_success() {
288 resp.json::<T>().await.map_err(CloudError::from)
289 } else {
290 let code = status.as_u16();
291 let body = resp.text().await.unwrap_or_default();
292 let message = serde_json::from_str::<ErrorBody>(&body)
293 .ok()
294 .and_then(|b| b.message)
295 .unwrap_or(body);
296 Err(CloudError {
297 status: Some(code),
298 message,
299 })
300 }
301 }
302}
303
304impl std::fmt::Display for Project {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 write!(f, "{}", self.name)
307 }
308}
309
310impl std::fmt::Display for Server {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 write!(f, "{}", self.name)
313 }
314}
315
316impl std::fmt::Display for Endpoint {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 write!(f, "{}", self.name)
319 }
320}
321
322#[cfg(test)]
323#[allow(non_snake_case)]
324mod tests {
325 use super::*;
326 use wiremock::matchers::{body_json, header, method, path};
327 use wiremock::{Mock, MockServer, ResponseTemplate};
328
329 fn authed_client(base_url: &str) -> CloudClient {
332 let mut c = CloudClient::new(base_url);
333 c.set_jwt("test-jwt-token".into());
334 c
335 }
336
337 #[test]
340 fn new__strips_trailing_slash() {
341 let c = CloudClient::new("https://api.mcpr.app/");
342 assert_eq!(c.base_url, "https://api.mcpr.app");
343 }
344
345 #[test]
346 fn new__starts_unauthenticated() {
347 let c = CloudClient::new("https://api.mcpr.app");
348 assert!(!c.is_authenticated());
349 assert!(c.jwt.is_none());
350 }
351
352 #[test]
355 fn set_jwt__makes_authenticated() {
356 let mut c = CloudClient::new("http://localhost");
357 c.set_jwt("tok".into());
358 assert!(c.is_authenticated());
359 }
360
361 #[tokio::test]
364 async fn cli_login__success() {
365 let server = MockServer::start().await;
366 Mock::given(method("POST"))
367 .and(path("/api/auth/cli/login"))
368 .and(body_json(serde_json::json!({"email": "a@b.com"})))
369 .respond_with(
370 ResponseTemplate::new(200)
371 .set_body_json(serde_json::json!({"request_id": "req-123"})),
372 )
373 .mount(&server)
374 .await;
375
376 let client = CloudClient::new(&server.uri());
377 let resp = client.cli_login("a@b.com").await.unwrap();
378 assert_eq!(resp.request_id, "req-123");
379 }
380
381 #[tokio::test]
382 async fn cli_login__server_error() {
383 let server = MockServer::start().await;
384 Mock::given(method("POST"))
385 .and(path("/api/auth/cli/login"))
386 .respond_with(
387 ResponseTemplate::new(400)
388 .set_body_json(serde_json::json!({"error": "invalid email"})),
389 )
390 .mount(&server)
391 .await;
392
393 let client = CloudClient::new(&server.uri());
394 let err = client.cli_login("bad").await.unwrap_err();
395 assert_eq!(err.status, Some(400));
396 assert!(err.message.contains("invalid email"));
397 }
398
399 #[tokio::test]
402 async fn cli_verify__success() {
403 let server = MockServer::start().await;
404 Mock::given(method("POST"))
405 .and(path("/api/auth/cli/verify"))
406 .and(body_json(
407 serde_json::json!({"request_id": "req-1", "code": "123456"}),
408 ))
409 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
410 "token": "jwt-xyz",
411 "user": {"id": "u1", "email": "a@b.com", "name": "Alice"}
412 })))
413 .mount(&server)
414 .await;
415
416 let client = CloudClient::new(&server.uri());
417 let resp = client.cli_verify("req-1", "123456").await.unwrap();
418 assert_eq!(resp.token, "jwt-xyz");
419 assert_eq!(resp.user.email, "a@b.com");
420 assert_eq!(resp.user.name.as_deref(), Some("Alice"));
421 }
422
423 #[tokio::test]
424 async fn cli_verify__invalid_code() {
425 let server = MockServer::start().await;
426 Mock::given(method("POST"))
427 .and(path("/api/auth/cli/verify"))
428 .respond_with(
429 ResponseTemplate::new(401)
430 .set_body_json(serde_json::json!({"error": "invalid code"})),
431 )
432 .mount(&server)
433 .await;
434
435 let client = CloudClient::new(&server.uri());
436 let err = client.cli_verify("req-1", "000000").await.unwrap_err();
437 assert_eq!(err.status, Some(401));
438 }
439
440 #[tokio::test]
443 async fn list_projects__sends_bearer_token() {
444 let server = MockServer::start().await;
445 Mock::given(method("GET"))
446 .and(path("/api/projects"))
447 .and(header("Authorization", "Bearer test-jwt-token"))
448 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
449 {"id": "p1", "name": "Study Kit", "slug": "study-kit"}
450 ])))
451 .mount(&server)
452 .await;
453
454 let client = authed_client(&server.uri());
455 let projects = client.list_projects().await.unwrap();
456 assert_eq!(projects.len(), 1);
457 assert_eq!(projects[0].slug, "study-kit");
458 }
459
460 #[tokio::test]
461 async fn list_projects__empty_list() {
462 let server = MockServer::start().await;
463 Mock::given(method("GET"))
464 .and(path("/api/projects"))
465 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
466 .mount(&server)
467 .await;
468
469 let client = authed_client(&server.uri());
470 let projects = client.list_projects().await.unwrap();
471 assert!(projects.is_empty());
472 }
473
474 #[tokio::test]
477 async fn create_project__success() {
478 let server = MockServer::start().await;
479 Mock::given(method("POST"))
480 .and(path("/api/projects"))
481 .and(body_json(
482 serde_json::json!({"name": "My App", "slug": "my-app"}),
483 ))
484 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
485 "id": "p2", "name": "My App", "slug": "my-app"
486 })))
487 .mount(&server)
488 .await;
489
490 let client = authed_client(&server.uri());
491 let project = client.create_project("My App", "my-app").await.unwrap();
492 assert_eq!(project.id, "p2");
493 assert_eq!(project.slug, "my-app");
494 }
495
496 #[tokio::test]
499 async fn list_servers__routes_to_project() {
500 let server = MockServer::start().await;
501 Mock::given(method("GET"))
502 .and(path("/api/servers/by-project/p1"))
503 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
504 {"id": "s1", "name": "prod", "slug": "prod", "project_id": "p1"}
505 ])))
506 .mount(&server)
507 .await;
508
509 let client = authed_client(&server.uri());
510 let servers = client.list_servers("p1").await.unwrap();
511 assert_eq!(servers.len(), 1);
512 assert_eq!(servers[0].slug, "prod");
513 }
514
515 #[tokio::test]
518 async fn create_server__success() {
519 let server = MockServer::start().await;
520 Mock::given(method("POST"))
521 .and(path("/api/servers/by-project/p1"))
522 .and(body_json(
523 serde_json::json!({"name": "staging", "slug": "staging"}),
524 ))
525 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
526 "id": "s2", "name": "staging", "slug": "staging", "project_id": "p1"
527 })))
528 .mount(&server)
529 .await;
530
531 let client = authed_client(&server.uri());
532 let s = client
533 .create_server("p1", "staging", "staging")
534 .await
535 .unwrap();
536 assert_eq!(s.id, "s2");
537 }
538
539 #[tokio::test]
542 async fn list_endpoints__routes_to_server() {
543 let server = MockServer::start().await;
544 Mock::given(method("GET"))
545 .and(path("/api/endpoints/by-server/s1"))
546 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
547 {"id": "e1", "name": "my-ep", "status": "active", "server_id": "s1"}
548 ])))
549 .mount(&server)
550 .await;
551
552 let client = authed_client(&server.uri());
553 let eps = client.list_endpoints_by_server("s1").await.unwrap();
554 assert_eq!(eps.len(), 1);
555 assert_eq!(eps[0].name, "my-ep");
556 }
557
558 #[tokio::test]
561 async fn create_endpoint__success() {
562 let server = MockServer::start().await;
563 Mock::given(method("POST"))
564 .and(path("/api/endpoints/by-server/s1"))
565 .and(body_json(serde_json::json!({"name": "my-tunnel"})))
566 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
567 "id": "e2", "name": "my-tunnel", "status": "active", "server_id": "s1"
568 })))
569 .mount(&server)
570 .await;
571
572 let client = authed_client(&server.uri());
573 let ep = client
574 .create_endpoint_by_server("s1", "my-tunnel")
575 .await
576 .unwrap();
577 assert_eq!(ep.name, "my-tunnel");
578 }
579
580 #[tokio::test]
583 async fn create_project_token__success() {
584 let server = MockServer::start().await;
585 Mock::given(method("POST"))
586 .and(path("/api/projects/p1/tokens"))
587 .and(body_json(serde_json::json!({"name": "cli-setup"})))
588 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
589 "id": "t1", "token": "mcpr_abc123", "name": "cli-setup"
590 })))
591 .mount(&server)
592 .await;
593
594 let client = authed_client(&server.uri());
595 let token = client
596 .create_project_token("p1", Some("cli-setup"))
597 .await
598 .unwrap();
599 assert_eq!(token.token, "mcpr_abc123");
600 }
601
602 #[tokio::test]
603 async fn create_project_token__null_name() {
604 let server = MockServer::start().await;
605 Mock::given(method("POST"))
606 .and(path("/api/projects/p1/tokens"))
607 .and(body_json(serde_json::json!({"name": null})))
608 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
609 "id": "t2", "token": "mcpr_def456", "name": null
610 })))
611 .mount(&server)
612 .await;
613
614 let client = authed_client(&server.uri());
615 let token = client.create_project_token("p1", None).await.unwrap();
616 assert_eq!(token.token, "mcpr_def456");
617 assert!(token.name.is_none());
618 }
619
620 #[tokio::test]
623 async fn parse_response__extracts_error_field() {
624 let server = MockServer::start().await;
625 Mock::given(method("GET"))
626 .and(path("/api/projects"))
627 .respond_with(
628 ResponseTemplate::new(403).set_body_json(serde_json::json!({"error": "forbidden"})),
629 )
630 .mount(&server)
631 .await;
632
633 let client = authed_client(&server.uri());
634 let err = client.list_projects().await.unwrap_err();
635 assert_eq!(err.status, Some(403));
636 assert_eq!(err.message, "forbidden");
637 }
638
639 #[tokio::test]
640 async fn parse_response__falls_back_to_raw_body() {
641 let server = MockServer::start().await;
642 Mock::given(method("GET"))
643 .and(path("/api/projects"))
644 .respond_with(ResponseTemplate::new(500).set_body_string("internal failure"))
645 .mount(&server)
646 .await;
647
648 let client = authed_client(&server.uri());
649 let err = client.list_projects().await.unwrap_err();
650 assert_eq!(err.status, Some(500));
651 assert_eq!(err.message, "internal failure");
652 }
653
654 #[test]
657 fn cloud_error_display__with_status() {
658 let e = CloudError {
659 status: Some(404),
660 message: "not found".into(),
661 };
662 assert_eq!(e.to_string(), "cloud API error (404): not found");
663 }
664
665 #[test]
666 fn cloud_error_display__without_status() {
667 let e = CloudError {
668 status: None,
669 message: "connection refused".into(),
670 };
671 assert_eq!(e.to_string(), "cloud API error: connection refused");
672 }
673
674 #[test]
675 fn project_display() {
676 let p = Project {
677 id: "x".into(),
678 name: "Study Kit".into(),
679 slug: "study-kit".into(),
680 };
681 assert_eq!(p.to_string(), "Study Kit");
682 }
683
684 #[test]
685 fn server_display() {
686 let s = Server {
687 id: "x".into(),
688 name: "prod".into(),
689 slug: "prod".into(),
690 project_id: "y".into(),
691 };
692 assert_eq!(s.to_string(), "prod");
693 }
694
695 #[test]
696 fn endpoint_display() {
697 let e = Endpoint {
698 id: "x".into(),
699 name: "my-ep".into(),
700 status: "active".into(),
701 server_id: Some("s".into()),
702 };
703 assert_eq!(e.to_string(), "my-ep");
704 }
705}