Skip to main content

mcpr_integrations/
cloud_client.rs

1//! Cloud API client for mcpr.app — used by CLI onboarding and future integrations.
2//!
3//! JWT is held in-memory only (never persisted to disk). The only persistent
4//! artifact from setup is the project-scoped `mcpr_*` token written to `mcpr.toml`.
5
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8
9/// Default cloud API base URL.
10pub const DEFAULT_CLOUD_URL: &str = "https://api.mcpr.app";
11
12// ── Response / model types ─────────────────────────────────────────────
13
14#[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// ── Request bodies ─────────────────────────────────────────────────────
63
64#[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// ── Error type ─────────────────────────────────────────────────────────
98
99#[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/// API error response body from the cloud backend.
129#[derive(Deserialize)]
130struct ErrorBody {
131    #[serde(alias = "error")]
132    message: Option<String>,
133}
134
135// ── Client ─────────────────────────────────────────────────────────────
136
137/// Cloud API client. JWT is held in-memory only.
138pub struct CloudClient {
139    http: Client,
140    base_url: String,
141    jwt: Option<String>,
142}
143
144impl CloudClient {
145    /// Create a new client pointing at the given cloud API base URL.
146    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    /// Store the JWT token (in-memory only) after successful verification.
155    pub fn set_jwt(&mut self, token: String) {
156        self.jwt = Some(token);
157    }
158
159    /// Whether the client has been authenticated.
160    pub fn is_authenticated(&self) -> bool {
161        self.jwt.is_some()
162    }
163
164    // ── Auth (public, no JWT needed) ───────────────────────────────────
165
166    /// Request a CLI login code. Sends a 6-digit verification code to the email.
167    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    /// Verify the CLI login code and receive a JWT.
179    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    // ── Projects ───────────────────────────────────────────────────────
191
192    /// List all projects for the authenticated user.
193    pub async fn list_projects(&self) -> Result<Vec<Project>> {
194        self.get("/api/projects").await
195    }
196
197    /// Create a new project.
198    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    // ── Servers ────────────────────────────────────────────────────────
209
210    /// List servers in a project.
211    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    /// Create a new server in a project.
217    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    // ── Endpoints ──────────────────────────────────────────────────────
228
229    /// List endpoints for a server.
230    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    /// Create a new endpoint (tunnel subdomain) for a server.
236    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    // ── Tokens ─────────────────────────────────────────────────────────
247
248    /// Create a project-scoped token (works for both tunnel auth and cloud ingest).
249    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    // ── Helpers ────────────────────────────────────────────────────────
264
265    /// Authenticated GET request.
266    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    /// Build a request with the Bearer JWT header.
276    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    /// Parse a response, extracting a JSON body or error message.
285    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    // ── Helpers ───────────────────────────────────────────────────
330
331    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    // ── CloudClient::new ──────────────────────────────────────────
338
339    #[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    // ── set_jwt / is_authenticated ───────────────────────────────
353
354    #[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    // ── cli_login ────────────────────────────────────────────────
362
363    #[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    // ── cli_verify ───────────────────────────────────────────────
400
401    #[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    // ── list_projects ────────────────────────────────────────────
441
442    #[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    // ── create_project ───────────────────────────────────────────
475
476    #[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    // ── list_servers ─────────────────────────────────────────────
497
498    #[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    // ── create_server ────────────────────────────────────────────
516
517    #[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    // ── list_endpoints_by_server ─────────────────────────────────
540
541    #[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    // ── create_endpoint_by_server ────────────────────────────────
559
560    #[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    // ── create_project_token ─────────────────────────────────────
581
582    #[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    // ── Error handling ───────────────────────────────────────────
621
622    #[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    // ── Display impls ────────────────────────────────────────────
655
656    #[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}