1use serde_json::Value;
13
14use crate::providers::api_base::{provider_api_base, provider_api_host};
15use crate::providers::egress::ProviderEgress;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum Identity {
24 User,
25 Oidc,
26}
27
28const API_BASE: &str = "https://api.vercel.com";
29
30pub fn api_base() -> String {
35 provider_api_base("NOMOREIDE_VERCEL_API_BASE", API_BASE)
36}
37
38fn egress() -> ProviderEgress {
41 ProviderEgress::new("vercel", vec![provider_api_host(&api_base())])
42}
43
44#[derive(Debug, Clone)]
45pub struct VercelApiError {
46 pub message: String,
47 pub status: u16,
48}
49
50impl std::fmt::Display for VercelApiError {
51 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(formatter, "{}", self.message)
53 }
54}
55
56#[derive(Debug, Clone)]
61pub struct RequestAuth {
62 pub token: String,
63 pub team_id: Option<String>,
64}
65
66pub async fn request(
68 auth: &RequestAuth,
69 method: &str,
70 path: &str,
71 accept: Option<&str>,
72) -> Result<Value, VercelApiError> {
73 let text = request_text(auth, method, path, accept).await?;
74 if accept == Some("text/plain") {
75 return Ok(Value::String(text));
76 }
77 serde_json::from_str(&text).or(Ok(Value::Null))
78}
79
80pub async fn request_text(
81 auth: &RequestAuth,
82 method: &str,
83 path: &str,
84 accept: Option<&str>,
85) -> Result<String, VercelApiError> {
86 request_text_with(auth, method, path, accept, None).await
87}
88
89pub async fn request_json(
98 auth: &RequestAuth,
99 method: &str,
100 path: &str,
101 body: Option<&Value>,
102) -> Result<Value, VercelApiError> {
103 let text = request_text_with(auth, method, path, None, body).await?;
104 serde_json::from_str(&text).or(Ok(Value::Null))
105}
106
107async fn request_text_with(
108 auth: &RequestAuth,
109 method: &str,
110 path: &str,
111 accept: Option<&str>,
112 body: Option<&Value>,
113) -> Result<String, VercelApiError> {
114 let mut url = if path.starts_with("http") {
115 path.to_string()
116 } else {
117 format!("{}{path}", api_base())
118 };
119 if let Some(team_id) = auth.team_id.as_ref() {
120 url = with_team_scope(&url, team_id);
121 }
122
123 let client = reqwest::Client::builder()
126 .redirect(reqwest::redirect::Policy::none())
127 .build()
128 .map_err(|error| VercelApiError {
129 message: format!("Vercel request failed: {error}"),
130 status: 0,
131 })?;
132 let verb = match method {
133 "POST" => reqwest::Method::POST,
134 "PATCH" => reqwest::Method::PATCH,
135 "DELETE" => reqwest::Method::DELETE,
136 _ => reqwest::Method::GET,
137 };
138 let token = auth.token.clone();
139 let accept_header = accept.unwrap_or("application/json").to_string();
140 let payload = body.cloned();
141 let response = egress()
142 .send(
143 &client,
144 &url,
145 |client, verb, target| {
146 let request = client
147 .request(verb, target)
148 .header("Authorization", format!("Bearer {token}"))
149 .header("Accept", accept_header.clone());
150 match payload.as_ref() {
151 Some(payload) => request.json(payload),
152 None => request,
153 }
154 },
155 verb,
156 )
157 .await
158 .map_err(|error| VercelApiError {
159 message: format!("Vercel request failed: {error}"),
160 status: 0,
161 })?;
162
163 let status = response.status();
164 let text = response.text().await.unwrap_or_default();
165 if status.is_success() {
166 return Ok(text);
167 }
168 Err(VercelApiError {
169 message: api_error_message(
170 &text,
171 status.as_u16(),
172 status.canonical_reason().unwrap_or_default(),
173 path,
174 ),
175 status: status.as_u16(),
176 })
177}
178
179fn query_value(value: &str) -> String {
187 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
188}
189
190fn with_team_scope(url: &str, team_id: &str) -> String {
202 let Ok(mut parsed) = url::Url::parse(url) else {
203 return url.to_string();
204 };
205 let existing: Vec<(String, String)> = parsed
206 .query_pairs()
207 .filter(|(key, _)| key != "teamId")
208 .map(|(key, value)| (key.into_owned(), value.into_owned()))
209 .collect();
210 parsed
211 .query_pairs_mut()
212 .clear()
213 .extend_pairs(existing)
214 .append_pair("teamId", team_id);
215 parsed.to_string()
216}
217
218fn api_error_message(body: &str, status: u16, reason: &str, path: &str) -> String {
223 serde_json::from_str::<Value>(body)
224 .ok()
225 .and_then(|value| {
226 value
227 .get("error")
228 .and_then(|error| error.get("message"))
229 .and_then(Value::as_str)
230 .map(str::to_string)
231 })
232 .unwrap_or_else(|| format!("Vercel returned {status} {reason} for {path}"))
233}
234
235pub struct VercelManager {
236 auth: RequestAuth,
237 identity: Identity,
238}
239
240impl VercelManager {
241 pub fn new(token: String, team_id: Option<String>, identity: Identity) -> Self {
242 VercelManager {
243 auth: RequestAuth { token, team_id },
244 identity,
245 }
246 }
247
248 pub async fn viewer(&self) -> Result<Value, VercelApiError> {
250 if self.identity == Identity::Oidc {
251 let claims = request(&self.auth, "GET", "/login/oauth/userinfo", None).await?;
252 return Ok(serde_json::json!({
253 "id": claims.get("sub").and_then(Value::as_str).unwrap_or(""),
254 "username": claims
255 .get("preferred_username")
256 .and_then(Value::as_str)
257 .or_else(|| claims.get("email").and_then(Value::as_str))
258 .unwrap_or("vercel"),
259 "email": claims.get("email").cloned().unwrap_or(Value::Null),
260 "avatar": claims.get("picture").cloned().unwrap_or(Value::Null),
261 }));
262 }
263 let data = request(&self.auth, "GET", "/v2/user", None).await?;
264 Ok(data.get("user").cloned().unwrap_or(Value::Null))
265 }
266
267 pub async fn list_teams(&self) -> Result<Vec<Value>, VercelApiError> {
268 let data = request(&self.auth, "GET", "/v2/teams?limit=100", None).await?;
269 Ok(data
270 .get("teams")
271 .and_then(Value::as_array)
272 .map(|teams| {
273 teams
274 .iter()
275 .map(|team| {
276 let mut row = serde_json::Map::new();
282 for key in ["id", "slug"] {
283 if let Some(value) = team.get(key) {
284 row.insert(key.into(), value.clone());
285 }
286 }
287 row.insert(
288 "name".into(),
289 team.get("name")
290 .filter(|value| !value.is_null())
291 .cloned()
292 .unwrap_or(Value::Null),
293 );
294 Value::Object(row)
295 })
296 .collect()
297 })
298 .unwrap_or_default())
299 }
300
301 pub async fn list_projects_raw(
308 &self,
309 search: Option<&str>,
310 repo_url: Option<&str>,
311 limit: Option<u32>,
312 ) -> Result<Vec<Value>, VercelApiError> {
313 let mut path = format!("/v10/projects?limit={}", limit.unwrap_or(50));
314 if let Some(search) = search.filter(|value| !value.is_empty()) {
315 path.push_str(&format!("&search={}", query_value(search)));
316 }
317 if let Some(repo_url) = repo_url {
318 path.push_str(&format!("&repoUrl={}", query_value(repo_url)));
319 }
320 let data = request(&self.auth, "GET", &path, None).await?;
321 Ok(match &data {
322 Value::Array(items) => items.clone(),
323 _ => data
324 .get("projects")
325 .and_then(Value::as_array)
326 .cloned()
327 .unwrap_or_default(),
328 })
329 }
330
331 pub async fn list_projects(
332 &self,
333 search: Option<&str>,
334 repo_url: Option<&str>,
335 limit: Option<u32>,
336 ) -> Result<Vec<Value>, VercelApiError> {
337 let projects = self.list_projects_raw(search, repo_url, limit).await?;
338 Ok(projects.iter().map(normalize_project).collect())
339 }
340
341 pub async fn get_project_raw(&self, id_or_name: &str) -> Result<Value, VercelApiError> {
342 let path = format!("/v9/projects/{}", urlencoding::encode(id_or_name));
343 request(&self.auth, "GET", &path, None).await
344 }
345
346 pub async fn get_project(&self, id_or_name: &str) -> Result<Value, VercelApiError> {
347 Ok(normalize_project(&self.get_project_raw(id_or_name).await?))
348 }
349
350 pub async fn list_deployments_raw(
352 &self,
353 project_id: &str,
354 target: Option<&str>,
355 limit: Option<u32>,
356 ) -> Result<Vec<Value>, VercelApiError> {
357 let mut path = format!(
358 "/v7/deployments?projectId={}&limit={}",
359 query_value(project_id),
360 limit.unwrap_or(20)
361 );
362 if target == Some("production") {
365 path.push_str("&target=production");
366 }
367 let data = request(&self.auth, "GET", &path, None).await?;
368 let deployments = data
369 .get("deployments")
370 .and_then(Value::as_array)
371 .cloned()
372 .unwrap_or_default();
373
374 if target == Some("preview") {
375 return Ok(deployments
376 .into_iter()
377 .filter(|deployment| {
378 deployment.get("target") != Some(&Value::String("production".into()))
379 })
380 .collect());
381 }
382 Ok(deployments)
383 }
384
385 pub async fn list_deployments(
386 &self,
387 project_id: &str,
388 target: Option<&str>,
389 limit: Option<u32>,
390 ) -> Result<Vec<Value>, VercelApiError> {
391 let deployments = self.list_deployments_raw(project_id, target, limit).await?;
392 Ok(deployments.iter().map(normalize_deployment).collect())
393 }
394
395 pub async fn get_deployment_raw(&self, id_or_url: &str) -> Result<Value, VercelApiError> {
396 let path = format!(
397 "/v13/deployments/{}?withGitRepoInfo=true",
398 urlencoding::encode(id_or_url)
399 );
400 request(&self.auth, "GET", &path, None).await
401 }
402
403 pub async fn get_deployment(&self, id_or_url: &str) -> Result<Value, VercelApiError> {
404 let raw = self.get_deployment_raw(id_or_url).await?;
405 let mut deployment = normalize_deployment(&raw);
406 if let Some(object) = deployment.as_object_mut() {
407 object.insert(
408 "aliases".into(),
409 raw.get("alias").cloned().unwrap_or(Value::Array(vec![])),
410 );
411 if let Some(building_at) = raw.get("buildingAt") {
412 object.insert("buildingAt".into(), building_at.clone());
413 }
414 if let Some(error_message) = raw.get("errorMessage").filter(|v| !v.is_null()) {
415 object.insert("errorMessage".into(), error_message.clone());
416 }
417 }
418 Ok(deployment)
419 }
420
421 pub async fn deployment_build_logs(
425 &self,
426 id_or_url: &str,
427 limit: Option<u32>,
428 ) -> Result<Vec<Value>, VercelApiError> {
429 let path = format!(
430 "/v3/deployments/{}/events?builds=1&direction=backward&limit={}",
431 urlencoding::encode(id_or_url),
432 limit.unwrap_or(500)
433 );
434 let raw = request_text(&self.auth, "GET", &path, Some("text/plain")).await?;
435 Ok(parse_build_log_events(&raw))
436 }
437
438 pub async fn list_env(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
445 let path = format!(
446 "/v9/projects/{}/env?limit=200",
447 urlencoding::encode(project_id)
448 );
449 let data = request(&self.auth, "GET", &path, None).await?;
450 let envs = match &data {
451 Value::Array(items) => items.clone(),
452 _ => data
453 .get("envs")
454 .and_then(Value::as_array)
455 .cloned()
456 .unwrap_or_default(),
457 };
458 let mut normalized: Vec<Value> = envs.iter().map(normalize_env_var).collect();
459 normalized.sort_by(|a, b| {
460 let key = |value: &Value| {
461 value
462 .get("key")
463 .and_then(Value::as_str)
464 .unwrap_or("")
465 .to_string()
466 };
467 key(a).cmp(&key(b))
468 });
469 Ok(normalized)
470 }
471
472 pub async fn list_env_raw(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
479 let path = format!(
480 "/v9/projects/{}/env?limit=200",
481 urlencoding::encode(project_id)
482 );
483 let data = request(&self.auth, "GET", &path, None).await?;
484 Ok(match &data {
485 Value::Array(items) => items.clone(),
486 _ => data
487 .get("envs")
488 .and_then(Value::as_array)
489 .cloned()
490 .unwrap_or_default(),
491 })
492 }
493
494 pub async fn list_domains_raw(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
497 let path = format!(
498 "/v9/projects/{}/domains?limit=100",
499 urlencoding::encode(project_id)
500 );
501 let data = request(&self.auth, "GET", &path, None).await?;
502 Ok(data
503 .get("domains")
504 .and_then(Value::as_array)
505 .cloned()
506 .unwrap_or_default())
507 }
508
509 pub async fn env_value(
513 &self,
514 project_id: &str,
515 env_id: &str,
516 ) -> Result<String, VercelApiError> {
517 let path = format!(
518 "/v9/projects/{}/env/{}",
519 urlencoding::encode(project_id),
520 urlencoding::encode(env_id)
521 );
522 let data = request(&self.auth, "GET", &path, None).await?;
523 Ok(data
524 .get("value")
525 .and_then(Value::as_str)
526 .unwrap_or("")
527 .to_string())
528 }
529
530 pub async fn list_domains(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
531 let path = format!(
532 "/v9/projects/{}/domains?limit=100",
533 urlencoding::encode(project_id)
534 );
535 let data = request(&self.auth, "GET", &path, None).await?;
536 Ok(data
537 .get("domains")
538 .and_then(Value::as_array)
539 .map(|domains| domains.iter().map(normalize_domain).collect())
540 .unwrap_or_default())
541 }
542
543 pub async fn deployment_runtime_logs(
550 &self,
551 id_or_url: &str,
552 limit: Option<u32>,
553 ) -> Result<Vec<Value>, VercelApiError> {
554 let path = format!(
555 "/v1/deployments/{}/runtime-logs?limit={}",
556 urlencoding::encode(id_or_url),
557 limit.unwrap_or(200)
558 );
559 match request_text(&self.auth, "GET", &path, Some("text/plain")).await {
560 Ok(raw) => Ok(parse_runtime_log_events(&raw)),
561 Err(error) if RUNTIME_LOGS_UNAVAILABLE.contains(&error.status) => Ok(vec![]),
562 Err(error) => Err(error),
563 }
564 }
565}
566
567const RUNTIME_LOGS_UNAVAILABLE: [u16; 3] = [402, 403, 404];
571
572pub fn repo_url(remote_url: &str) -> Option<String> {
574 let trimmed = remote_url
575 .trim()
576 .trim_end_matches(".git")
577 .trim_end_matches('/');
578 if let Some(rest) = trimmed.strip_prefix("git@") {
579 let (host, path) = rest.split_once(':')?;
580 return Some(format!("https://{host}/{path}"));
581 }
582 if trimmed.starts_with("https://") {
583 return Some(trimmed.to_string());
584 }
585 if let Some(rest) = trimmed.strip_prefix("http://") {
586 return Some(format!("https://{rest}"));
587 }
588 None
589}
590
591pub fn parse_build_log_events(raw: &str) -> Vec<Value> {
595 let trimmed = raw.trim();
596 if trimmed.is_empty() {
597 return vec![];
598 }
599
600 let mut events: Vec<Value> = Vec::new();
601 match serde_json::from_str::<Value>(trimmed) {
602 Ok(Value::Array(items)) => events.extend(items),
603 Ok(other) => events.push(other),
604 Err(_) => {
605 for line in trimmed.lines().filter(|line| !line.trim().is_empty()) {
606 if let Ok(event) = serde_json::from_str::<Value>(line) {
607 events.push(event);
608 }
609 }
610 }
611 }
612
613 let mut lines: Vec<Value> = events
614 .iter()
615 .enumerate()
616 .map(|(index, event)| {
617 let payload = event.get("payload");
618 let created = event.get("created").and_then(Value::as_i64);
619 let created_at = payload
620 .and_then(|p| p.get("date"))
621 .and_then(Value::as_i64)
622 .or(created)
623 .unwrap_or(0);
624 let id = payload
625 .and_then(|p| p.get("id"))
626 .and_then(Value::as_str)
627 .map(str::to_string)
628 .unwrap_or_else(|| format!("{}-{index}", created.unwrap_or(index as i64)));
629 let text = payload
630 .and_then(|p| p.get("text"))
631 .and_then(Value::as_str)
632 .unwrap_or("");
633 serde_json::json!({
634 "id": id,
635 "createdAt": created_at,
636 "type": event.get("type").and_then(Value::as_str).unwrap_or("stdout"),
637 "text": strip_ansi(text).trim_end(),
638 })
639 })
640 .filter(|line| {
641 line.get("text")
642 .and_then(Value::as_str)
643 .is_some_and(|text| !text.is_empty())
644 })
645 .collect();
646
647 lines.sort_by_key(|line| line.get("createdAt").and_then(Value::as_i64).unwrap_or(0));
648 lines
649}
650
651fn strip_ansi(text: &str) -> String {
654 let mut out = String::with_capacity(text.len());
655 let mut chars = text.chars().peekable();
656 while let Some(character) = chars.next() {
657 if character != '\u{1b}' {
658 out.push(character);
659 continue;
660 }
661 if chars.peek() != Some(&'[') {
662 continue;
663 }
664 chars.next();
665 for inner in chars.by_ref() {
667 if !inner.is_ascii_digit() && inner != ';' {
668 break;
669 }
670 }
671 }
672 out
673}
674
675pub fn parse_runtime_log_events(raw: &str) -> Vec<Value> {
679 let trimmed = raw.trim();
680 if trimmed.is_empty() {
681 return vec![];
682 }
683
684 let mut rows: Vec<Value> = Vec::new();
685 for line in trimmed.lines().filter(|line| !line.trim().is_empty()) {
686 match serde_json::from_str::<Value>(line) {
687 Ok(Value::Array(items)) => rows.extend(items),
688 Ok(other) => rows.push(other),
689 Err(_) => {}
690 }
691 }
692
693 let mut lines: Vec<Value> = rows
694 .iter()
695 .enumerate()
696 .map(|(index, row)| {
697 let created_at = row
698 .get("timestampInMs")
699 .or_else(|| row.get("timestamp"))
700 .and_then(Value::as_i64)
701 .unwrap_or(0);
702 let id = row
707 .get("rowId")
708 .or_else(|| row.get("requestId"))
709 .and_then(Value::as_str)
710 .map(str::to_string)
711 .unwrap_or_else(|| {
712 let stamp = row
713 .get("timestampInMs")
714 .and_then(Value::as_i64)
715 .map(|value| value.to_string())
716 .unwrap_or_else(|| index.to_string());
717 format!("{stamp}-{index}")
718 });
719 let mut line = serde_json::Map::new();
720 line.insert("id".into(), Value::String(id));
721 line.insert("createdAt".into(), Value::from(created_at));
722 line.insert(
723 "level".into(),
724 Value::from(row.get("level").and_then(Value::as_str).unwrap_or("info")),
725 );
726 line.insert(
727 "message".into(),
728 Value::from(
729 row.get("message")
730 .and_then(Value::as_str)
731 .unwrap_or("")
732 .trim_end(),
733 ),
734 );
735 for key in ["source", "statusCode", "requestMethod", "requestPath"] {
740 if let Some(value) = row.get(key) {
741 line.insert(key.into(), value.clone());
742 }
743 }
744 Value::Object(line)
745 })
746 .filter(|line| {
747 line.get("message")
748 .and_then(Value::as_str)
749 .is_some_and(|message| !message.is_empty())
750 })
751 .collect();
752
753 lines.sort_by_key(|line| line.get("createdAt").and_then(Value::as_i64).unwrap_or(0));
754 lines
755}
756
757fn normalize_env_var(env: &Value) -> Value {
758 let target = match env.get("target") {
759 Some(Value::Array(items)) => Value::Array(items.clone()),
760 Some(Value::String(single)) => Value::Array(vec![Value::String(single.clone())]),
761 _ => Value::Array(vec![]),
762 };
763 serde_json::json!({
764 "id": env
765 .get("id")
766 .or_else(|| env.get("key"))
767 .cloned()
768 .unwrap_or(Value::Null),
769 "key": env.get("key").cloned().unwrap_or(Value::Null),
770 "target": target,
771 "type": env.get("type").and_then(Value::as_str).unwrap_or("encrypted"),
772 "gitBranch": env.get("gitBranch").cloned().unwrap_or(Value::Null),
773 "comment": env.get("comment").cloned().unwrap_or(Value::Null),
774 "createdAt": env.get("createdAt").cloned().unwrap_or(Value::Null),
775 "updatedAt": env.get("updatedAt").cloned().unwrap_or(Value::Null),
776 })
777}
778
779fn normalize_domain(domain: &Value) -> Value {
780 let verification: Vec<Value> = domain
781 .get("verification")
782 .and_then(Value::as_array)
783 .map(|entries| {
784 entries
785 .iter()
786 .filter(|entry| {
787 entry.get("domain").and_then(Value::as_str).is_some()
788 && entry.get("value").and_then(Value::as_str).is_some()
789 })
790 .map(|entry| {
791 serde_json::json!({
792 "type": entry.get("type").and_then(Value::as_str).unwrap_or("TXT"),
793 "domain": entry.get("domain").cloned().unwrap_or(Value::Null),
794 "value": entry.get("value").cloned().unwrap_or(Value::Null),
795 "reason": entry.get("reason").cloned().unwrap_or(Value::Null),
796 })
797 })
798 .collect()
799 })
800 .unwrap_or_default();
801
802 serde_json::json!({
803 "name": domain.get("name").cloned().unwrap_or(Value::Null),
804 "apexName": domain.get("apexName").cloned().unwrap_or(Value::Null),
805 "verified": domain.get("verified").and_then(Value::as_bool).unwrap_or(false),
806 "redirect": domain.get("redirect").cloned().unwrap_or(Value::Null),
807 "gitBranch": domain.get("gitBranch").cloned().unwrap_or(Value::Null),
808 "createdAt": domain.get("createdAt").cloned().unwrap_or(Value::Null),
809 "updatedAt": domain.get("updatedAt").cloned().unwrap_or(Value::Null),
810 "verification": verification,
811 })
812}
813
814fn normalize_project(project: &Value) -> Value {
815 let link = project.get("link").filter(|link| {
816 link.get("type")
817 .and_then(Value::as_str)
818 .is_some_and(|kind| !kind.is_empty())
819 });
820 serde_json::json!({
821 "id": project.get("id").cloned().unwrap_or(Value::Null),
822 "name": project.get("name").cloned().unwrap_or(Value::Null),
823 "framework": project.get("framework").cloned().unwrap_or(Value::Null),
824 "updatedAt": project.get("updatedAt").cloned().unwrap_or(Value::Null),
825 "link": link.map(|link| serde_json::json!({
826 "type": link.get("type").cloned().unwrap_or(Value::Null),
827 "org": link.get("org").cloned().unwrap_or(Value::Null),
828 "repo": link.get("repo").cloned().unwrap_or(Value::Null),
829 "productionBranch": link.get("productionBranch").cloned().unwrap_or(Value::Null),
830 })).unwrap_or(Value::Null),
831 "buildCommand": project.get("buildCommand").cloned().unwrap_or(Value::Null),
834 "devCommand": project.get("devCommand").cloned().unwrap_or(Value::Null),
835 "installCommand": project.get("installCommand").cloned().unwrap_or(Value::Null),
836 "outputDirectory": project.get("outputDirectory").cloned().unwrap_or(Value::Null),
837 "rootDirectory": project.get("rootDirectory").cloned().unwrap_or(Value::Null),
838 "nodeVersion": project.get("nodeVersion").cloned().unwrap_or(Value::Null),
839 "serverlessFunctionRegion": project
840 .get("serverlessFunctionRegion")
841 .cloned()
842 .unwrap_or(Value::Null),
843 })
844}
845
846fn normalize_deployment(deployment: &Value) -> Value {
847 let meta = deployment.get("meta");
848 let pick = |keys: [&str; 3]| -> Value {
849 keys.iter()
850 .find_map(|key| meta.and_then(|meta| meta.get(*key)).cloned())
851 .unwrap_or(Value::Null)
852 };
853 let target = deployment.get("target").cloned().unwrap_or(Value::Null);
854 let ready_substate = deployment.get("readySubstate").and_then(Value::as_str);
855
856 serde_json::json!({
857 "uid": deployment
858 .get("uid")
859 .or_else(|| deployment.get("id"))
860 .cloned()
861 .unwrap_or(Value::String(String::new())),
862 "name": deployment.get("name").cloned().unwrap_or(Value::Null),
863 "url": deployment.get("url").cloned().unwrap_or(Value::Null),
864 "state": deployment
865 .get("readyState")
866 .or_else(|| deployment.get("state"))
867 .cloned()
868 .unwrap_or(Value::String("QUEUED".into())),
869 "target": target,
870 "createdAt": deployment
871 .get("createdAt")
872 .or_else(|| deployment.get("created"))
873 .cloned()
874 .unwrap_or(Value::from(0)),
875 "readyAt": deployment
876 .get("readyAt")
877 .or_else(|| deployment.get("ready"))
878 .cloned()
879 .unwrap_or(Value::Null),
880 "isCurrentProduction": deployment.get("target").and_then(Value::as_str) == Some("production")
883 && ready_substate != Some("STAGED"),
884 "creator": deployment.get("creator").cloned().unwrap_or(Value::Null),
885 "meta": {
886 "branch": pick(["githubCommitRef", "gitlabCommitRef", "bitbucketCommitRef"]),
887 "sha": pick(["githubCommitSha", "gitlabCommitSha", "bitbucketCommitSha"]),
888 "commitMessage": pick(["githubCommitMessage", "gitlabCommitMessage", "bitbucketCommitMessage"]),
889 "commitAuthor": pick(["githubCommitAuthorName", "gitlabCommitAuthorName", "bitbucketCommitAuthorName"]),
890 },
891 "inspectorUrl": deployment.get("inspectorUrl").cloned().unwrap_or(Value::Null),
892 })
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn ssh_and_https_remotes_map_to_the_url_vercel_indexes() {
901 assert_eq!(
902 repo_url("git@github.com:acme/web.git").unwrap(),
903 "https://github.com/acme/web"
904 );
905 assert_eq!(
906 repo_url("https://github.com/acme/web/").unwrap(),
907 "https://github.com/acme/web"
908 );
909 assert_eq!(
911 repo_url("http://github.com/acme/web").unwrap(),
912 "https://github.com/acme/web"
913 );
914 assert!(repo_url("/local/path").is_none());
915 }
916
917 #[test]
918 fn a_production_deployment_staged_behind_the_alias_is_not_current() {
919 let staged = normalize_deployment(&serde_json::json!({
920 "uid": "dpl_1", "name": "web", "url": null,
921 "target": "production", "readySubstate": "STAGED",
922 }));
923 assert_eq!(staged["isCurrentProduction"], false);
924
925 let promoted = normalize_deployment(&serde_json::json!({
926 "uid": "dpl_2", "name": "web", "url": null,
927 "target": "production", "readySubstate": "PROMOTED",
928 }));
929 assert_eq!(promoted["isCurrentProduction"], true);
930 }
931
932 #[test]
933 fn deployment_ids_fall_back_across_the_api_versions_field_names() {
934 let by_id = normalize_deployment(&serde_json::json!({ "id": "dpl_x", "name": "web" }));
935 assert_eq!(by_id["uid"], "dpl_x");
936 assert_eq!(by_id["state"], "QUEUED");
937 }
938
939 #[test]
940 fn build_logs_accept_both_an_array_and_newline_delimited_json() {
941 let array = parse_build_log_events(
942 r#"[{"type":"stdout","created":2,"payload":{"id":"b","text":"second"}},
943 {"type":"stdout","created":1,"payload":{"id":"a","text":"first"}}]"#,
944 );
945 assert_eq!(array.len(), 2);
946 assert_eq!(array[0]["text"], "first");
948
949 let ndjson = parse_build_log_events(
950 "{\"type\":\"stdout\",\"created\":1,\"payload\":{\"text\":\"one\"}}\n{ broken\n{\"type\":\"stdout\",\"created\":2,\"payload\":{\"text\":\"two\"}}",
951 );
952 assert_eq!(ndjson.len(), 2);
954 }
955
956 #[test]
957 fn ansi_colour_codes_are_stripped_from_build_output() {
958 let raw = serde_json::json!({
962 "created": 1,
963 "payload": { "text": "\u{1b}[32mdone\u{1b}[0m" },
964 })
965 .to_string();
966
967 let logs = parse_build_log_events(&raw);
968 assert_eq!(logs[0]["text"], "done");
969 }
970
971 #[test]
972 fn blank_lines_are_dropped_so_the_log_has_no_holes() {
973 let logs = parse_build_log_events("{\"created\":1,\"payload\":{\"text\":\" \"}}");
974 assert!(logs.is_empty());
975 }
976
977 #[test]
980 fn an_error_body_without_a_message_names_the_request() {
981 assert_eq!(
982 api_error_message("not json", 404, "Not Found", "/v2/user"),
983 "Vercel returned 404 Not Found for /v2/user"
984 );
985 assert_eq!(
986 api_error_message(
987 r#"{"error":{"message":"Forbidden"}}"#,
988 403,
989 "Forbidden",
990 "/v2/user"
991 ),
992 "Forbidden"
993 );
994 }
995}